Compare commits

..

11 Commits

120 changed files with 1975 additions and 5511 deletions

View File

@@ -12,7 +12,6 @@ language: Match repository languages and conventions
## Rules
0. **Principle:** just the facts m'am.
1. **Default to the least complex solution.** Fix the problem directly
where it occurs; avoid new layers, indirection, or patterns unless
strictly necessary.

View File

@@ -4,6 +4,7 @@ alwaysApply: false
---
✅ use system date command to timestamp all interactions with accurate date and
time
✅ python script files must always have a blank line at their end
✅ remove whitespace at the end of lines
✅ use npm run lint-fix to check for warnings
✅ do not use npm run dev let me handle running and supplying feedback
@@ -21,10 +22,12 @@ alwaysApply: false
- [ ] **Timestamp Usage**: Include accurate timestamps in all interactions
- [ ] **Code Quality**: Use npm run lint-fix to check for warnings
- [ ] **File Standards**: Ensure Python files have blank line at end
- [ ] **Whitespace**: Remove trailing whitespace from all lines
### After Development
- [ ] **Linting Check**: Run npm run lint-fix to verify code quality
- [ ] **File Validation**: Confirm Python files end with blank line
- [ ] **Whitespace Review**: Verify no trailing whitespace remains
- [ ] **Documentation**: Update relevant documentation with changes

View File

@@ -1,5 +1,6 @@
---
alwaysApply: false
alwaysApply: true
inherits: base_context.mdc
---
```json
{

View File

@@ -1,6 +1,7 @@
---
alwaysApply: false
alwaysApply: true
---
# Meta-Rule: Core Always-On Rules
**Author**: Matthew Raymer
@@ -293,6 +294,9 @@ or context. They form the foundation for all AI assistant behavior.
**See also**:
- `.cursor/rules/meta_feature_planning.mdc` for workflow-specific rules
- `.cursor/rules/meta_bug_diagnosis.mdc` for investigation workflows
- `.cursor/rules/meta_bug_fixing.mdc` for fix implementation
- `.cursor/rules/meta_feature_implementation.mdc` for feature development
**Status**: Active core always-on meta-rule
**Priority**: Critical (applies to every prompt)

View File

@@ -5,7 +5,7 @@
**Status**: 🎯 **ACTIVE** - Version control guidelines
## Core Principles
### 0) let the developer control git
### 1) Version-Control Ownership
- **MUST NOT** run `git add`, `git commit`, or any write action.

View File

@@ -19,16 +19,15 @@ npm run lint-fix || {
}
# Then run Build Architecture Guard
#echo "🏗️ Running Build Architecture Guard..."
#bash ./scripts/build-arch-guard.sh --staged || {
# echo
# echo "❌ Build Architecture Guard failed. Please fix the issues and try again."
# echo "💡 To bypass this check for emergency commits, use:"
# echo " git commit --no-verify"
# echo
# exit 1
#}
echo "🏗️ Running Build Architecture Guard..."
bash ./scripts/build-arch-guard.sh --staged || {
echo
echo "❌ Build Architecture Guard failed. Please fix the issues and try again."
echo "💡 To bypass this check for emergency commits, use:"
echo " git commit --no-verify"
echo
exit 1
}
echo "✅ All pre-commit checks passed!"

View File

@@ -18,10 +18,10 @@ else
RANGE="HEAD~1..HEAD"
fi
#bash ./scripts/build-arch-guard.sh --range "$RANGE" || {
# echo
# echo "💡 To bypass this check for emergency pushes, use:"
# echo " git push --no-verify"
# echo
# exit 1
#}
bash ./scripts/build-arch-guard.sh --range "$RANGE" || {
echo
echo "💡 To bypass this check for emergency pushes, use:"
echo " git push --no-verify"
echo
exit 1
}

View File

@@ -617,8 +617,7 @@ The Electron build process follows a multi-stage approach:
#### **Stage 2: Capacitor Sync**
- Copies web assets to Electron app directory
- Uses Electron-specific Capacitor configuration (not copied from main config)
- Syncs Capacitor plugins for Electron platform
- Syncs Capacitor configuration and plugins
- Prepares native module bindings
#### **Stage 3: TypeScript Compile**

View File

@@ -1,852 +0,0 @@
# TimeSafari Code Quality: Comprehensive Deep Analysis
**Author**: Matthew Raymer
**Date**: Tue Sep 16 05:22:10 AM UTC 2025
**Status**: 🎯 **COMPREHENSIVE ANALYSIS** - Complete code quality assessment with actionable recommendations
## Executive Summary
The TimeSafari codebase demonstrates **exceptional code quality** with mature patterns, minimal technical debt, and excellent separation of concerns. This comprehensive analysis covers **291 source files** totaling **104,527 lines** of code, including detailed examination of **94 Vue components and views**.
**Key Quality Metrics:**
- **Technical Debt**: Extremely low (6 TODO/FIXME comments across entire codebase)
- **Database Migration**: 99.5% complete (1 remaining legacy import)
- **File Complexity**: High variance (largest file: 2,215 lines)
- **Type Safety**: Mixed patterns (41 "as any" assertions in Vue files, 62 total)
- **Error Handling**: Comprehensive (367 catch blocks with good coverage)
- **Architecture**: Consistent Vue 3 Composition API with TypeScript
## Vue Components & Views Analysis (94 Files)
### Component Analysis (40 Components)
#### Component Size Distribution
```
Large Components (>500 lines): 5 components (12.5%)
├── ImageMethodDialog.vue (947 lines) 🔴 CRITICAL
├── GiftedDialog.vue (670 lines) ⚠️ HIGH PRIORITY
├── PhotoDialog.vue (669 lines) ⚠️ HIGH PRIORITY
├── PushNotificationPermission.vue (660 lines) ⚠️ HIGH PRIORITY
└── MembersList.vue (550 lines) ⚠️ MODERATE PRIORITY
Medium Components (200-500 lines): 12 components (30%)
├── GiftDetailsStep.vue (450 lines)
├── EntityGrid.vue (348 lines)
├── ActivityListItem.vue (334 lines)
├── OfferDialog.vue (327 lines)
├── OnboardingDialog.vue (314 lines)
├── EntitySelectionStep.vue (313 lines)
├── GiftedPrompts.vue (293 lines)
├── ChoiceButtonDialog.vue (250 lines)
├── DataExportSection.vue (251 lines)
├── AmountInput.vue (224 lines)
├── HiddenDidDialog.vue (220 lines)
└── FeedFilters.vue (218 lines)
Small Components (<200 lines): 23 components (57.5%)
├── ContactListItem.vue (217 lines)
├── EntitySummaryButton.vue (202 lines)
├── IdentitySection.vue (186 lines)
├── ContactInputForm.vue (173 lines)
├── SpecialEntityCard.vue (156 lines)
├── RegistrationNotice.vue (154 lines)
├── ContactNameDialog.vue (154 lines)
├── PersonCard.vue (153 lines)
├── UserNameDialog.vue (147 lines)
├── InfiniteScroll.vue (132 lines)
├── LocationSearchSection.vue (124 lines)
├── UsageLimitsSection.vue (123 lines)
├── QuickNav.vue (118 lines)
├── ProjectCard.vue (104 lines)
├── ContactListHeader.vue (101 lines)
├── TopMessage.vue (98 lines)
├── InviteDialog.vue (95 lines)
├── ImageViewer.vue (94 lines)
├── EntityIcon.vue (86 lines)
├── ShowAllCard.vue (66 lines)
├── ContactBulkActions.vue (53 lines)
├── ProjectIcon.vue (47 lines)
└── LargeIdenticonModal.vue (44 lines)
```
#### Critical Component Analysis
**1. `ImageMethodDialog.vue` (947 lines) 🔴 CRITICAL REFACTORING NEEDED**
**Issues Identified:**
- **Excessive Single Responsibility**: Handles camera preview, file upload, URL input, cropping, diagnostics, and error handling
- **Complex State Management**: 20+ reactive properties with interdependencies
- **Mixed Concerns**: Camera API, file handling, UI state, and business logic intertwined
- **Template Complexity**: ~300 lines of template with deeply nested conditions
**Refactoring Strategy:**
```typescript
// Current monolithic structure
ImageMethodDialog.vue (947 lines) {
CameraPreview: ~200 lines
FileUpload: ~150 lines
URLInput: ~100 lines
CroppingInterface: ~200 lines
DiagnosticsPanel: ~150 lines
ErrorHandling: ~100 lines
StateManagement: ~47 lines
}
// Proposed component decomposition
ImageMethodDialog.vue (coordinator, ~200 lines)
CameraPreviewComponent.vue (~250 lines)
FileUploadComponent.vue (~150 lines)
URLInputComponent.vue (~100 lines)
ImageCropperComponent.vue (~200 lines)
DiagnosticsPanelComponent.vue (~150 lines)
ImageUploadErrorHandler.vue (~100 lines)
```
**2. `GiftedDialog.vue` (670 lines) ⚠️ HIGH PRIORITY**
**Assessment**: **GOOD** - Already partially refactored with step components extracted.
**3. `PhotoDialog.vue` (669 lines) ⚠️ HIGH PRIORITY**
**Issues**: Similar to ImageMethodDialog with significant code duplication.
**4. `PushNotificationPermission.vue` (660 lines) ⚠️ HIGH PRIORITY**
**Issues**: Complex permission logic with platform-specific code mixed together.
### View Analysis (54 Views)
#### View Size Distribution
```
Large Views (>1000 lines): 9 views (16.7%)
├── AccountViewView.vue (2,215 lines) 🔴 CRITICAL
├── HomeView.vue (1,852 lines) ⚠️ HIGH PRIORITY
├── ProjectViewView.vue (1,479 lines) ⚠️ HIGH PRIORITY
├── DatabaseMigration.vue (1,438 lines) ⚠️ HIGH PRIORITY
├── ContactsView.vue (1,382 lines) ⚠️ HIGH PRIORITY
├── TestView.vue (1,259 lines) ⚠️ MODERATE PRIORITY
├── ClaimView.vue (1,225 lines) ⚠️ MODERATE PRIORITY
├── NewEditProjectView.vue (957 lines) ⚠️ MODERATE PRIORITY
└── ContactQRScanShowView.vue (929 lines) ⚠️ MODERATE PRIORITY
Medium Views (500-1000 lines): 8 views (14.8%)
├── ConfirmGiftView.vue (898 lines)
├── DiscoverView.vue (888 lines)
├── DIDView.vue (848 lines)
├── GiftedDetailsView.vue (840 lines)
├── OfferDetailsView.vue (781 lines)
├── HelpView.vue (780 lines)
├── ProjectsView.vue (742 lines)
└── ContactQRScanFullView.vue (701 lines)
Small Views (<500 lines): 37 views (68.5%)
├── OnboardMeetingSetupView.vue (687 lines)
├── ContactImportView.vue (568 lines)
├── HelpNotificationsView.vue (566 lines)
├── OnboardMeetingListView.vue (507 lines)
├── InviteOneView.vue (475 lines)
├── QuickActionBvcEndView.vue (442 lines)
├── ContactAmountsView.vue (416 lines)
├── SearchAreaView.vue (384 lines)
├── SharedPhotoView.vue (379 lines)
├── ContactGiftingView.vue (373 lines)
├── ContactEditView.vue (345 lines)
├── IdentitySwitcherView.vue (324 lines)
├── UserProfileView.vue (323 lines)
├── NewActivityView.vue (323 lines)
├── QuickActionBvcBeginView.vue (303 lines)
├── SeedBackupView.vue (292 lines)
├── InviteOneAcceptView.vue (292 lines)
├── ClaimCertificateView.vue (279 lines)
├── StartView.vue (271 lines)
├── ImportAccountView.vue (265 lines)
├── ClaimAddRawView.vue (249 lines)
├── OnboardMeetingMembersView.vue (247 lines)
├── DeepLinkErrorView.vue (239 lines)
├── ClaimReportCertificateView.vue (236 lines)
├── DeepLinkRedirectView.vue (219 lines)
├── ImportDerivedAccountView.vue (207 lines)
├── ShareMyContactInfoView.vue (196 lines)
├── RecentOffersToUserProjectsView.vue (176 lines)
├── RecentOffersToUserView.vue (166 lines)
├── NewEditAccountView.vue (142 lines)
├── StatisticsView.vue (133 lines)
├── HelpOnboardingView.vue (118 lines)
├── LogView.vue (104 lines)
├── NewIdentifierView.vue (97 lines)
├── HelpNotificationTypesView.vue (73 lines)
├── ConfirmContactView.vue (57 lines)
└── QuickActionBvcView.vue (54 lines)
```
#### Critical View Analysis
**1. `AccountViewView.vue` (2,215 lines) 🔴 CRITICAL REFACTORING NEEDED**
**Issues Identified:**
- **Monolithic Architecture**: Handles 7 distinct concerns in single file
- **Template Complexity**: ~750 lines of template with deeply nested conditions
- **Method Proliferation**: 50+ methods handling disparate concerns
- **State Management**: 25+ reactive properties without clear organization
**Refactoring Strategy:**
```typescript
// Current monolithic structure
AccountViewView.vue (2,215 lines) {
ProfileSection: ~400 lines
SettingsSection: ~300 lines
NotificationSection: ~200 lines
ServerConfigSection: ~250 lines
ExportImportSection: ~300 lines
LimitsSection: ~150 lines
MapSection: ~200 lines
StateManagement: ~415 lines
}
// Proposed component extraction
AccountViewView.vue (coordinator, ~400 lines)
ProfileManagementSection.vue (~300 lines)
ServerConfigurationSection.vue (~250 lines)
NotificationSettingsSection.vue (~200 lines)
DataExportImportSection.vue (~300 lines)
UsageLimitsDisplay.vue (~150 lines)
LocationProfileSection.vue (~200 lines)
AccountViewStateManager.ts (~200 lines)
```
**2. `HomeView.vue` (1,852 lines) ⚠️ HIGH PRIORITY**
**Issues Identified:**
- **Multiple Concerns**: Activity feed, projects, contacts, notifications in one file
- **Complex State Management**: 20+ reactive properties with interdependencies
- **Mixed Lifecycle Logic**: Mount, update, and destroy logic intertwined
**3. `ProjectViewView.vue` (1,479 lines) ⚠️ HIGH PRIORITY**
**Issues Identified:**
- **Project Management Complexity**: Handles project details, members, offers, and activities
- **Mixed Concerns**: Project data, member management, and activity feed in single view
### Vue Component Quality Patterns
#### Excellent Patterns Found:
**1. EntityIcon.vue (86 lines) ✅ EXCELLENT**
```typescript
// Clean, focused responsibility
@Component({ name: "EntityIcon" })
export default class EntityIcon extends Vue {
@Prop() contact?: Contact;
@Prop({ default: "" }) entityId!: string;
@Prop({ default: 0 }) iconSize!: number;
generateIcon(): string {
// Clear priority order: profile image → avatar → fallback
const imageUrl = this.contact?.profileImageUrl || this.profileImageUrl;
if (imageUrl) return `<img src="${imageUrl}" ... />`;
const identifier = this.contact?.did || this.entityId;
if (!identifier) return `<img src="${blankSquareSvg}" ... />`;
return createAvatar(avataaars, { seed: identifier, size: this.iconSize }).toString();
}
}
```
**2. QuickNav.vue (118 lines) ✅ EXCELLENT**
```typescript
// Simple, focused navigation component
@Component({ name: "QuickNav" })
export default class QuickNav extends Vue {
@Prop selected = "";
// Clean template with consistent patterns
// Proper accessibility attributes
// Responsive design with safe area handling
}
```
**3. Small Focused Views ✅ EXCELLENT**
```typescript
// QuickActionBvcView.vue (54 lines) - Perfect size
// ConfirmContactView.vue (57 lines) - Focused responsibility
// HelpNotificationTypesView.vue (73 lines) - Clear purpose
// LogView.vue (104 lines) - Simple utility view
```
#### Problematic Patterns Found:
**1. Excessive Props in Dialog Components**
```typescript
// GiftedDialog.vue - Too many props
@Prop() fromProjectId = "";
@Prop() toProjectId = "";
@Prop() isFromProjectView = false;
@Prop() hideShowAll = false;
@Prop({ default: "person" }) giverEntityType = "person";
@Prop({ default: "person" }) recipientEntityType = "person";
// ... 10+ more props
```
**2. Complex State Machines**
```typescript
// ImageMethodDialog.vue - Complex state management
cameraState: "off" | "initializing" | "active" | "error" | "retrying" | "stopped" = "off";
showCameraPreview = false;
isRetrying = false;
showDiagnostics = false;
// ... 15+ more state properties
```
**3. Excessive Reactive Properties**
```typescript
// AccountViewView.vue - Too many reactive properties
downloadUrl: string = "";
loadingLimits: boolean = false;
loadingProfile: boolean = true;
showAdvanced: boolean = false;
showB64Copy: boolean = false;
showContactGives: boolean = false;
showDidCopy: boolean = false;
showDerCopy: boolean = false;
showGeneralAdvanced: boolean = false;
showLargeIdenticonId?: string;
showLargeIdenticonUrl?: string;
showPubCopy: boolean = false;
showShortcutBvc: boolean = false;
warnIfProdServer: boolean = false;
warnIfTestServer: boolean = false;
zoom: number = 2;
isMapReady: boolean = false;
// ... 10+ more properties
```
## File Size and Complexity Analysis (All Files)
### Problematic Large Files
#### 1. `AccountViewView.vue` (2,215 lines) 🔴 **CRITICAL**
**Issues Identified:**
- **Excessive Single File Responsibility**: Handles profile, settings, notifications, server configuration, export/import, limits checking
- **Template Complexity**: ~750 lines of template with deeply nested conditions
- **Method Proliferation**: 50+ methods handling disparate concerns
- **State Management**: 25+ reactive properties without clear organization
#### 2. `PlatformServiceMixin.ts` (2,091 lines) ⚠️ **HIGH PRIORITY**
**Issues Identified:**
- **God Object Pattern**: Single file handling 80+ methods across multiple concerns
- **Mixed Abstraction Levels**: Low-level SQL utilities mixed with high-level business logic
- **Method Length Variance**: Some methods 100+ lines, others single-line wrappers
**Refactoring Strategy:**
```typescript
// Current monolithic mixin
PlatformServiceMixin.ts (2,091 lines)
// Proposed separation of concerns
CoreDatabaseMixin.ts // $db, $exec, $query, $first (200 lines)
SettingsManagementMixin.ts // $settings, $saveSettings (400 lines)
ContactManagementMixin.ts // $contacts, $insertContact (300 lines)
EntityOperationsMixin.ts // $insertEntity, $updateEntity (400 lines)
CachingMixin.ts // Cache management (150 lines)
ActiveIdentityMixin.ts // Active DID management (200 lines)
UtilityMixin.ts // Mapping, JSON parsing (200 lines)
LoggingMixin.ts // $log, $logError (100 lines)
```
#### 3. `HomeView.vue` (1,852 lines) ⚠️ **MODERATE PRIORITY**
**Issues Identified:**
- **Multiple Concerns**: Activity feed, projects, contacts, notifications in one file
- **Complex State Management**: 20+ reactive properties with interdependencies
- **Mixed Lifecycle Logic**: Mount, update, and destroy logic intertwined
### File Size Distribution Analysis
```
Files > 1000 lines: 9 files (4.6% of codebase)
Files 500-1000 lines: 23 files (11.7% of codebase)
Files 200-500 lines: 45 files (22.8% of codebase)
Files < 200 lines: 120 files (60.9% of codebase)
```
**Assessment**: Good distribution with most files reasonably sized, but critical outliers need attention.
## Type Safety Analysis
### Type Assertion Patterns
#### "as any" Usage (62 total instances) ⚠️
**Vue Components & Views (41 instances):**
```typescript
// ImageMethodDialog.vue:504
const activeIdentity = await (this as any).$getActiveIdentity();
// GiftedDialog.vue:228
const activeIdentity = await (this as any).$getActiveIdentity();
// AccountViewView.vue: Multiple instances for:
// - PlatformServiceMixin method access
// - Vue refs with complex typing
// - External library integration (Leaflet)
```
**Other Files (21 instances):**
- **Vue Component References** (23 instances): `(this.$refs.dialog as any)`
- **Platform Detection** (12 instances): `(navigator as any).standalone`
- **External Library Integration** (15 instances): Leaflet, Axios extensions
- **Legacy Code Compatibility** (8 instances): Temporary migration code
- **Event Handler Workarounds** (4 instances): Vue event typing issues
**Example Problematic Pattern:**
```typescript
// src/views/AccountViewView.vue:934
const iconDefault = L.Icon.Default.prototype as unknown as Record<string, unknown>;
// Better approach:
interface LeafletIconPrototype {
_getIconUrl?: unknown;
}
const iconDefault = L.Icon.Default.prototype as LeafletIconPrototype;
```
#### "unknown" Type Usage (755 instances)
**Analysis**: Generally good practice showing defensive programming, but some areas could benefit from more specific typing.
### Recommended Type Safety Improvements
1. **Create Interface Extensions**:
```typescript
// src/types/platform-service-mixin.ts
interface VueWithPlatformServiceMixin extends Vue {
$getActiveIdentity(): Promise<{ activeDid: string }>;
$saveSettings(changes: Partial<Settings>): Promise<boolean>;
// ... other methods
}
// src/types/external.ts
declare global {
interface Navigator {
standalone?: boolean;
}
}
interface VueRefWithOpen {
open: (callback: (result?: unknown) => void) => void;
}
```
2. **Component Ref Typing**:
```typescript
// Instead of: (this.$refs.dialog as any).open()
// Use: (this.$refs.dialog as VueRefWithOpen).open()
```
## Error Handling Consistency Analysis
### Error Handling Patterns (367 catch blocks)
#### Pattern Distribution:
1. **Structured Logging** (85%): Uses logger.error with context
2. **User Notification** (78%): Shows user-friendly error messages
3. **Graceful Degradation** (92%): Provides fallback behavior
4. **Error Propagation** (45%): Re-throws when appropriate
#### Excellent Pattern Example:
```typescript
// src/views/AccountViewView.vue:1617
try {
const response = await this.axios.delete(url, { headers });
if (response.status === 204) {
this.profileImageUrl = "";
this.notify.success("Image deleted successfully.");
}
} catch (error) {
if (isApiError(error) && error.response?.status === 404) {
// Graceful handling - image already gone
this.profileImageUrl = "";
} else {
this.notify.error("Failed to delete image", TIMEOUTS.STANDARD);
}
}
```
#### Areas for Improvement:
1. **Inconsistent Error Typing**: Some catch(error: any), others catch(error: unknown)
2. **Missing Error Boundaries**: No Vue error boundary components
3. **Silent Failures**: 15% of catch blocks don't notify users
## Code Duplication Analysis
### Significant Duplication Patterns
#### 1. **Toggle Component Pattern** (12 occurrences)
```html
<!-- Repeated across multiple files -->
<div class="relative ml-2 cursor-pointer" @click="toggleMethod()">
<input v-model="property" type="checkbox" class="sr-only" />
<div class="block bg-slate-500 w-14 h-8 rounded-full"></div>
<div class="dot absolute left-1 top-1 bg-slate-400 w-6 h-6 rounded-full transition"></div>
</div>
```
**Solution**: Create `ToggleSwitch.vue` component with props for value, label, and change handler.
#### 2. **API Error Handling Pattern** (25 occurrences)
```typescript
try {
const response = await this.axios.post(url, data, { headers });
if (response.status === 200) {
this.notify.success("Operation successful");
}
} catch (error) {
if (isApiError(error)) {
this.notify.error(`Failed: ${error.message}`);
}
}
```
**Solution**: Create `ApiRequestMixin.ts` with standardized request/response handling.
#### 3. **Settings Update Pattern** (40+ occurrences)
```typescript
async methodName() {
await this.$saveSettings({ property: this.newValue });
this.property = this.newValue;
}
```
**Solution**: Enhanced PlatformServiceMixin already provides `$saveSettings()` - migrate remaining manual patterns.
## Dependency and Coupling Analysis
### Import Dependency Patterns
#### Legacy Database Coupling (EXCELLENT)
- **Status**: 99.5% resolved (1 remaining databaseUtil import)
- **Remaining**: `src/views/DeepLinkErrorView.vue:import { logConsoleAndDb }`
- **Resolution**: Replace with PlatformServiceMixin `$logAndConsole()`
#### Circular Dependency Status (EXCELLENT)
- **Status**: 100% resolved, no active circular dependencies
- **Previous Issues**: All resolved through PlatformServiceMixin architecture
#### Component Coupling Analysis
```typescript
// High coupling components (>10 imports)
AccountViewView.vue: 15 imports (understandable given scope)
HomeView.vue: 12 imports
ProjectViewView.vue: 11 imports
// Well-isolated components (<5 imports)
QuickActionViews: 3-4 imports each
Component utilities: 2-3 imports each
```
**Assessment**: Reasonable coupling levels with clear architectural boundaries.
## Console Logging Analysis (129 instances)
### Logging Pattern Distribution:
1. **console.log**: 89 instances (69%)
2. **console.warn**: 24 instances (19%)
3. **console.error**: 16 instances (12%)
### Vue Components & Views Logging (3 instances):
- **Components**: 1 console.* call
- **Views**: 2 console.* calls
### Inconsistent Logging Approach:
```typescript
// Mixed patterns found:
console.log("Direct console logging"); // 89 instances
logger.debug("Structured logging"); // Preferred pattern
this.$logAndConsole("Mixin logging"); // PlatformServiceMixin
```
### Recommended Standardization:
1. **Migration Strategy**: Replace all console.* with logger.* calls
2. **Structured Context**: Add consistent metadata to log entries
3. **Log Levels**: Standardize debug/info/warn/error usage
## Technical Debt Analysis (6 total)
### Components (1 TODO):
```typescript
// PushNotificationPermission.vue
// TODO: secretDB functionality needs to be migrated to PlatformServiceMixin
```
### Views (2 TODOs):
```typescript
// AccountViewView.vue
// TODO: Implement this for SQLite
// TODO: implement this for SQLite
```
### Other Files (3 TODOs):
```typescript
// src/db/tables/accounts.ts
// TODO: When finished with migration, move these fields to Account and move identity and mnemonic here.
// src/util.d.ts
// TODO: , inspect: inspect
// src/libs/crypto/vc/passkeyHelpers.ts
// TODO: If it's after February 2025 when you read this then consider whether it still makes sense
```
**Assessment**: **EXCELLENT** - Only 6 TODO comments across 291 files.
## Performance Anti-Patterns
### Identified Issues:
#### 1. **Excessive Reactive Properties**
```typescript
// AccountViewView.vue has 25+ reactive properties
// Many could be computed or moved to component state
```
#### 2. **Inline Method Calls in Templates**
```html
<!-- Anti-pattern: -->
<span>{{ readableDate(timeStr) }}</span>
<!-- Better: -->
<span>{{ readableTime }}</span>
<!-- With computed property -->
```
#### 3. **Missing Key Attributes in Lists**
```html
<!-- Several v-for loops missing :key attributes -->
<li v-for="item in items">
```
#### 4. **Complex Template Logic**
```html
<!-- AccountViewView.vue - Complex nested conditions -->
<div v-if="!activeDid" id="noticeBeforeShare" class="bg-amber-200...">
<p class="mb-4">
<b>Note:</b> Before you can share with others or take any action, you need an identifier.
</p>
<router-link :to="{ name: 'new-identifier' }" class="inline-block...">
Create An Identifier
</router-link>
</div>
<!-- Identity Details -->
<IdentitySection
:given-name="givenName"
:profile-image-url="profileImageUrl"
:active-did="activeDid"
:is-registered="isRegistered"
:show-large-identicon-id="showLargeIdenticonId"
:show-large-identicon-url="showLargeIdenticonUrl"
:show-did-copy="showDidCopy"
@edit-name="onEditName"
@show-qr-code="onShowQrCode"
@add-image="onAddImage"
@delete-image="onDeleteImage"
@show-large-identicon-id="onShowLargeIdenticonId"
@show-large-identicon-url="onShowLargeIdenticonUrl"
/>
```
## Specific Actionable Recommendations
### Priority 1: Critical File Refactoring
1. **Split AccountViewView.vue**:
- **Timeline**: 2-3 sprints
- **Strategy**: Extract 6 major sections into focused components
- **Risk**: Medium (requires careful state management coordination)
- **Benefit**: Massive maintainability improvement, easier testing
2. **Decompose ImageMethodDialog.vue**:
- **Timeline**: 2-3 sprints
- **Strategy**: Extract 6 focused components (camera, file upload, cropping, etc.)
- **Risk**: Medium (complex camera state management)
- **Benefit**: Massive maintainability improvement
3. **Decompose PlatformServiceMixin.ts**:
- **Timeline**: 1-2 sprints
- **Strategy**: Create focused mixins by concern area
- **Risk**: Low (well-defined interfaces already exist)
- **Benefit**: Better code organization, reduced cognitive load
### Priority 2: Component Extraction
1. **HomeView.vue** → 4 focused sections
- **Timeline**: 1-2 sprints
- **Risk**: Low (clear separation of concerns)
- **Benefit**: Better code organization
2. **ProjectViewView.vue** → 4 focused sections
- **Timeline**: 1-2 sprints
- **Risk**: Low (well-defined boundaries)
- **Benefit**: Improved maintainability
### Priority 3: Shared Component Creation
1. **CameraPreviewComponent.vue**
- Extract from ImageMethodDialog.vue and PhotoDialog.vue
- **Benefit**: Eliminate code duplication
2. **FileUploadComponent.vue**
- Extract from ImageMethodDialog.vue and PhotoDialog.vue
- **Benefit**: Consistent file handling
3. **ToggleSwitch.vue**
- Replace 12 duplicate toggle patterns
- **Benefit**: Consistent UI components
4. **DiagnosticsPanelComponent.vue**
- Extract from ImageMethodDialog.vue
- **Benefit**: Reusable debugging component
### Priority 4: Type Safety Enhancement
1. **Eliminate "as any" Assertions**:
- **Timeline**: 1 sprint
- **Strategy**: Create proper interface extensions
- **Risk**: Low
- **Benefit**: Better compile-time error detection
2. **Standardize Error Typing**:
- **Timeline**: 0.5 sprint
- **Strategy**: Use consistent `catch (error: unknown)` pattern
- **Risk**: None
- **Benefit**: Better error handling consistency
### Priority 5: State Management Optimization
1. **Create Composables for Complex State**:
```typescript
// src/composables/useCameraState.ts
export function useCameraState() {
const cameraState = ref<CameraState>("off");
const showPreview = ref(false);
const isRetrying = ref(false);
const startCamera = async () => { /* ... */ };
const stopCamera = () => { /* ... */ };
return { cameraState, showPreview, isRetrying, startCamera, stopCamera };
}
```
2. **Group Related Reactive Properties**:
```typescript
// Instead of:
showB64Copy: boolean = false;
showDidCopy: boolean = false;
showDerCopy: boolean = false;
showPubCopy: boolean = false;
// Use:
copyStates = {
b64: false,
did: false,
der: false,
pub: false
};
```
### Priority 6: Code Standardization
1. **Logging Standardization**:
- **Timeline**: 1 sprint
- **Strategy**: Replace all console.* with logger.*
- **Risk**: None
- **Benefit**: Consistent logging, better debugging
2. **Template Optimization**:
- Add missing `:key` attributes
- Convert inline method calls to computed properties
- Implement virtual scrolling for large lists
## Quality Metrics Summary
### Vue Component Quality Distribution:
| Size Category | Count | Percentage | Quality Assessment |
|---------------|-------|------------|-------------------|
| Large (>500 lines) | 5 | 12.5% | 🔴 Needs Refactoring |
| Medium (200-500 lines) | 12 | 30% | 🟡 Good with Minor Issues |
| Small (<200 lines) | 23 | 57.5% | 🟢 Excellent |
### Vue View Quality Distribution:
| Size Category | Count | Percentage | Quality Assessment |
|---------------|-------|------------|-------------------|
| Large (>1000 lines) | 9 | 16.7% | 🔴 Needs Refactoring |
| Medium (500-1000 lines) | 8 | 14.8% | 🟡 Good with Minor Issues |
| Small (<500 lines) | 37 | 68.5% | 🟢 Excellent |
### Overall Quality Metrics:
| Metric | Components | Views | Overall Assessment |
|--------|------------|-------|-------------------|
| Technical Debt | 1 TODO | 2 TODOs | 🟢 Excellent |
| Type Safety | 6 "as any" | 35 "as any" | 🟡 Good |
| Console Logging | 1 instance | 2 instances | 🟢 Excellent |
| Architecture Consistency | 100% | 100% | 🟢 Excellent |
| Component Reuse | High | High | 🟢 Excellent |
### Before vs. Target State:
| Metric | Current | Target | Status |
|--------|---------|---------|---------|
| Files >1000 lines | 9 files | 3 files | 🟡 Needs Work |
| "as any" assertions | 62 | 15 | 🟡 Moderate |
| Console.* calls | 129 | 0 | 🔴 Needs Work |
| Component reuse | 40% | 75% | 🟡 Moderate |
| Error consistency | 85% | 95% | 🟢 Good |
| Type coverage | 88% | 95% | 🟢 Good |
## Risk Assessment
### Low Risk Improvements (High Impact):
- Logging standardization
- Type assertion cleanup
- Missing key attributes
- Component extraction from AccountViewView.vue
- Shared component creation (ToggleSwitch, CameraPreview)
### Medium Risk Improvements:
- PlatformServiceMixin decomposition
- State management optimization
- ImageMethodDialog decomposition
### High Risk Items:
- None identified - project demonstrates excellent architectural discipline
## Conclusion
The TimeSafari codebase demonstrates **exceptional code quality** with:
**Key Strengths:**
- **Consistent Architecture**: 100% Vue 3 Composition API with TypeScript
- **Minimal Technical Debt**: Only 6 TODO comments across 291 files
- **Excellent Small Components**: 68.5% of views and 57.5% of components are well-sized
- **Strong Type Safety**: Minimal "as any" usage, mostly justified
- **Clean Logging**: Minimal console.* usage, structured logging preferred
- **Excellent Database Migration**: 99.5% complete
- **Comprehensive Error Handling**: 367 catch blocks with good coverage
- **No Circular Dependencies**: 100% resolved
**Primary Focus Areas:**
1. **Decompose Large Files**: 5 components and 9 views need refactoring
2. **Extract Shared Components**: Camera, file upload, and diagnostics components
3. **Optimize State Management**: Group related properties and create composables
4. **Improve Type Safety**: Create proper interface extensions for mixin methods
5. **Logging Standardization**: Replace 129 console.* calls with structured logger.*
**The component architecture is production-ready** with these improvements representing **strategic optimization** rather than critical fixes. The codebase demonstrates **mature Vue.js development practices** with excellent separation of concerns and consistent patterns.
---
**Investigation Methodology:**
- Static analysis of 291 source files (197 general + 94 Vue components/views)
- Pattern recognition across 104,527 lines of code
- Manual review of large files and complexity patterns
- Dependency analysis and coupling assessment
- Performance anti-pattern identification
- Architecture consistency evaluation

View File

@@ -68,16 +68,16 @@ TimeSafari supports configurable logging levels via the `VITE_LOG_LEVEL` environ
```bash
# Show only errors
VITE_LOG_LEVEL=error npm run build:web:dev
VITE_LOG_LEVEL=error npm run dev
# Show warnings and errors
VITE_LOG_LEVEL=warn npm run build:web:dev
VITE_LOG_LEVEL=warn npm run dev
# Show info, warnings, and errors (default)
VITE_LOG_LEVEL=info npm run build:web:dev
VITE_LOG_LEVEL=info npm run dev
# Show all log levels including debug
VITE_LOG_LEVEL=debug npm run build:web:dev
VITE_LOG_LEVEL=debug npm run dev
```
### Available Levels
@@ -305,17 +305,6 @@ timesafari/
└── 📄 doc/README-BUILD-GUARD.md # Guard system documentation
```
## Known Issues
### Critical Vue Reactivity Bug
A critical Vue reactivity bug was discovered during ActiveDid migration testing where component properties fail to trigger template updates correctly.
**Impact**: The `newDirectOffersActivityNumber` element in HomeView.vue requires a watcher workaround to render correctly.
**Status**: Workaround implemented, investigation ongoing.
**Documentation**: See [Vue Reactivity Bug Report](doc/vue-reactivity-bug-report.md) for details.
## 🤝 Contributing
1. **Follow the Build Architecture Guard** - Update BUILDING.md when modifying build files

View File

@@ -31,8 +31,8 @@ android {
applicationId "app.timesafari.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 41
versionName "1.0.8"
versionCode 40
versionName "1.0.7"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -1,655 +0,0 @@
# Android Emulator Deployment Guide (No Android Studio)
**Author**: Matthew Raymer
**Date**: 2025-01-27
**Status**: 🎯 **ACTIVE** - Complete guide for deploying TimeSafari to Android emulator using command-line tools
## Overview
This guide provides comprehensive instructions for building and deploying TimeSafari to Android emulators using only command-line tools, without requiring Android Studio. It leverages the existing build system and adds emulator-specific deployment workflows.
## Prerequisites
### Required Tools
1. **Android SDK Command Line Tools**
```bash
# Install via package manager (Arch Linux)
sudo pacman -S android-sdk-cmdline-tools-latest
# Or download from Google
# https://developer.android.com/studio/command-line
```
2. **Android SDK Platform Tools**
```bash
# Install via package manager
sudo pacman -S android-sdk-platform-tools
# Or via Android SDK Manager
sdkmanager "platform-tools"
```
3. **Android SDK Build Tools**
```bash
sdkmanager "build-tools;34.0.0"
```
4. **Android Platform**
```bash
sdkmanager "platforms;android-34"
```
5. **Android Emulator**
```bash
sdkmanager "emulator"
```
6. **System Images**
```bash
# For API 34 (Android 14)
sdkmanager "system-images;android-34;google_apis;x86_64"
# For API 33 (Android 13) - alternative
sdkmanager "system-images;android-33;google_apis;x86_64"
```
### Environment Setup
```bash
# Add to ~/.bashrc or ~/.zshrc
export ANDROID_HOME=$HOME/Android/Sdk
export ANDROID_AVD_HOME=$HOME/.android/avd # Important for AVD location
export PATH=$PATH:$ANDROID_HOME/emulator
export PATH=$PATH:$ANDROID_HOME/platform-tools
export PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin
export PATH=$PATH:$ANDROID_HOME/build-tools/34.0.0
# Reload shell
source ~/.bashrc
```
### Verify Installation
```bash
# Check all tools are available
adb version
emulator -version
avdmanager list
```
## Resource-Aware Emulator Setup
### ⚡ **Quick Start Recommendation**
**For best results, always start with resource analysis:**
```bash
# 1. Check your system capabilities
./scripts/avd-resource-checker.sh
# 2. Use the generated optimal startup script
/tmp/start-avd-TimeSafari_Emulator.sh
# 3. Deploy your app
npm run build:android:dev
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
```
This prevents system lockups and ensures optimal performance.
### AVD Resource Checker Script
**New Feature**: TimeSafari includes an intelligent resource checker that automatically detects your system capabilities and recommends optimal AVD configurations.
```bash
# Check system resources and get recommendations
./scripts/avd-resource-checker.sh
# Check resources for specific AVD
./scripts/avd-resource-checker.sh TimeSafari_Emulator
# Test AVD startup performance
./scripts/avd-resource-checker.sh TimeSafari_Emulator --test
# Create optimized AVD with recommended settings
./scripts/avd-resource-checker.sh TimeSafari_Emulator --create
```
**What the script analyzes:**
- **System Memory**: Total and available RAM
- **CPU Cores**: Available processing power
- **GPU Capabilities**: NVIDIA, AMD, Intel, or software rendering
- **Hardware Acceleration**: Optimal graphics settings
**What it generates:**
- **Optimal configuration**: Memory, cores, and GPU settings
- **Startup command**: Ready-to-use emulator command
- **Startup script**: Saved to `/tmp/start-avd-{name}.sh` for reuse
## Emulator Management
### Create Android Virtual Device (AVD)
```bash
# List available system images
avdmanager list target
# Create AVD for API 34
avdmanager create avd \
--name "TimeSafari_Emulator" \
--package "system-images;android-34;google_apis;x86_64" \
--device "pixel_7"
# List created AVDs
avdmanager list avd
```
### Start Emulator
```bash
# Start emulator with hardware acceleration (recommended)
emulator -avd TimeSafari_Emulator -gpu host -no-audio &
# Start with reduced resources (if system has limited RAM)
emulator -avd TimeSafari_Emulator \
-no-audio \
-memory 2048 \
-cores 2 \
-gpu swiftshader_indirect &
# Start with minimal resources (safest for low-end systems)
emulator -avd TimeSafari_Emulator \
-no-audio \
-memory 1536 \
-cores 1 \
-gpu swiftshader_indirect &
# Check if emulator is running
adb devices
```
### Resource Management
**Important**: Android emulators can consume significant system resources. Choose the appropriate configuration based on your system:
- **High-end systems** (16GB+ RAM, dedicated GPU): Use `-gpu host`
- **Mid-range systems** (8-16GB RAM): Use `-memory 2048 -cores 2`
- **Low-end systems** (4-8GB RAM): Use `-memory 1536 -cores 1 -gpu swiftshader_indirect`
### Emulator Control
```bash
# Stop emulator
adb emu kill
# Restart emulator
adb reboot
# Check emulator status
adb get-state
```
## Build and Deploy Workflow
### Method 1: Using Existing Build Scripts
The TimeSafari project already has comprehensive Android build scripts that can be adapted for emulator deployment:
```bash
# Development build with auto-run
npm run build:android:dev:run
# Test build with auto-run
npm run build:android:test:run
# Production build with auto-run
npm run build:android:prod:run
```
### Method 2: Custom Emulator Deployment Script
Create a new script specifically for emulator deployment:
```bash
# Create emulator deployment script
cat > scripts/deploy-android-emulator.sh << 'EOF'
#!/bin/bash
# deploy-android-emulator.sh
# Author: Matthew Raymer
# Date: 2025-01-27
# Description: Deploy TimeSafari to Android emulator without Android Studio
set -e
# Source common utilities
source "$(dirname "$0")/common.sh"
# Default values
BUILD_MODE="development"
AVD_NAME="TimeSafari_Emulator"
START_EMULATOR=true
CLEAN_BUILD=true
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--dev|--development)
BUILD_MODE="development"
shift
;;
--test)
BUILD_MODE="test"
shift
;;
--prod|--production)
BUILD_MODE="production"
shift
;;
--avd)
AVD_NAME="$2"
shift 2
;;
--no-start-emulator)
START_EMULATOR=false
shift
;;
--no-clean)
CLEAN_BUILD=false
shift
;;
-h|--help)
echo "Usage: $0 [options]"
echo "Options:"
echo " --dev, --development Build for development"
echo " --test Build for testing"
echo " --prod, --production Build for production"
echo " --avd NAME Use specific AVD name"
echo " --no-start-emulator Don't start emulator"
echo " --no-clean Skip clean build"
echo " -h, --help Show this help"
exit 0
;;
*)
log_error "Unknown option: $1"
exit 1
;;
esac
done
# Function to check if emulator is running
check_emulator_running() {
if adb devices | grep -q "emulator.*device"; then
return 0
else
return 1
fi
}
# Function to start emulator
start_emulator() {
log_info "Starting Android emulator: $AVD_NAME"
# Check if AVD exists
if ! avdmanager list avd | grep -q "$AVD_NAME"; then
log_error "AVD '$AVD_NAME' not found. Please create it first."
log_info "Create AVD with: avdmanager create avd --name $AVD_NAME --package system-images;android-34;google_apis;x86_64"
exit 1
fi
# Start emulator in background
emulator -avd "$AVD_NAME" -no-audio -no-snapshot &
EMULATOR_PID=$!
# Wait for emulator to boot
log_info "Waiting for emulator to boot..."
adb wait-for-device
# Wait for boot to complete
log_info "Waiting for boot to complete..."
while [ "$(adb shell getprop sys.boot_completed)" != "1" ]; do
sleep 2
done
log_success "Emulator is ready!"
}
# Function to build and deploy
build_and_deploy() {
log_info "Building TimeSafari for $BUILD_MODE mode..."
# Clean build if requested
if [ "$CLEAN_BUILD" = true ]; then
log_info "Cleaning previous build..."
npm run clean:android
fi
# Build based on mode
case $BUILD_MODE in
"development")
npm run build:android:dev
;;
"test")
npm run build:android:test
;;
"production")
npm run build:android:prod
;;
esac
# Deploy to emulator
log_info "Deploying to emulator..."
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
# Launch app
log_info "Launching TimeSafari..."
adb shell am start -n app.timesafari/.MainActivity
log_success "TimeSafari deployed and launched successfully!"
}
# Main execution
main() {
log_info "TimeSafari Android Emulator Deployment"
log_info "Build Mode: $BUILD_MODE"
log_info "AVD Name: $AVD_NAME"
# Start emulator if requested and not running
if [ "$START_EMULATOR" = true ]; then
if ! check_emulator_running; then
start_emulator
else
log_info "Emulator already running"
fi
fi
# Build and deploy
build_and_deploy
log_success "Deployment completed successfully!"
}
# Run main function
main "$@"
EOF
# Make script executable
chmod +x scripts/deploy-android-emulator.sh
```
### Method 3: Direct Command Line Deployment
For quick deployments without scripts:
```bash
# 1. Ensure emulator is running
adb devices
# 2. Build the app
npm run build:android:dev
# 3. Install APK
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
# 4. Launch app
adb shell am start -n app.timesafari/.MainActivity
# 5. View logs
adb logcat | grep -E "(TimeSafari|Capacitor|MainActivity)"
```
## Advanced Deployment Options
### Custom API Server Configuration
For development with custom API endpoints:
```bash
# Build with custom API IP
npm run build:android:dev:custom
# Or modify capacitor.config.ts for specific IP
# Then build normally
npm run build:android:dev
```
### Debug vs Release Builds
```bash
# Debug build (default)
npm run build:android:debug
# Release build
npm run build:android:release
# Install specific build
adb install -r android/app/build/outputs/apk/release/app-release.apk
```
### Asset Management
```bash
# Validate Android assets
npm run assets:validate:android
# Generate assets only
npm run build:android:assets
# Clean assets
npm run assets:clean
```
## Troubleshooting
### Common Issues
1. **Emulator Not Starting / AVD Not Found**
```bash
# Check available AVDs
avdmanager list avd
# If AVD exists but emulator can't find it, check AVD location
echo $ANDROID_AVD_HOME
ls -la ~/.android/avd/
# Fix AVD path issue (common on Arch Linux)
export ANDROID_AVD_HOME=/home/$USER/.config/.android/avd
# Or create symlinks if AVDs are in different location
mkdir -p ~/.android/avd
ln -s /home/$USER/.config/.android/avd/* ~/.android/avd/
# Create new AVD if needed
avdmanager create avd --name "TimeSafari_Emulator" --package "system-images;android-34;google_apis;x86_64"
# Check emulator logs
emulator -avd TimeSafari_Emulator -verbose
```
2. **System Lockup / High Resource Usage**
```bash
# Kill any stuck emulator processes
pkill -f emulator
# Check system resources
free -h
nvidia-smi # if using NVIDIA GPU
# Start with minimal resources
emulator -avd TimeSafari_Emulator \
-no-audio \
-memory 1536 \
-cores 1 \
-gpu swiftshader_indirect &
# Monitor resource usage
htop
# If still having issues, try software rendering only
emulator -avd TimeSafari_Emulator \
-no-audio \
-no-snapshot \
-memory 1024 \
-cores 1 \
-gpu off &
```
3. **ADB Device Not Found**
```bash
# Restart ADB server
adb kill-server
adb start-server
# Check devices
adb devices
# Check emulator status
adb get-state
```
3. **Build Failures**
```bash
# Clean everything
npm run clean:android
# Rebuild
npm run build:android:dev
# Check Gradle logs
cd android && ./gradlew clean --stacktrace
```
4. **Installation Failures**
```bash
# Uninstall existing app
adb uninstall app.timesafari
# Reinstall
adb install android/app/build/outputs/apk/debug/app-debug.apk
# Check package info
adb shell pm list packages | grep timesafari
```
### Performance Optimization
1. **Emulator Performance**
```bash
# Start with hardware acceleration
emulator -avd TimeSafari_Emulator -gpu host
# Use snapshot for faster startup
emulator -avd TimeSafari_Emulator -snapshot default
# Allocate more RAM
emulator -avd TimeSafari_Emulator -memory 4096
```
2. **Build Performance**
```bash
# Use Gradle daemon
echo "org.gradle.daemon=true" >> android/gradle.properties
# Increase heap size
echo "org.gradle.jvmargs=-Xmx4g" >> android/gradle.properties
# Enable parallel builds
echo "org.gradle.parallel=true" >> android/gradle.properties
```
## Integration with Existing Build System
### NPM Scripts Integration
Add emulator-specific scripts to `package.json`:
```json
{
"scripts": {
"emulator:check": "./scripts/avd-resource-checker.sh",
"emulator:check:test": "./scripts/avd-resource-checker.sh TimeSafari_Emulator --test",
"emulator:check:create": "./scripts/avd-resource-checker.sh TimeSafari_Emulator --create",
"emulator:start": "emulator -avd TimeSafari_Emulator -no-audio &",
"emulator:start:optimized": "/tmp/start-avd-TimeSafari_Emulator.sh",
"emulator:stop": "adb emu kill",
"emulator:deploy": "./scripts/deploy-android-emulator.sh",
"emulator:deploy:dev": "./scripts/deploy-android-emulator.sh --dev",
"emulator:deploy:test": "./scripts/deploy-android-emulator.sh --test",
"emulator:deploy:prod": "./scripts/deploy-android-emulator.sh --prod",
"emulator:logs": "adb logcat | grep -E '(TimeSafari|Capacitor|MainActivity)'",
"emulator:shell": "adb shell"
}
}
```
### CI/CD Integration
For automated testing and deployment:
```bash
# GitHub Actions example
- name: Start Android Emulator
run: |
emulator -avd TimeSafari_Emulator -no-audio -no-snapshot &
adb wait-for-device
adb shell getprop sys.boot_completed
- name: Build and Deploy
run: |
npm run build:android:test
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n app.timesafari/.MainActivity
- name: Run Tests
run: |
npm run test:android
```
## Best Practices
### Development Workflow
1. **Start emulator once per session**
```bash
emulator -avd TimeSafari_Emulator -no-audio &
```
2. **Use incremental builds**
```bash
# For rapid iteration
npm run build:android:sync
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
```
3. **Monitor logs continuously**
```bash
adb logcat | grep -E "(TimeSafari|Capacitor|MainActivity)" --color=always
```
### Performance Tips
1. **Use snapshots for faster startup**
2. **Enable hardware acceleration**
3. **Allocate sufficient RAM (4GB+)**
4. **Use SSD storage for AVDs**
5. **Close unnecessary applications**
### Security Considerations
1. **Use debug builds for development only**
2. **Never commit debug keystores**
3. **Use release builds for testing**
4. **Validate API endpoints in production builds**
## Conclusion
This guide provides a complete solution for deploying TimeSafari to Android emulators without Android Studio. The approach leverages the existing build system while adding emulator-specific deployment capabilities.
The key benefits:
- ✅ **No Android Studio required**
- ✅ **Command-line only workflow**
- ✅ **Integration with existing build scripts**
- ✅ **Automated deployment options**
- ✅ **Comprehensive troubleshooting guide**
For questions or issues, refer to the troubleshooting section or check the existing build documentation in `BUILDING.md`.

View File

@@ -1,181 +0,0 @@
# Seed Phrase Backup Reminder Implementation
## Overview
This implementation adds a modal dialog that reminds users to back up their seed phrase if they haven't done so yet. The reminder appears after specific user actions and includes a 24-hour cooldown to avoid being too intrusive.
## Features
- **Modal Dialog**: Uses the existing notification group modal system from `App.vue`
- **Smart Timing**: Only shows when `hasBackedUpSeed = false`
- **24-Hour Cooldown**: Uses localStorage to prevent showing more than once per day
- **Action-Based Triggers**: Shows after specific user actions
- **User Choice**: "Backup Identifier Seed" or "Remind me Later" options
## Implementation Details
### Core Utility (`src/utils/seedPhraseReminder.ts`)
The main utility provides:
- `shouldShowSeedReminder(hasBackedUpSeed)`: Checks if reminder should be shown
- `markSeedReminderShown()`: Updates localStorage timestamp
- `createSeedReminderNotification()`: Creates the modal configuration
- `showSeedPhraseReminder(hasBackedUpSeed, notifyFunction)`: Main function to show reminder
### Trigger Points
The reminder is shown after these user actions:
**Note**: The reminder is triggered by **claim creation** actions, not claim confirmations. This focuses on when users are actively creating new content rather than just confirming existing claims.
1. **Profile Saving** (`AccountViewView.vue`)
- After clicking "Save Profile" button
- Only when profile save is successful
2. **Claim Creation** (Multiple views)
- `ClaimAddRawView.vue`: After submitting raw claims
- `GiftedDialog.vue`: After creating gifts/claims
- `GiftedDetailsView.vue`: After recording gifts/claims
- `OfferDialog.vue`: After creating offers
3. **QR Code Views Exit**
- `ContactQRScanFullView.vue`: When exiting via back button
- `ContactQRScanShowView.vue`: When exiting via back button
### Modal Configuration
```typescript
{
group: "modal",
type: "confirm",
title: "Backup Your Identifier Seed?",
text: "It looks like you haven't backed up your identifier seed yet. It's important to back it up as soon as possible to secure your identity.",
yesText: "Backup Identifier Seed",
noText: "Remind me Later",
onYes: () => navigate to /seed-backup,
onNo: () => mark as shown for 24 hours,
onCancel: () => mark as shown for 24 hours
}
```
**Important**: The modal is configured with `timeout: -1` to ensure it stays open until the user explicitly interacts with one of the buttons. This prevents the dialog from closing automatically.
### Cooldown Mechanism
- **Storage Key**: `seedPhraseReminderLastShown`
- **Cooldown Period**: 24 hours (24 * 60 * 60 * 1000 milliseconds)
- **Implementation**: localStorage with timestamp comparison
- **Fallback**: Shows reminder if timestamp is invalid or missing
## User Experience
### When Reminder Appears
- User has not backed up their seed phrase (`hasBackedUpSeed = false`)
- At least 24 hours have passed since last reminder
- User performs one of the trigger actions
- **1-second delay** after the success message to allow users to see the confirmation
### User Options
1. **"Backup Identifier Seed"**: Navigates to `/seed-backup` page
2. **"Remind me Later"**: Dismisses and won't show again for 24 hours
3. **Cancel/Close**: Same behavior as "Remind me Later"
### Frequency Control
- **First Time**: Always shows if user hasn't backed up
- **Subsequent**: Only shows after 24-hour cooldown
- **Automatic Reset**: When user completes seed backup (`hasBackedUpSeed = true`)
## Technical Implementation
### Error Handling
- Graceful fallback if localStorage operations fail
- Logging of errors for debugging
- Non-blocking implementation (doesn't affect main functionality)
### Integration Points
- **Platform Service**: Uses `$accountSettings()` to check backup status
- **Notification System**: Integrates with existing `$notify` system
- **Router**: Uses `window.location.href` for navigation
### Performance Considerations
- Minimal localStorage operations
- No blocking operations
- Efficient timestamp comparisons
- **Timing Behavior**: 1-second delay before showing reminder to improve user experience flow
## Testing
### Manual Testing Scenarios
1. **First Time User**
- Create new account
- Perform trigger action (save profile, create claim, exit QR view)
- Verify reminder appears
2. **Repeat User (Within 24h)**
- Perform trigger action
- Verify reminder does NOT appear
3. **Repeat User (After 24h)**
- Wait 24+ hours
- Perform trigger action
- Verify reminder appears again
4. **User Who Has Backed Up**
- Complete seed backup
- Perform trigger action
- Verify reminder does NOT appear
5. **QR Code View Exit**
- Navigate to QR code view (full or show)
- Exit via back button
- Verify reminder appears (if conditions are met)
### Browser Testing
- Test localStorage functionality
- Verify timestamp handling
- Check navigation to seed backup page
## Future Enhancements
### Potential Improvements
1. **Customizable Cooldown**: Allow users to set reminder frequency
2. **Progressive Urgency**: Increase reminder frequency over time
3. **Analytics**: Track reminder effectiveness and user response
4. **A/B Testing**: Test different reminder messages and timing
### Configuration Options
- Reminder frequency settings
- Custom reminder messages
- Different trigger conditions
- Integration with other notification systems
## Maintenance
### Monitoring
- Check localStorage usage in browser dev tools
- Monitor user feedback about reminder frequency
- Track navigation success to seed backup page
### Updates
- Modify reminder text in `createSeedReminderNotification()`
- Adjust cooldown period in `REMINDER_COOLDOWN_MS` constant
- Add new trigger points as needed
## Conclusion
This implementation provides a non-intrusive way to remind users about seed phrase backup while respecting their preferences and avoiding notification fatigue. The 24-hour cooldown ensures users aren't overwhelmed while maintaining the importance of the security reminder.
The feature is fully integrated with the existing codebase architecture and follows established patterns for notifications, error handling, and user interaction.

View File

@@ -1,116 +0,0 @@
import { CapacitorConfig } from '@capacitor/cli';
const config: CapacitorConfig = {
appId: 'app.timesafari',
appName: 'TimeSafari',
webDir: 'dist',
server: {
cleartext: true
},
plugins: {
App: {
appUrlOpen: {
handlers: [
{
url: 'timesafari://*',
autoVerify: true
}
]
}
},
SplashScreen: {
launchShowDuration: 3000,
launchAutoHide: true,
backgroundColor: '#ffffff',
androidSplashResourceName: 'splash',
androidScaleType: 'CENTER_CROP',
showSpinner: false,
androidSpinnerStyle: 'large',
iosSpinnerStyle: 'small',
spinnerColor: '#999999',
splashFullScreen: true,
splashImmersive: true
},
CapSQLite: {
iosDatabaseLocation: 'Library/CapacitorDatabase',
iosIsEncryption: false,
iosBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
},
androidIsEncryption: false,
androidBiometric: {
biometricAuth: false,
biometricTitle: 'Biometric login for TimeSafari'
},
electronIsEncryption: false
}
},
ios: {
contentInset: 'never',
allowsLinkPreview: true,
scrollEnabled: true,
limitsNavigationsToAppBoundDomains: true,
backgroundColor: '#ffffff',
allowNavigation: [
'*.timesafari.app',
'*.jsdelivr.net',
'api.endorser.ch'
]
},
android: {
allowMixedContent: true,
captureInput: true,
webContentsDebuggingEnabled: false,
allowNavigation: [
'*.timesafari.app',
'*.jsdelivr.net',
'api.endorser.ch',
'10.0.2.2:3000'
]
},
electron: {
deepLinking: {
schemes: ['timesafari']
},
buildOptions: {
appId: 'app.timesafari',
productName: 'TimeSafari',
directories: {
output: 'dist-electron-packages'
},
files: [
'dist/**/*',
'electron/**/*'
],
mac: {
category: 'public.app-category.productivity',
target: [
{
target: 'dmg',
arch: ['x64', 'arm64']
}
]
},
win: {
target: [
{
target: 'nsis',
arch: ['x64']
}
]
},
linux: {
target: [
{
target: 'AppImage',
arch: ['x64']
}
],
category: 'Utility'
}
}
}
};
export default config;

View File

@@ -56,6 +56,7 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@capacitor-community/sqlite/-/sqlite-6.0.2.tgz",
"integrity": "sha512-sj+2SPLu7E/3dM3xxcWwfNomG+aQHuN96/EFGrOtp4Dv30/2y5oIPyi6hZGjQGjPc5GDNoTQwW7vxWNzybjuMg==",
"license": "MIT",
"dependencies": {
"jeep-sqlite": "^2.7.2"
},

View File

@@ -50,7 +50,6 @@ process.stderr.on('error', (err) => {
const trayMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [new MenuItem({ label: 'Quit App', role: 'quit' })];
const appMenuBarMenuTemplate: (MenuItemConstructorOptions | MenuItem)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' },
];

View File

@@ -53,7 +53,6 @@ export class ElectronCapacitorApp {
];
private AppMenuBarMenuTemplate: (MenuItem | MenuItemConstructorOptions)[] = [
{ role: process.platform === 'darwin' ? 'appMenu' : 'fileMenu' },
{ role: 'editMenu' },
{ role: 'viewMenu' },
];
private mainWindowState;

View File

@@ -1,6 +1,6 @@
{
"compileOnSave": true,
"include": ["./src/**/*"],
"include": ["./src/**/*", "./capacitor.config.ts", "./capacitor.config.js"],
"compilerOptions": {
"outDir": "./build",
"importHelpers": true,

View File

@@ -403,7 +403,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -413,7 +413,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.8;
MARKETING_VERSION = 1.0.7;
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -430,7 +430,7 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
CURRENT_PROJECT_VERSION = 40;
DEVELOPMENT_TEAM = GM3FS5JQPH;
ENABLE_APP_SANDBOX = NO;
ENABLE_USER_SCRIPT_SANDBOXING = NO;
@@ -440,7 +440,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.8;
MARKETING_VERSION = 1.0.7;
PRODUCT_BUNDLE_IDENTIFIER = app.timesafari;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";

91
package-lock.json generated
View File

@@ -27,6 +27,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
"@jlongster/sql.js": "^1.6.7",
@@ -90,6 +91,7 @@
"vue": "3.5.13",
"vue-axios": "^3.5.2",
"vue-facing-decorator": "3.0.4",
"vue-markdown-render": "^2.2.1",
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0",
@@ -106,6 +108,7 @@
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.14.11",
"@types/node-fetch": "^2.6.12",
"@types/ramda": "^0.29.11",
@@ -6786,6 +6789,17 @@
"node": ">=6"
}
},
"node_modules/@fortawesome/free-regular-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.7.2.tgz",
"integrity": "sha512-7Z/ur0gvCMW8G93dXIQOkQqHo2M5HLhYrRVC0//fakJXxcF1VmMPsxnG6Ee8qEylA8b8Q3peQXWMNZ62lYF28g==",
"dependencies": {
"@fortawesome/fontawesome-common-types": "6.7.2"
},
"engines": {
"node": ">=6"
}
},
"node_modules/@fortawesome/free-solid-svg-icons": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz",
@@ -10147,6 +10161,12 @@
"@types/geojson": "*"
}
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"dev": true
},
"node_modules/@types/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
@@ -10154,6 +10174,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
"dev": true,
"dependencies": {
"@types/linkify-it": "^5",
"@types/mdurl": "^2"
}
},
"node_modules/@types/mdurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==",
"dev": true
},
"node_modules/@types/minimist": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
@@ -32883,6 +32919,61 @@
"vue": "^3.0.0"
}
},
"node_modules/vue-markdown-render": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/vue-markdown-render/-/vue-markdown-render-2.2.1.tgz",
"integrity": "sha512-XkYnC0PMdbs6Vy6j/gZXSvCuOS0787Se5COwXlepRqiqPiunyCIeTPQAO2XnB4Yl04EOHXwLx5y6IuszMWSgyQ==",
"dependencies": {
"markdown-it": "^13.0.2"
},
"peerDependencies": {
"vue": "^3.3.4"
}
},
"node_modules/vue-markdown-render/node_modules/entities": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-3.0.1.tgz",
"integrity": "sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/vue-markdown-render/node_modules/linkify-it": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-4.0.1.tgz",
"integrity": "sha512-C7bfi1UZmoj8+PQx22XyeXCuBlokoyWQL5pWSP+EI6nzRylyThouddufc2c1NDIcP9k5agmN9fLpA7VNJfIiqw==",
"dependencies": {
"uc.micro": "^1.0.1"
}
},
"node_modules/vue-markdown-render/node_modules/markdown-it": {
"version": "13.0.2",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-13.0.2.tgz",
"integrity": "sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==",
"dependencies": {
"argparse": "^2.0.1",
"entities": "~3.0.1",
"linkify-it": "^4.0.1",
"mdurl": "^1.0.1",
"uc.micro": "^1.0.5"
},
"bin": {
"markdown-it": "bin/markdown-it.js"
}
},
"node_modules/vue-markdown-render/node_modules/mdurl": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
"integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g=="
},
"node_modules/vue-markdown-render/node_modules/uc.micro": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
"integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA=="
},
"node_modules/vue-picture-cropper": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/vue-picture-cropper/-/vue-picture-cropper-0.7.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "timesafari",
"version": "1.1.1-beta",
"version": "1.1.0-beta",
"description": "Time Safari Application",
"author": {
"name": "Time Safari Team"
@@ -106,7 +106,7 @@
"guard": "bash ./scripts/build-arch-guard.sh",
"guard:test": "bash ./scripts/build-arch-guard.sh --staged",
"guard:setup": "npm run prepare && echo '✅ Build Architecture Guard is now active!'",
"clean:android": "./scripts/uninstall-android.sh",
"clean:android": "./scripts/clean-android.sh",
"clean:ios": "rm -rf ios/App/build ios/App/Pods ios/App/output ios/App/App/public ios/DerivedData ios/capacitor-cordova-ios-plugins ios/App/App/capacitor.config.json ios/App/App/config.xml || true",
"clean:electron": "./scripts/build-electron.sh --clean",
"clean:all": "npm run clean:ios && npm run clean:android && npm run clean:electron",
@@ -136,7 +136,6 @@
"*.{js,ts,vue,css,json,yml,yaml}": "eslint --fix || true",
"*.{md,markdown,mdc}": "markdownlint-cli2 --fix"
},
"dependencies": {
"@capacitor-community/electron": "^5.0.1",
"@capacitor-community/sqlite": "6.0.2",
@@ -157,6 +156,7 @@
"@ethersproject/hdnode": "^5.7.0",
"@ethersproject/wallet": "^5.8.0",
"@fortawesome/fontawesome-svg-core": "^6.5.1",
"@fortawesome/free-regular-svg-icons": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.5.1",
"@fortawesome/vue-fontawesome": "^3.0.6",
"@jlongster/sql.js": "^1.6.7",
@@ -220,6 +220,7 @@
"vue": "3.5.13",
"vue-axios": "^3.5.2",
"vue-facing-decorator": "3.0.4",
"vue-markdown-render": "^2.2.1",
"vue-picture-cropper": "^0.7.0",
"vue-qrcode-reader": "^5.5.3",
"vue-router": "^4.5.0",
@@ -236,6 +237,7 @@
"@types/js-yaml": "^4.0.9",
"@types/leaflet": "^1.9.8",
"@types/luxon": "^3.4.2",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.14.11",
"@types/node-fetch": "^2.6.12",
"@types/ramda": "^0.29.11",

View File

@@ -21,7 +21,7 @@ export default defineConfig({
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: 4,
workers: 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: [
['list'],

View File

@@ -1,46 +0,0 @@
{
"icons": [
{
"src": "../icons/icon-48.webp",
"type": "image/png",
"sizes": "48x48",
"purpose": "any maskable"
},
{
"src": "../icons/icon-72.webp",
"type": "image/png",
"sizes": "72x72",
"purpose": "any maskable"
},
{
"src": "../icons/icon-96.webp",
"type": "image/png",
"sizes": "96x96",
"purpose": "any maskable"
},
{
"src": "../icons/icon-128.webp",
"type": "image/png",
"sizes": "128x128",
"purpose": "any maskable"
},
{
"src": "../icons/icon-192.webp",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "../icons/icon-256.webp",
"type": "image/png",
"sizes": "256x256",
"purpose": "any maskable"
},
{
"src": "../icons/icon-512.webp",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
]
}

View File

@@ -1,389 +0,0 @@
#!/bin/bash
# avd-resource-checker.sh
# Author: Matthew Raymer
# Date: 2025-01-27
# Description: Check system resources and recommend optimal AVD configuration
set -e
# Source common utilities
source "$(dirname "$0")/common.sh"
# Colors for output
RED_COLOR='\033[0;31m'
GREEN_COLOR='\033[0;32m'
YELLOW_COLOR='\033[1;33m'
BLUE_COLOR='\033[0;34m'
NC_COLOR='\033[0m' # No Color
# Function to print colored output
print_status() {
local color=$1
local message=$2
echo -e "${color}${message}${NC_COLOR}"
}
# Function to get system memory in MB
get_system_memory() {
if command -v free >/dev/null 2>&1; then
free -m | awk 'NR==2{print $2}'
else
echo "0"
fi
}
# Function to get available memory in MB
get_available_memory() {
if command -v free >/dev/null 2>&1; then
free -m | awk 'NR==2{print $7}'
else
echo "0"
fi
}
# Function to get CPU core count
get_cpu_cores() {
if command -v nproc >/dev/null 2>&1; then
nproc
elif [ -f /proc/cpuinfo ]; then
grep -c ^processor /proc/cpuinfo
else
echo "1"
fi
}
# Function to check GPU capabilities
check_gpu_capabilities() {
local gpu_type="unknown"
local gpu_memory="0"
# Check for NVIDIA GPU
if command -v nvidia-smi >/dev/null 2>&1; then
gpu_type="nvidia"
gpu_memory=$(nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null | head -1 || echo "0")
print_status $GREEN_COLOR "✓ NVIDIA GPU detected (${gpu_memory}MB VRAM)"
return 0
fi
# Check for AMD GPU
if command -v rocm-smi >/dev/null 2>&1; then
gpu_type="amd"
print_status $GREEN_COLOR "✓ AMD GPU detected"
return 0
fi
# Check for Intel GPU
if lspci 2>/dev/null | grep -i "vga.*intel" >/dev/null; then
gpu_type="intel"
print_status $YELLOW_COLOR "✓ Intel integrated GPU detected"
return 1
fi
# Check for generic GPU
if lspci 2>/dev/null | grep -i "vga" >/dev/null; then
gpu_type="generic"
print_status $YELLOW_COLOR "✓ Generic GPU detected"
return 1
fi
print_status $RED_COLOR "✗ No GPU detected"
return 2
}
# Function to check if hardware acceleration is available
check_hardware_acceleration() {
local gpu_capable=$1
if [ $gpu_capable -eq 0 ]; then
print_status $GREEN_COLOR "✓ Hardware acceleration recommended"
return 0
elif [ $gpu_capable -eq 1 ]; then
print_status $YELLOW_COLOR "⚠ Limited hardware acceleration"
return 1
else
print_status $RED_COLOR "✗ No hardware acceleration available"
return 2
fi
}
# Function to recommend AVD configuration
recommend_avd_config() {
local total_memory=$1
local available_memory=$2
local cpu_cores=$3
local gpu_capable=$4
print_status $BLUE_COLOR "\n=== AVD Configuration Recommendation ==="
# Calculate recommended memory (leave 2GB for system)
local system_reserve=2048
local recommended_memory=$((available_memory - system_reserve))
# Cap memory at reasonable limits
if [ $recommended_memory -gt 4096 ]; then
recommended_memory=4096
elif [ $recommended_memory -lt 1024 ]; then
recommended_memory=1024
fi
# Calculate recommended cores (leave 2 cores for system)
local recommended_cores=$((cpu_cores - 2))
if [ $recommended_cores -lt 1 ]; then
recommended_cores=1
elif [ $recommended_cores -gt 4 ]; then
recommended_cores=4
fi
# Determine GPU setting
local gpu_setting=""
case $gpu_capable in
0) gpu_setting="-gpu host" ;;
1) gpu_setting="-gpu swiftshader_indirect" ;;
2) gpu_setting="-gpu swiftshader_indirect" ;;
esac
# Generate recommendation
print_status $GREEN_COLOR "Recommended AVD Configuration:"
echo " Memory: ${recommended_memory}MB"
echo " Cores: ${recommended_cores}"
echo " GPU: ${gpu_setting}"
# Get AVD name from function parameter (passed from main)
local avd_name=$5
local command="emulator -avd ${avd_name} -no-audio -memory ${recommended_memory} -cores ${recommended_cores} ${gpu_setting} &"
print_status $BLUE_COLOR "\nGenerated Command:"
echo " ${command}"
# Save to file for easy execution
local script_file="/tmp/start-avd-${avd_name}.sh"
cat > "$script_file" << EOF
#!/bin/bash
# Auto-generated AVD startup script
# Generated by avd-resource-checker.sh on $(date)
echo "Starting AVD: ${avd_name}"
echo "Memory: ${recommended_memory}MB"
echo "Cores: ${recommended_cores}"
echo "GPU: ${gpu_setting}"
${command}
echo "AVD started in background"
echo "Check status with: adb devices"
echo "View logs with: adb logcat"
EOF
chmod +x "$script_file"
print_status $GREEN_COLOR "\n✓ Startup script saved to: ${script_file}"
return 0
}
# Function to test AVD startup
test_avd_startup() {
local avd_name=$1
local test_duration=${2:-30}
print_status $BLUE_COLOR "\n=== Testing AVD Startup ==="
# Check if AVD exists
if ! avdmanager list avd | grep -q "$avd_name"; then
print_status $RED_COLOR "✗ AVD '$avd_name' not found"
return 1
fi
print_status $YELLOW_COLOR "Testing AVD startup for ${test_duration} seconds..."
# Start emulator in test mode
emulator -avd "$avd_name" -no-audio -no-window -no-snapshot -memory 1024 -cores 1 -gpu swiftshader_indirect &
local emulator_pid=$!
# Wait for boot
local boot_time=0
local max_wait=$test_duration
while [ $boot_time -lt $max_wait ]; do
if adb devices | grep -q "emulator.*device"; then
print_status $GREEN_COLOR "✓ AVD booted successfully in ${boot_time} seconds"
break
fi
sleep 2
boot_time=$((boot_time + 2))
done
# Cleanup
kill $emulator_pid 2>/dev/null || true
adb emu kill 2>/dev/null || true
if [ $boot_time -ge $max_wait ]; then
print_status $RED_COLOR "✗ AVD failed to boot within ${test_duration} seconds"
return 1
fi
return 0
}
# Function to list available AVDs
list_available_avds() {
print_status $BLUE_COLOR "\n=== Available AVDs ==="
if ! command -v avdmanager >/dev/null 2>&1; then
print_status $RED_COLOR "✗ avdmanager not found. Please install Android SDK command line tools."
return 1
fi
local avd_list=$(avdmanager list avd 2>/dev/null)
if [ -z "$avd_list" ]; then
print_status $YELLOW_COLOR "⚠ No AVDs found. Create one with:"
echo " avdmanager create avd --name TimeSafari_Emulator --package system-images;android-34;google_apis;x86_64"
return 1
fi
echo "$avd_list"
return 0
}
# Function to create optimized AVD
create_optimized_avd() {
local avd_name=$1
local memory=$2
local cores=$3
print_status $BLUE_COLOR "\n=== Creating Optimized AVD ==="
# Check if system image is available
local system_image="system-images;android-34;google_apis;x86_64"
if ! sdkmanager --list | grep -q "$system_image"; then
print_status $YELLOW_COLOR "Installing system image: $system_image"
sdkmanager "$system_image"
fi
# Create AVD
print_status $YELLOW_COLOR "Creating AVD: $avd_name"
avdmanager create avd \
--name "$avd_name" \
--package "$system_image" \
--device "pixel_7" \
--force
# Configure AVD
local avd_config_file="$HOME/.android/avd/${avd_name}.avd/config.ini"
if [ -f "$avd_config_file" ]; then
print_status $YELLOW_COLOR "Configuring AVD settings..."
# Set memory
sed -i "s/vm.heapSize=.*/vm.heapSize=${memory}/" "$avd_config_file"
# Set cores
sed -i "s/hw.cpu.ncore=.*/hw.cpu.ncore=${cores}/" "$avd_config_file"
# Disable unnecessary features
echo "hw.audioInput=no" >> "$avd_config_file"
echo "hw.audioOutput=no" >> "$avd_config_file"
echo "hw.camera.back=none" >> "$avd_config_file"
echo "hw.camera.front=none" >> "$avd_config_file"
echo "hw.gps=no" >> "$avd_config_file"
echo "hw.sensors.orientation=no" >> "$avd_config_file"
echo "hw.sensors.proximity=no" >> "$avd_config_file"
print_status $GREEN_COLOR "✓ AVD configured successfully"
fi
return 0
}
# Main function
main() {
print_status $BLUE_COLOR "=== TimeSafari AVD Resource Checker ==="
print_status $BLUE_COLOR "Checking system resources and recommending optimal AVD configuration\n"
# Get system information
local total_memory=$(get_system_memory)
local available_memory=$(get_available_memory)
local cpu_cores=$(get_cpu_cores)
print_status $BLUE_COLOR "=== System Information ==="
echo "Total Memory: ${total_memory}MB"
echo "Available Memory: ${available_memory}MB"
echo "CPU Cores: ${cpu_cores}"
# Check GPU capabilities
print_status $BLUE_COLOR "\n=== GPU Analysis ==="
check_gpu_capabilities
local gpu_capable=$?
# Check hardware acceleration
check_hardware_acceleration $gpu_capable
local hw_accel=$?
# List available AVDs
list_available_avds
# Get AVD name from user or use default
local avd_name="TimeSafari_Emulator"
if [ $# -gt 0 ]; then
avd_name="$1"
fi
# Recommend configuration
recommend_avd_config $total_memory $available_memory $cpu_cores $gpu_capable "$avd_name"
# Test AVD if requested
if [ "$2" = "--test" ]; then
test_avd_startup "$avd_name"
fi
# Create optimized AVD if requested
if [ "$2" = "--create" ]; then
local recommended_memory=$((available_memory - 2048))
if [ $recommended_memory -gt 4096 ]; then
recommended_memory=4096
elif [ $recommended_memory -lt 1024 ]; then
recommended_memory=1024
fi
local recommended_cores=$((cpu_cores - 2))
if [ $recommended_cores -lt 1 ]; then
recommended_cores=1
elif [ $recommended_cores -gt 4 ]; then
recommended_cores=4
fi
create_optimized_avd "$avd_name" $recommended_memory $recommended_cores
fi
print_status $GREEN_COLOR "\n=== Resource Check Complete ==="
print_status $YELLOW_COLOR "Tip: Use the generated startup script for consistent AVD launches"
}
# Show help
show_help() {
echo "Usage: $0 [AVD_NAME] [OPTIONS]"
echo ""
echo "Options:"
echo " --test Test AVD startup (30 second test)"
echo " --create Create optimized AVD with recommended settings"
echo " --help Show this help message"
echo ""
echo "Examples:"
echo " $0 # Check resources and recommend config"
echo " $0 TimeSafari_Emulator # Check resources for specific AVD"
echo " $0 TimeSafari_Emulator --test # Test AVD startup"
echo " $0 TimeSafari_Emulator --create # Create optimized AVD"
echo ""
echo "The script will:"
echo " - Analyze system resources (RAM, CPU, GPU)"
echo " - Recommend optimal AVD configuration"
echo " - Generate startup command and script"
echo " - Optionally test or create AVD"
}
# Parse command line arguments
if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then
show_help
exit 0
fi
# Run main function
main "$@"

View File

@@ -22,7 +22,6 @@
# --sync Sync Capacitor only
# --assets Generate assets only
# --deploy Deploy APK to connected device
# --uninstall Uninstall app from connected device
# -h, --help Show this help message
# -v, --verbose Enable verbose logging
#
@@ -197,7 +196,6 @@ SYNC_ONLY=false
ASSETS_ONLY=false
DEPLOY_APP=false
AUTO_RUN=false
UNINSTALL=false
CUSTOM_API_IP=""
# Function to parse Android-specific arguments
@@ -248,9 +246,6 @@ parse_android_args() {
--auto-run)
AUTO_RUN=true
;;
--uninstall)
UNINSTALL=true
;;
--api-ip)
if [ $((i + 1)) -lt ${#args[@]} ]; then
CUSTOM_API_IP="${args[$((i + 1))]}"
@@ -296,7 +291,6 @@ print_android_usage() {
echo " --assets Generate assets only"
echo " --deploy Deploy APK to connected device"
echo " --auto-run Auto-run app after build"
echo " --uninstall Uninstall app from connected device"
echo " --api-ip <ip> Custom IP address for claim API (defaults to 10.0.2.2)"
echo ""
echo "Common Options:"
@@ -311,7 +305,6 @@ print_android_usage() {
echo " $0 --clean # Clean only"
echo " $0 --sync # Sync only"
echo " $0 --deploy # Build and deploy to device"
echo " $0 --uninstall # Uninstall app from device"
echo " $0 --dev # Dev build with default 10.0.2.2"
echo " $0 --dev --api-ip 192.168.1.100 # Dev build with custom API IP"
echo ""
@@ -358,18 +351,8 @@ fi
# Setup application directories
setup_app_directories
# Load environment-specific .env file if it exists
env_file=".env.$BUILD_MODE"
if [ -f "$env_file" ]; then
load_env_file "$env_file"
else
log_debug "No $env_file file found, using default environment"
fi
# Load .env file if it exists (fallback)
if [ -f ".env" ]; then
load_env_file ".env"
fi
# Load environment from .env file if it exists
load_env_file ".env"
# Handle clean-only mode
if [ "$CLEAN_ONLY" = true ]; then
@@ -424,13 +407,8 @@ safe_execute "Validating asset configuration" "npm run assets:validate" || {
log_info "If you encounter build failures, please run 'npm install' first to ensure all dependencies are available."
}
# Step 2: Uninstall Android app
if [ "$UNINSTALL" = true ]; then
log_info "Uninstall: uninstalling app from device"
safe_execute "Uninstalling Android app" "./scripts/uninstall-android.sh" || exit 1
log_success "Uninstall completed successfully!"
exit 0
fi
# Step 2: Clean Android app
safe_execute "Cleaning Android app" "npm run clean:android" || exit 1
# Step 3: Clean dist directory
log_info "Cleaning dist directory..."

View File

@@ -181,7 +181,7 @@ sync_capacitor() {
copy_web_assets() {
log_info "Copying web assets to Electron"
safe_execute "Copying assets" "cp -r dist/* electron/app/"
# Note: Electron has its own capacitor.config.ts file, so we don't copy the main config
safe_execute "Copying config" "cp capacitor.config.json electron/capacitor.config.json"
}
# Compile TypeScript
@@ -341,19 +341,7 @@ main_electron_build() {
# Setup environment
setup_build_env "electron" "$BUILD_MODE"
setup_app_directories
# Load environment-specific .env file if it exists
env_file=".env.$BUILD_MODE"
if [ -f "$env_file" ]; then
load_env_file "$env_file"
else
log_debug "No $env_file file found, using default environment"
fi
# Load .env file if it exists (fallback)
if [ -f ".env" ]; then
load_env_file ".env"
fi
load_env_file ".env"
# Step 1: Clean Electron build artifacts
clean_electron_artifacts

View File

@@ -324,18 +324,8 @@ fi
# Setup application directories
setup_app_directories
# Load environment-specific .env file if it exists
env_file=".env.$BUILD_MODE"
if [ -f "$env_file" ]; then
load_env_file "$env_file"
else
log_debug "No $env_file file found, using default environment"
fi
# Load .env file if it exists (fallback)
if [ -f ".env" ]; then
load_env_file ".env"
fi
# Load environment from .env file if it exists
load_env_file ".env"
# Validate iOS environment
validate_ios_environment

View File

@@ -1,8 +1,8 @@
#!/bin/bash
# uninstall-android.sh
# clean-android.sh
# Author: Matthew Raymer
# Date: 2025-08-19
# Description: Uninstall Android app with timeout protection to prevent hanging
# Description: Clean Android app with timeout protection to prevent hanging
# This script safely uninstalls the TimeSafari app from connected Android devices
# with a 30-second timeout to prevent indefinite hanging.

View File

@@ -386,7 +386,7 @@ export default class App extends Vue {
let allGoingOff = false;
try {
const settings: Settings = await this.$accountSettings();
const settings: Settings = await this.$settings();
const notifyingNewActivity = !!settings?.notifyingNewActivityTime;
const notifyingReminder = !!settings?.notifyingReminderTime;

View File

@@ -22,4 +22,24 @@
.dialog {
@apply bg-white p-4 rounded-lg w-full max-w-lg;
}
/* Markdown content styling to restore list elements */
.markdown-content ul {
@apply list-disc list-inside ml-4;
}
.markdown-content ol {
@apply list-decimal list-inside ml-4;
}
.markdown-content li {
@apply mb-1;
}
.markdown-content ul ul,
.markdown-content ol ol,
.markdown-content ul ol,
.markdown-content ol ul {
@apply ml-4 mt-1;
}
}

View File

@@ -80,7 +80,10 @@
<!-- Description -->
<p class="font-medium">
<a class="cursor-pointer" @click="emitLoadClaim(record.jwtId)">
{{ description }}
<vue-markdown
:source="truncatedDescription"
class="markdown-content"
/>
</a>
</p>
@@ -258,11 +261,13 @@ import {
NOTIFY_UNKNOWN_PERSON,
} from "@/constants/notifications";
import { TIMEOUTS } from "@/utils/notify";
import VueMarkdown from "vue-markdown-render";
@Component({
components: {
EntityIcon,
ProjectIcon,
VueMarkdown,
},
})
export default class ActivityListItem extends Vue {
@@ -303,6 +308,14 @@ export default class ActivityListItem extends Vue {
return `${claim?.description || ""}`;
}
get truncatedDescription(): string {
const desc = this.description;
if (desc.length <= 300) {
return desc;
}
return desc.substring(0, 300) + "...";
}
private displayAmount(code: string, amt: number) {
return `${amt} ${this.currencyShortWordForCode(code, amt === 1)}`;
}

View File

@@ -16,12 +16,6 @@ messages * - Conditional UI based on platform capabilities * * @component *
:to="{ name: 'seed-backup' }"
:class="backupButtonClasses"
>
<!-- Notification dot - show while the user has not yet backed up their seed phrase -->
<font-awesome
v-if="!hasBackedUpSeed"
icon="circle"
class="absolute -right-[8px] -top-[8px] text-rose-500 text-[14px] border border-white rounded-full"
></font-awesome>
Backup Identifier Seed
</router-link>
@@ -104,12 +98,6 @@ export default class DataExportSection extends Vue {
*/
isExporting = false;
/**
* Flag indicating if the user has backed up their seed phrase
* Used to control the visibility of the notification dot
*/
hasBackedUpSeed = false;
/**
* Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called
@@ -141,7 +129,7 @@ export default class DataExportSection extends Vue {
* CSS classes for the backup button (router link)
*/
get backupButtonClasses(): string {
return "block relative w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2";
return "block w-full text-center text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-1.5 py-2 rounded-md mb-2 mt-2";
}
/**
@@ -230,22 +218,6 @@ export default class DataExportSection extends Vue {
created() {
this.notify = createNotifyHelpers(this.$notify);
this.loadSeedBackupStatus();
}
/**
* Loads the seed backup status from account settings
* Updates the hasBackedUpSeed flag to control notification dot visibility
*/
private async loadSeedBackupStatus(): Promise<void> {
try {
const settings = await this.$accountSettings();
this.hasBackedUpSeed = !!settings.hasBackedUpSeed;
} catch (err: unknown) {
logger.error("Failed to load seed backup status:", err);
// Default to false (show notification dot) if we can't load the setting
this.hasBackedUpSeed = false;
}
}
}
</script>

View File

@@ -82,7 +82,6 @@ import GiftDetailsStep from "../components/GiftDetailsStep.vue";
import { PlanData } from "../interfaces/records";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
NOTIFY_GIFT_ERROR_NO_DESCRIPTION,
@@ -220,18 +219,9 @@ export default class GiftedDialog extends Vue {
this.stepType = "giver";
try {
const settings = await this.$accountSettings();
const settings = await this.$settings();
this.apiServer = settings.apiServer || "";
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
logger.debug("[GiftedDialog] Settings received:", {
activeDid: this.activeDid,
apiServer: this.apiServer,
});
this.activeDid = settings.activeDid || "";
this.allContacts = await this.$contacts();
@@ -421,15 +411,6 @@ export default class GiftedDialog extends Vue {
);
} else {
this.safeNotify.success("That gift was recorded.", TIMEOUTS.VERY_LONG);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount);
}

View File

@@ -74,7 +74,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyTextToClipboard('A link to this page', deepLinkUrl)"
@click="copyToClipboard('A link to this page', deepLinkUrl)"
>click here to copy this page, paste it into a message, and ask if
they'll tell you more about the {{ roleName }}.</a
>
@@ -110,7 +110,7 @@
* @since 2024-12-19
*/
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import * as R from "ramda";
import * as serverUtil from "../libs/endorserServer";
import { Contact } from "../db/tables/contacts";
@@ -197,24 +197,19 @@ export default class HiddenDidDialog extends Vue {
);
}
async copyTextToClipboard(name: string, text: string) {
try {
await copyToClipboard(text);
this.notify.success(
NOTIFY_COPIED_TO_CLIPBOARD.message(name || "That"),
TIMEOUTS.SHORT,
);
} catch (error) {
this.$logAndConsole(
`Error copying ${name || "content"} to clipboard: ${error}`,
true,
);
this.notify.error(`Failed to copy ${name || "content"} to clipboard.`);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.notify.success(
NOTIFY_COPIED_TO_CLIPBOARD.message(name || "That"),
TIMEOUTS.SHORT,
);
});
}
onClickShareClaim() {
this.copyTextToClipboard("A link to this page", this.deepLinkUrl);
this.copyToClipboard("A link to this page", this.deepLinkUrl);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",

View File

@@ -132,7 +132,7 @@
v-if="shouldMirrorVideo"
class="absolute top-2 left-2 bg-black/50 text-white px-2 py-1 rounded text-xs"
>
<font-awesome icon="circle-user" class="w-[1em] mr-1" />
<font-awesome icon="mirror" class="w-[1em] mr-1" />
Mirrored
</div>
<div :class="cameraControlsClasses">
@@ -293,7 +293,7 @@ const inputImageFileNameRef = ref<Blob>();
export default class ImageMethodDialog extends Vue {
$notify!: NotifyFunction;
$router!: Router;
notify!: ReturnType<typeof createNotifyHelpers>;
notify = createNotifyHelpers(this.$notify);
/** Active DID for user authentication */
activeDid = "";
@@ -498,14 +498,9 @@ export default class ImageMethodDialog extends Vue {
* @throws {Error} When settings retrieval fails
*/
async mounted() {
// Initialize notification helpers
this.notify = createNotifyHelpers(this.$notify);
try {
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
const settings = await this.$accountSettings();
this.activeDid = settings.activeDid || "";
} catch (error) {
logger.error("Error retrieving settings from database:", error);
this.notify.error(

View File

@@ -232,12 +232,7 @@ export default class MembersList extends Vue {
this.notify = createNotifyHelpers(this.$notify);
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.firstName = settings.firstName || "";
await this.fetchMembers();

View File

@@ -64,7 +64,6 @@ import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_OFFER_SETTINGS_ERROR,
NOTIFY_OFFER_RECORDING,
@@ -176,11 +175,7 @@ export default class OfferDialog extends Vue {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
@@ -304,14 +299,6 @@ export default class OfferDialog extends Vue {
);
} else {
this.notify.success(NOTIFY_OFFER_SUCCESS.message, TIMEOUTS.VERY_LONG);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {

View File

@@ -270,12 +270,7 @@ export default class OnboardingDialog extends Vue {
async open(page: OnboardPage) {
this.page = page;
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered;
const contacts = await this.$getAllContacts();

View File

@@ -268,12 +268,7 @@ export default class PhotoDialog extends Vue {
// logger.log("PhotoDialog mounted");
try {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.isRegistered = !!settings.isRegistered;
logger.log("isRegistered:", this.isRegistered);
} catch (error: unknown) {

View File

@@ -28,7 +28,7 @@ import { logger } from "../utils/logger";
export default class TopMessage extends Vue {
// Enhanced PlatformServiceMixin v4.0 provides:
// - Cached database operations: this.$contacts(), this.$settings(), this.$accountSettings()
// - Settings shortcuts: this.$saveSettings()
// - Settings shortcuts: this.$saveSettings(), this.$saveMySettings()
// - Cache management: this.$refreshSettings(), this.$clearAllCaches()
// - Ultra-concise database methods: this.$db(), this.$exec(), this.$query()
// - All methods use smart caching with TTL for massive performance gains
@@ -49,11 +49,8 @@ export default class TopMessage extends Vue {
logger.debug("[TopMessage] 📥 Loading settings without overrides...");
const settings = await this.$accountSettings();
// Get activeDid from new active_identity table (ActiveDid migration)
const activeIdentity = await this.$getActiveIdentity();
logger.debug("[TopMessage] 📊 Settings loaded:", {
activeDid: activeIdentity.activeDid,
activeDid: settings.activeDid,
apiServer: settings.apiServer,
warnIfTestServer: settings.warnIfTestServer,
warnIfProdServer: settings.warnIfProdServer,
@@ -67,7 +64,7 @@ export default class TopMessage extends Vue {
settings.apiServer &&
settings.apiServer !== AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = activeIdentity.activeDid?.slice(11, 15);
const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You're not using prod, user " + didPrefix;
logger.debug("[TopMessage] ⚠️ Test server warning displayed:", {
apiServer: settings.apiServer,
@@ -78,7 +75,7 @@ export default class TopMessage extends Vue {
settings.apiServer &&
settings.apiServer === AppString.PROD_ENDORSER_API_SERVER
) {
const didPrefix = activeIdentity.activeDid?.slice(11, 15);
const didPrefix = settings.activeDid?.slice(11, 15);
this.message = "You are using prod, user " + didPrefix;
logger.debug("[TopMessage] ⚠️ Production server warning displayed:", {
apiServer: settings.apiServer,

View File

@@ -8,7 +8,7 @@
<!-- show spinner if loading limits -->
<div
v-if="loadingLimits"
class="text-slate-500 text-center italic mb-4"
class="text-center"
role="status"
aria-live="polite"
>
@@ -19,10 +19,7 @@
aria-hidden="true"
></font-awesome>
</div>
<div
v-if="limitsMessage"
class="bg-amber-200 text-amber-900 border-amber-500 border-dashed border text-center rounded-md overflow-hidden px-4 py-3 mb-4"
>
<div class="mb-4 text-center">
{{ limitsMessage }}
</div>
<div v-if="endorserLimits">

View File

@@ -84,7 +84,7 @@ export default class UserNameDialog extends Vue {
*/
async open(aCallback?: (name?: string) => void) {
this.callback = aCallback || this.callback;
const settings = await this.$accountSettings();
const settings = await this.$settings();
this.givenName = settings.firstName || "";
this.visible = true;
}
@@ -95,18 +95,7 @@ export default class UserNameDialog extends Vue {
*/
async onClickSaveChanges() {
try {
// Get activeDid from new active_identity table (ActiveDid migration)
const activeIdentity = await this.$getActiveIdentity();
const activeDid = activeIdentity.activeDid;
if (activeDid) {
// Save to user-specific settings for the current identity
await this.$saveUserSettings(activeDid, { firstName: this.givenName });
} else {
// Fallback to master settings if no active DID
await this.$saveSettings({ firstName: this.givenName });
}
await this.$updateSettings({ firstName: this.givenName });
this.visible = false;
this.callback(this.givenName);
} catch (error) {

View File

@@ -1689,11 +1689,3 @@ export const NOTIFY_CONTACTS_ADDED_CONFIRM = {
title: "They're Added To Your List",
message: "Would you like to go to the main page now?",
};
// ImportAccountView.vue specific constants
// Used in: ImportAccountView.vue (onImportClick method - duplicate account warning)
export const NOTIFY_DUPLICATE_ACCOUNT_IMPORT = {
title: "Account Already Imported",
message:
"This account has already been imported. Please use a different seed phrase or check your existing accounts.",
};

View File

@@ -4,7 +4,6 @@ import {
} from "../services/migrationService";
import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { arrayBufferToBase64 } from "@/libs/crypto";
import { logger } from "@/utils/logger";
// Generate a random secret for the secret table
@@ -29,61 +28,7 @@ import { logger } from "@/utils/logger";
// where they couldn't take action because they couldn't unlock that identity.)
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
const secretBase64 = arrayBufferToBase64(randomBytes.buffer);
// Single source of truth for migration 004 SQL
const MIG_004_SQL = `
-- Migration 004: active_identity_management (CONSOLIDATED)
-- Combines original migrations 004, 005, and 006 into single atomic operation
-- CRITICAL SECURITY: Uses ON DELETE RESTRICT constraint from the start
-- Assumes master code deployed with migration 003 (hasBackedUpSeed)
-- Enable foreign key constraints for data integrity
PRAGMA foreign_keys = ON;
-- Add UNIQUE constraint to accounts.did for foreign key support
CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_did_unique ON accounts(did);
-- Create active_identity table with SECURE constraint (ON DELETE RESTRICT)
-- This prevents accidental account deletion - critical security feature
CREATE TABLE IF NOT EXISTS active_identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
activeDid TEXT REFERENCES accounts(did) ON DELETE RESTRICT,
lastUpdated TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Add performance indexes
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_identity_single_record ON active_identity(id);
-- Seed singleton row (only if not already exists)
INSERT INTO active_identity (id, activeDid, lastUpdated)
SELECT 1, NULL, datetime('now')
WHERE NOT EXISTS (SELECT 1 FROM active_identity WHERE id = 1);
-- MIGRATE EXISTING DATA: Copy activeDid from settings to active_identity
-- This prevents data loss when migration runs on existing databases
UPDATE active_identity
SET activeDid = (SELECT activeDid FROM settings WHERE id = 1),
lastUpdated = datetime('now')
WHERE id = 1
AND EXISTS (SELECT 1 FROM settings WHERE id = 1 AND activeDid IS NOT NULL AND activeDid != '');
-- Copy important settings that were set in the MASTER_SETTINGS_KEY to the main identity.
-- (We're not doing them all because some were already identity-specific and others aren't as critical.)
UPDATE settings
SET lastViewedClaimId = (SELECT lastViewedClaimId FROM settings WHERE id = 1),
profileImageUrl = (SELECT profileImageUrl FROM settings WHERE id = 1),
showShortcutBvc = (SELECT showShortcutBvc FROM settings WHERE id = 1),
warnIfProdServer = (SELECT warnIfProdServer FROM settings WHERE id = 1),
warnIfTestServer = (SELECT warnIfTestServer FROM settings WHERE id = 1)
WHERE id = 2;
-- CLEANUP: Remove orphaned settings records and clear legacy activeDid values
-- which usually simply deletes the MASTER_SETTINGS_KEY record.
-- This completes the migration from settings-based to table-based active identity
DELETE FROM settings WHERE accountDid IS NULL;
UPDATE settings SET activeDid = NULL;
`;
const secretBase64 = arrayBufferToBase64(randomBytes);
// Each migration can include multiple SQL statements (with semicolons)
const MIGRATIONS = [
@@ -180,44 +125,14 @@ const MIGRATIONS = [
`,
},
{
name: "003_add_hasBackedUpSeed_to_settings",
name: "005_add_starredPlanHandleIds_to_settings",
sql: `
-- Add hasBackedUpSeed field to settings
-- This migration assumes master code has been deployed
-- The error handling will catch this if column already exists and mark migration as applied
ALTER TABLE settings ADD COLUMN hasBackedUpSeed BOOLEAN DEFAULT FALSE;
ALTER TABLE settings ADD COLUMN starredPlanHandleIds TEXT DEFAULT '[]'; -- JSON string
ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT;
`,
},
{
name: "004_active_identity_management",
sql: MIG_004_SQL,
},
];
/**
* Extract single value from database query result
* Works with different database service result formats
*/
function extractSingleValue<T>(result: T): string | number | null {
if (!result) return null;
// Handle AbsurdSQL format: QueryExecResult[]
if (Array.isArray(result) && result.length > 0 && result[0]?.values) {
const values = result[0].values;
return values.length > 0 ? values[0][0] : null;
}
// Handle Capacitor SQLite format: { values: unknown[][] }
if (typeof result === "object" && result !== null && "values" in result) {
const values = (result as { values: unknown[][] }).values;
return values && values.length > 0
? (values[0][0] as string | number)
: null;
}
return null;
}
/**
* @param sqlExec - A function that executes a SQL statement and returns the result
* @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations"
@@ -227,73 +142,8 @@ export async function runMigrations<T>(
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
// Only log migration start in development
const isDevelopment = process.env.VITE_PLATFORM === "development";
if (isDevelopment) {
logger.debug("[Migration] Starting database migrations");
}
for (const migration of MIGRATIONS) {
if (isDevelopment) {
logger.debug("[Migration] Registering migration:", migration.name);
}
registerMigration(migration);
}
if (isDevelopment) {
logger.debug("[Migration] Running migration service");
}
await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames);
if (isDevelopment) {
logger.debug("[Migration] Database migrations completed");
}
// Bootstrapping: Ensure active account is selected after migrations
if (isDevelopment) {
logger.debug("[Migration] Running bootstrapping hooks");
}
try {
// Check if we have accounts but no active selection
const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts");
const accountsCount = (extractSingleValue(accountsResult) as number) || 0;
// Check if active_identity table exists, and if not, try to recover
let activeDid: string | null = null;
try {
const activeResult = await sqlQuery(
"SELECT activeDid FROM active_identity WHERE id = 1",
);
activeDid = (extractSingleValue(activeResult) as string) || null;
} catch (error) {
// Table doesn't exist - migration 004 may not have run yet
if (isDevelopment) {
logger.debug(
"[Migration] active_identity table not found - migration may not have run",
);
}
activeDid = null;
}
if (accountsCount > 0 && (!activeDid || activeDid === "")) {
if (isDevelopment) {
logger.debug("[Migration] Auto-selecting first account as active");
}
const firstAccountResult = await sqlQuery(
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
);
const firstAccountDid =
(extractSingleValue(firstAccountResult) as string) || null;
if (firstAccountDid) {
await sqlExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[firstAccountDid],
);
logger.info(`[Migration] Set active account to: ${firstAccountDid}`);
}
}
} catch (error) {
logger.warn("[Migration] Bootstrapping hook failed (non-critical):", error);
}
}

View File

@@ -157,10 +157,11 @@ export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
result.columns,
result.values,
)[0] as Settings;
if (settings.searchBoxes) {
// @ts-expect-error - the searchBoxes field is a string in the DB
settings.searchBoxes = JSON.parse(settings.searchBoxes);
}
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
settings.starredPlanHandleIds = parseJsonField(
settings.starredPlanHandleIds,
[],
);
return settings;
}
}
@@ -226,10 +227,11 @@ export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
);
}
// Handle searchBoxes parsing
if (settings.searchBoxes) {
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
}
settings.searchBoxes = parseJsonField(settings.searchBoxes, []);
settings.starredPlanHandleIds = parseJsonField(
settings.starredPlanHandleIds,
[],
);
return settings;
} catch (error) {
@@ -567,8 +569,6 @@ export async function debugSettingsData(did?: string): Promise<void> {
* - Web SQLite (wa-sqlite/absurd-sql): Auto-parses JSON strings to objects
* - Capacitor SQLite: Returns raw strings that need manual parsing
*
* Maybe consolidate with PlatformServiceMixin._parseJsonField
*
* @param value The value to parse (could be string or already parsed object)
* @param defaultValue Default value if parsing fails
* @returns Parsed object or default value

View File

@@ -1,14 +0,0 @@
/**
* ActiveIdentity type describes the active identity selection.
* This replaces the activeDid field in the settings table for better
* database architecture and data integrity.
*
* @author Matthew Raymer
* @since 2025-08-29
*/
export interface ActiveIdentity {
id: number;
activeDid: string;
lastUpdated: string;
}

View File

@@ -9,8 +9,6 @@ export type Contact = {
// When adding a property:
// - Consider whether it should be added when exporting & sharing contacts, eg. DataExportSection
// - If it's a boolean, it should be converted from a 0/1 integer in PlatformServiceMixin._mapColumnsToValues
// - If it's a JSON string, it should be converted to an object/array in PlatformServiceMixin._mapColumnsToValues
//
did: string;
contactMethods?: Array<ContactMethod>;

View File

@@ -14,12 +14,6 @@ export type BoundingBox = {
* New entries that are boolean should also be added to PlatformServiceMixin._mapColumnsToValues
*/
export type Settings = {
//
// When adding a property:
// - If it's a boolean, it should be converted from a 0/1 integer in PlatformServiceMixin._mapColumnsToValues
// - If it's a JSON string, it should be converted to an object/array in PlatformServiceMixin._mapColumnsToValues
//
// default entry is keyed with MASTER_SETTINGS_KEY; other entries are linked to an account with account ID
id?: string | number; // this is erased for all those entries that are keyed with accountDid
@@ -35,7 +29,6 @@ export type Settings = {
finishedOnboarding?: boolean; // the user has completed the onboarding process
firstName?: string; // user's full name, may be null if unwanted for a particular account
hasBackedUpSeed?: boolean; // tracks whether the user has backed up their seed phrase
hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean;
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable
@@ -43,6 +36,7 @@ export type Settings = {
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred plan changes that they've acknowledged seeing
// The claim list has a most recent one used in notifications that's separate from the last viewed
lastNotifiedClaimId?: string;
@@ -67,15 +61,18 @@ export type Settings = {
showContactGivesInline?: boolean; // Display contact inline or not
showGeneralAdvanced?: boolean; // Show advanced features which don't have their own flag
showShortcutBvc?: boolean; // Show shortcut for Bountiful Voluntaryist Community actions
starredPlanHandleIds?: string[]; // Array of starred plan handle IDs
vapid?: string; // VAPID (Voluntary Application Server Identification) field for web push
warnIfProdServer?: boolean; // Warn if using a production server
warnIfTestServer?: boolean; // Warn if using a testing server
webPushServer?: string; // Web Push server URL
};
// type of settings where the searchBoxes are JSON strings instead of objects
// type of settings where the values are JSON strings instead of objects
export type SettingsWithJsonStrings = Settings & {
searchBoxes: string;
starredPlanHandleIds: string;
};
export function checkIsAnyFeedFilterOn(settings: Settings): boolean {
@@ -92,6 +89,11 @@ export const SettingsSchema = {
/**
* Constants.
*/
/**
* This is deprecated.
* It only remains for those with a PWA who have not migrated, but we'll soon remove it.
*/
export const MASTER_SETTINGS_KEY = "1";
export const DEFAULT_PASSKEY_EXPIRATION_MINUTES = 15;

View File

@@ -72,11 +72,15 @@ export interface PlanActionClaim extends ClaimObject {
name: string;
agent?: { identifier: string };
description?: string;
endTime?: string;
identifier?: string;
image?: string;
lastClaimId?: string;
location?: {
geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
};
startTime?: string;
url?: string;
}
// AKA Registration & RegisterAction

View File

@@ -1,4 +1,5 @@
import { GiveActionClaim, OfferClaim } from "./claims";
import { GiveActionClaim, OfferClaim, PlanActionClaim } from "./claims";
import { GenericCredWrapper } from "./common";
// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
@@ -61,6 +62,11 @@ export interface PlanSummaryRecord {
jwtId?: string;
}
export interface PlanSummaryAndPreviousClaim {
plan: PlanSummaryRecord;
wrappedClaimBefore: GenericCredWrapper<PlanActionClaim>;
}
/**
* Represents data about a project
*
@@ -87,7 +93,10 @@ export interface PlanData {
name: string;
/**
* The identifier of the project record -- different from jwtId
* (Maybe we should use the jwtId to iterate through the records instead.)
*
* This has been used to iterate through plan records, because jwtId ordering doesn't match
* chronological create ordering, though it does match most recent edit order (in reverse order).
* (It may be worthwhile to order by jwtId instead. It is an indexed field.)
**/
rowId?: string;
}

View File

@@ -16,7 +16,7 @@
* @module endorserServer
*/
import { Axios, AxiosRequestConfig, AxiosResponse } from "axios";
import { Axios, AxiosRequestConfig } from "axios";
import { Buffer } from "buffer";
import { sha256 } from "ethereum-cryptography/sha256";
import { LRUCache } from "lru-cache";
@@ -56,7 +56,12 @@ import {
KeyMetaWithPrivate,
KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records";
import {
OfferSummaryRecord,
OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { APP_SERVER } from "@/constants/app";
@@ -315,7 +320,7 @@ export function didInfoForContact(
return { displayName: "You", known: true };
} else if (contact) {
return {
displayName: contact.name || "Contact Without a Name",
displayName: contact.name || "Contact With No Name",
known: true,
profileImageUrl: contact.profileImageUrl,
};
@@ -362,6 +367,22 @@ export function didInfo(
return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
}
/**
* In some contexts (eg. agent), a blank really is nobody.
*/
export function didInfoOrNobody(
did: string | undefined,
activeDid: string | undefined,
allMyDids: string[],
contacts: Contact[],
): string {
if (did == null) {
return "Nobody";
} else {
return didInfo(did, activeDid, allMyDids, contacts);
}
}
/**
* return text description without any references to "you" as user
*/
@@ -730,7 +751,7 @@ export async function getNewOffersToUser(
activeDid: string,
afterOfferJwtId?: string,
beforeOfferJwtId?: string,
) {
): Promise<{ data: Array<OfferSummaryRecord>; hitLimit: boolean }> {
let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`;
if (afterOfferJwtId) {
url += "&afterId=" + afterOfferJwtId;
@@ -752,7 +773,7 @@ export async function getNewOffersToUserProjects(
activeDid: string,
afterOfferJwtId?: string,
beforeOfferJwtId?: string,
) {
): Promise<{ data: Array<OfferToPlanSummaryRecord>; hitLimit: boolean }> {
let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`;
if (afterOfferJwtId) {
url += "?afterId=" + afterOfferJwtId;
@@ -766,6 +787,44 @@ export async function getNewOffersToUserProjects(
return response.data;
}
/**
* Get starred projects that have been updated since the last check
*
* @param axios - axios instance
* @param apiServer - endorser API server URL
* @param activeDid - user's DID for authentication
* @param starredPlanHandleIds - array of starred project handle IDs
* @param afterId - JWT ID to check for changes after (from lastAckedStarredPlanChangesJwtId)
* @returns { data: Array<PlanSummaryAndPreviousClaim>, hitLimit: boolean }
*/
export async function getStarredProjectsWithChanges(
axios: Axios,
apiServer: string,
activeDid: string,
starredPlanHandleIds: string[],
afterId?: string,
): Promise<{ data: Array<PlanSummaryAndPreviousClaim>; hitLimit: boolean }> {
if (!starredPlanHandleIds || starredPlanHandleIds.length === 0) {
return { data: [], hitLimit: false };
}
if (!afterId) {
return { data: [], hitLimit: false };
}
// Use POST method for larger lists of project IDs
const url = `${apiServer}/api/v2/report/plansLastUpdatedBetween`;
const headers = await getHeaders(activeDid);
const requestBody = {
planIds: starredPlanHandleIds,
afterId: afterId,
};
const response = await axios.post(url, requestBody, { headers });
return response.data;
}
/**
* Construct GiveAction VC for submission to server
*
@@ -1131,7 +1190,7 @@ export async function createAndSubmitClaim(
// Enhanced diagnostic logging for claim submission
const requestId = `claim_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
logger.debug("[Claim Submission] 🚀 Starting claim submission:", {
logger.info("[Claim Submission] 🚀 Starting claim submission:", {
requestId,
apiServer,
requesterDid: issuerDid,
@@ -1157,7 +1216,7 @@ export async function createAndSubmitClaim(
},
});
logger.debug("[Claim Submission] ✅ Claim submitted successfully:", {
logger.info("[Claim Submission] ✅ Claim submitted successfully:", {
requestId,
status: response.status,
handleId: response.data?.handleId,
@@ -1313,28 +1372,6 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
};
/**
* Formats type string for display by adding spaces before capitals
* and optionally adds an appropriate article prefix (a/an)
*
* @param text - Text to format
* @returns Formatted string with article prefix
*/
export const capitalizeAndInsertSpacesBeforeCapsWithAPrefix = (
text: string,
): string => {
const word = capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
}
};
/**
return readable summary of claim, or something generic
@@ -1754,7 +1791,7 @@ export async function fetchImageRateLimits(
axios: Axios,
issuerDid: string,
imageServer?: string,
): Promise<AxiosResponse | null> {
) {
const server = imageServer || DEFAULT_IMAGE_API_SERVER;
const url = server + "/image-limits";
const headers = await getHeaders(issuerDid);
@@ -1788,7 +1825,7 @@ export async function fetchImageRateLimits(
};
};
logger.error("[Image Server] Image rate limits check failed:", {
logger.warn("[Image Server] Image rate limits check failed:", {
did: issuerDid,
server: server,
errorCode: axiosError.response?.data?.error?.code,
@@ -1796,6 +1833,7 @@ export async function fetchImageRateLimits(
httpStatus: axiosError.response?.status,
timestamp: new Date().toISOString(),
});
return null;
throw error;
}
}

View File

@@ -86,6 +86,7 @@ import {
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faStar,
faThumbtack,
faTrashCan,
faTriangleExclamation,
@@ -94,6 +95,9 @@ import {
faXmark,
} from "@fortawesome/free-solid-svg-icons";
// these are referenced differently, eg. ":icon='['far', 'star']'" as in ProjectViewView.vue
import { faStar as faStarRegular } from "@fortawesome/free-regular-svg-icons";
// Initialize Font Awesome library with all required icons
library.add(
faArrowDown,
@@ -168,14 +172,16 @@ library.add(
faPlus,
faQrcode,
faQuestion,
faRotate,
faRightFromBracket,
faRotate,
faShareNodes,
faSpinner,
faSquare,
faSquareCaretDown,
faSquareCaretUp,
faSquarePlus,
faStar,
faStarRegular,
faThumbtack,
faTrashCan,
faTriangleExclamation,

View File

@@ -3,7 +3,7 @@
import axios, { AxiosResponse } from "axios";
import { Buffer } from "buffer";
import * as R from "ramda";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { DEFAULT_PUSH_SERVER, NotificationIface } from "../constants/app";
import { Account, AccountEncrypted } from "../db/tables/accounts";
@@ -160,49 +160,6 @@ export const isGiveAction = (
return isGiveClaimType(veriClaim.claimType);
};
export interface OfferFulfillment {
offerHandleId: string;
offerType: string;
}
interface FulfillmentItem {
"@type": string;
identifier?: string;
[key: string]: unknown;
}
/**
* Extract offer fulfillment information from the fulfills field
* Handles both array and single object cases
*/
export const extractOfferFulfillment = (
fulfills: FulfillmentItem | FulfillmentItem[] | null | undefined,
): OfferFulfillment | null => {
if (!fulfills) {
return null;
}
// Handle both array and single object cases
let offerFulfill = null;
if (Array.isArray(fulfills)) {
// Find the Offer in the fulfills array
offerFulfill = fulfills.find((item) => item["@type"] === "Offer");
} else if (fulfills["@type"] === "Offer") {
// fulfills is a single Offer object
offerFulfill = fulfills;
}
if (offerFulfill) {
return {
offerHandleId: offerFulfill.identifier || "",
offerType: offerFulfill["@type"],
};
}
return null;
};
export const shortDid = (did: string) => {
if (did.startsWith("did:peer:")) {
return (
@@ -240,19 +197,11 @@ export const nameForContact = (
);
};
export const doCopyTwoSecRedo = async (
text: string,
fn: () => void,
): Promise<void> => {
export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
fn();
try {
await copyToClipboard(text);
setTimeout(fn, 2000);
} catch (error) {
// Note: This utility function doesn't have access to notification system
// The calling component should handle error notifications
// Error is silently caught to avoid breaking the 2-second redo pattern
}
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
};
export interface ConfirmerData {
@@ -665,65 +614,57 @@ export const retrieveAllAccountsMetadata = async (): Promise<
return result;
};
export const DUPLICATE_ACCOUNT_ERROR = "Cannot import duplicate account.";
/**
* Saves a new identity to SQL database
* Saves a new identity to both SQL and Dexie databases
*/
export async function saveNewIdentity(
identity: IIdentifier,
mnemonic: string,
derivationPath: string,
): Promise<void> {
// add to the new sql db
const platformService = await getPlatformService();
try {
// add to the new sql db
const platformService = await getPlatformService();
// Check if account already exists before attempting to save
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[identity.did],
);
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
if (!secrets?.values?.length || !secrets.values[0]?.length) {
throw new Error(
"No initial encryption supported. We recommend you clear your data and start over.",
);
}
if (existingAccount?.values?.length) {
const secretBase64 = secrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const identityStr = JSON.stringify(identity);
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`;
const params = [
new Date().toISOString(),
derivationPath,
identity.did,
encryptedIdentityBase64,
encryptedMnemonicBase64,
identity.keys[0].publicKeyHex,
];
await platformService.dbExec(sql, params);
await platformService.updateDefaultSettings({ activeDid: identity.did });
await platformService.insertNewDidIntoSettings(identity.did);
} catch (error) {
logger.error("Failed to update default settings:", error);
throw new Error(
`Account with DID ${identity.did} already exists. ${DUPLICATE_ACCOUNT_ERROR}`,
"Failed to set default settings. Please try again or restart the app.",
);
}
const secrets = await platformService.dbQuery(
`SELECT secretBase64 FROM secret`,
);
if (!secrets?.values?.length || !secrets.values[0]?.length) {
throw new Error(
"No initial encryption supported. We recommend you clear your data and start over.",
);
}
const secretBase64 = secrets.values[0][0] as string;
const secret = base64ToArrayBuffer(secretBase64);
const identityStr = JSON.stringify(identity);
const encryptedIdentity = await simpleEncrypt(identityStr, secret);
const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
VALUES (?, ?, ?, ?, ?, ?)`;
const params = [
new Date().toISOString(),
derivationPath,
identity.did,
encryptedIdentityBase64,
encryptedMnemonicBase64,
identity.keys[0].publicKeyHex,
];
await platformService.dbExec(sql, params);
// Update active identity in the active_identity table instead of settings
await platformService.updateActiveDid(identity.did);
await platformService.insertNewDidIntoSettings(identity.did);
}
/**
@@ -774,8 +715,7 @@ export const registerSaveAndActivatePasskey = async (
): Promise<Account> => {
const account = await registerAndSavePasskey(keyName);
const platformService = await getPlatformService();
// Update active identity in the active_identity table instead of settings
await platformService.updateActiveDid(account.did);
await platformService.updateDefaultSettings({ activeDid: account.did });
await platformService.updateDidSpecificSettings(account.did, {
isRegistered: false,
});
@@ -1092,58 +1032,3 @@ export async function importFromMnemonic(
}
}
}
/**
* Checks if an account with the given DID already exists in the database
*
* @param did - The DID to check for duplicates
* @returns Promise<boolean> - True if account already exists, false otherwise
* @throws Error if database query fails
*/
export async function checkForDuplicateAccount(did: string): Promise<boolean>;
/**
* Checks if an account with the given DID already exists in the database
*
* @param mnemonic - The mnemonic phrase to derive DID from
* @param derivationPath - The derivation path to use
* @returns Promise<boolean> - True if account already exists, false otherwise
* @throws Error if database query fails
*/
export async function checkForDuplicateAccount(
mnemonic: string,
derivationPath: string,
): Promise<boolean>;
/**
* Implementation of checkForDuplicateAccount with overloaded signatures
*/
export async function checkForDuplicateAccount(
didOrMnemonic: string,
derivationPath?: string,
): Promise<boolean> {
let didToCheck: string;
if (derivationPath) {
// Derive the DID from mnemonic and derivation path
const [address, privateHex, publicHex] = deriveAddress(
didOrMnemonic.trim().toLowerCase(),
derivationPath,
);
const newId = newIdentifier(address, privateHex, publicHex, derivationPath);
didToCheck = newId.did;
} else {
// Use the provided DID directly
didToCheck = didOrMnemonic;
}
// Check if an account with this DID already exists
const platformService = await getPlatformService();
const existingAccount = await platformService.dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[didToCheck],
);
return (existingAccount?.values?.length ?? 0) > 0;
}

View File

@@ -69,18 +69,18 @@ const deepLinkHandler = new DeepLinkHandler(router);
*/
const handleDeepLink = async (data: { url: string }) => {
const { url } = data;
logger.debug(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
logger.info(`[Main] 🌐 Deeplink received from Capacitor: ${url}`);
try {
// Wait for router to be ready
logger.debug(`[Main] ⏳ Waiting for router to be ready...`);
logger.info(`[Main] ⏳ Waiting for router to be ready...`);
await router.isReady();
logger.debug(`[Main] ✅ Router is ready, processing deeplink`);
logger.info(`[Main] ✅ Router is ready, processing deeplink`);
// Process the deeplink
logger.debug(`[Main] 🚀 Starting deeplink processing`);
logger.info(`[Main] 🚀 Starting deeplink processing`);
await deepLinkHandler.handleDeepLink(url);
logger.debug(`[Main] ✅ Deeplink processed successfully`);
logger.info(`[Main] ✅ Deeplink processed successfully`);
} catch (error) {
logger.error(`[Main] ❌ Deeplink processing failed:`, {
url,
@@ -115,25 +115,25 @@ const registerDeepLinkListener = async () => {
);
// Check if Capacitor App plugin is available
logger.debug(`[Main] 🔍 Checking Capacitor App plugin availability...`);
logger.info(`[Main] 🔍 Checking Capacitor App plugin availability...`);
if (!CapacitorApp) {
throw new Error("Capacitor App plugin not available");
}
logger.info(`[Main] ✅ Capacitor App plugin is available`);
// Check available methods on CapacitorApp
logger.debug(
logger.info(
`[Main] 🔍 Capacitor App plugin methods:`,
Object.getOwnPropertyNames(CapacitorApp),
);
logger.debug(
logger.info(
`[Main] 🔍 Capacitor App plugin addListener method:`,
typeof CapacitorApp.addListener,
);
// Wait for router to be ready first
await router.isReady();
logger.debug(
logger.info(
`[Main] ✅ Router is ready, proceeding with listener registration`,
);
@@ -148,6 +148,9 @@ const registerDeepLinkListener = async () => {
listenerHandle,
);
// Test the listener registration by checking if it's actually registered
logger.info(`[Main] 🧪 Verifying listener registration...`);
return listenerHandle;
} catch (error) {
logger.error(`[Main] ❌ Failed to register deeplink listener:`, {

View File

@@ -24,12 +24,12 @@ logger.info("[Main] 🌍 Boot-time environment configuration:", {
// Dynamically import the appropriate main entry point
if (platform === "capacitor") {
logger.debug(`[Main] 📱 Loading Capacitor-specific entry point`);
logger.info(`[Main] 📱 Loading Capacitor-specific entry point`);
import("./main.capacitor");
} else if (platform === "electron") {
logger.debug(`[Main] 💻 Loading Electron-specific entry point`);
logger.info(`[Main] 💻 Loading Electron-specific entry point`);
import("./main.electron");
} else {
logger.debug(`[Main] 🌐 Loading Web-specific entry point`);
logger.info(`[Main] 🌐 Loading Web-specific entry point`);
import("./main.web");
}

View File

@@ -387,7 +387,7 @@ router.beforeEach(async (to, _from, next) => {
);
}
logger.debug(`[Router] ✅ Navigation guard passed for: ${to.path}`);
logger.info(`[Router] ✅ Navigation guard passed for: ${to.path}`);
next();
} catch (error) {
logger.error("[Router] ❌ Identity creation failed in navigation guard:", {

View File

@@ -155,16 +155,6 @@ export interface PlatformService {
*/
dbGetOneRow(sql: string, params?: unknown[]): Promise<unknown[] | undefined>;
/**
* Not recommended except for debugging.
* Return the raw result of a SQL query.
*
* @param sql - The SQL query to execute
* @param params - The parameters to pass to the query
* @returns Promise resolving to the raw query result, or undefined if no results
*/
dbRawQuery(sql: string, params?: unknown[]): Promise<unknown | undefined>;
// Database utility methods
/**
* Generates an INSERT SQL statement for a given model and table.
@@ -183,7 +173,6 @@ export interface PlatformService {
* @returns Promise that resolves when the update is complete
*/
updateDefaultSettings(settings: Record<string, unknown>): Promise<void>;
updateActiveDid(did: string): Promise<void>;
/**
* Inserts a new DID into the settings table.

View File

@@ -36,7 +36,7 @@ export class WebInlineQRScanner implements QRScannerService {
// Generate a short random ID for this scanner instance
this.id = Math.random().toString(36).substring(2, 8).toUpperCase();
this.options = options ?? {};
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Initializing scanner with options:`,
{
...this.options,
@@ -49,7 +49,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.context = this.canvas.getContext("2d", { willReadFrequently: true });
this.video = document.createElement("video");
this.video.setAttribute("playsinline", "true"); // Required for iOS
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] DOM elements created successfully`,
);
}
@@ -60,7 +60,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.cameraStateListeners.forEach((listener) => {
try {
listener.onStateChange(state, message);
logger.debug(
logger.info(
`[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`,
{
state,
@@ -89,7 +89,7 @@ export class WebInlineQRScanner implements QRScannerService {
async checkPermissions(): Promise<boolean> {
try {
this.updateCameraState("initializing", "Checking camera permissions...");
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Checking camera permissions...`,
);
@@ -99,7 +99,7 @@ export class WebInlineQRScanner implements QRScannerService {
const permissions = await navigator.permissions.query({
name: "camera" as PermissionName,
});
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`,
permissions.state,
);
@@ -165,7 +165,7 @@ export class WebInlineQRScanner implements QRScannerService {
"initializing",
"Requesting camera permissions...",
);
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera permissions...`,
);
@@ -175,7 +175,7 @@ export class WebInlineQRScanner implements QRScannerService {
(device) => device.kind === "videoinput",
);
logger.debug(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, {
count: videoDevices.length,
devices: videoDevices.map((d) => ({ id: d.deviceId, label: d.label })),
userAgent: navigator.userAgent,
@@ -188,7 +188,7 @@ export class WebInlineQRScanner implements QRScannerService {
}
// Try to get a stream with specific constraints
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera stream with constraints:`,
{
facingMode: "environment",
@@ -210,7 +210,7 @@ export class WebInlineQRScanner implements QRScannerService {
// Stop the test stream immediately
stream.getTracks().forEach((track) => {
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, {
kind: track.kind,
label: track.label,
readyState: track.readyState,
@@ -275,12 +275,12 @@ export class WebInlineQRScanner implements QRScannerService {
async isSupported(): Promise<boolean> {
try {
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Checking browser support...`,
);
// Check for secure context first
if (!window.isSecureContext) {
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Camera access requires HTTPS (secure context)`,
);
return false;
@@ -300,7 +300,7 @@ export class WebInlineQRScanner implements QRScannerService {
(device) => device.kind === "videoinput",
);
logger.debug(`[WebInlineQRScanner:${this.id}] Device support check:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Device support check:`, {
hasSecureContext: window.isSecureContext,
hasMediaDevices: !!navigator.mediaDevices,
hasGetUserMedia: !!navigator.mediaDevices?.getUserMedia,
@@ -379,7 +379,7 @@ export class WebInlineQRScanner implements QRScannerService {
// Log scan attempt every 100 frames or 1 second
if (this.scanAttempts % 100 === 0 || timeSinceLastScan >= 1000) {
logger.debug(`[WebInlineQRScanner:${this.id}] Scanning frame:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, {
attempt: this.scanAttempts,
dimensions: {
width: this.canvas.width,
@@ -421,7 +421,7 @@ export class WebInlineQRScanner implements QRScannerService {
!code.data ||
code.data.length === 0;
logger.debug(`[WebInlineQRScanner:${this.id}] QR Code detected:`, {
logger.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, {
data: code.data,
location: code.location,
attempts: this.scanAttempts,
@@ -512,13 +512,13 @@ export class WebInlineQRScanner implements QRScannerService {
this.scanAttempts = 0;
this.lastScanTime = Date.now();
this.updateCameraState("initializing", "Starting camera...");
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Starting scan with options:`,
this.options,
);
// Get camera stream with options
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Requesting camera stream...`,
);
this.stream = await navigator.mediaDevices.getUserMedia({
@@ -531,7 +531,7 @@ export class WebInlineQRScanner implements QRScannerService {
this.updateCameraState("active", "Camera is active");
logger.debug(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, {
tracks: this.stream.getTracks().map((t) => ({
kind: t.kind,
label: t.label,
@@ -550,14 +550,14 @@ export class WebInlineQRScanner implements QRScannerService {
this.video.style.display = "none";
}
await this.video.play();
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Video element started playing`,
);
}
// Emit stream to component
this.events.emit("stream", this.stream);
logger.debug(`[WebInlineQRScanner:${this.id}] Stream event emitted`);
logger.error(`[WebInlineQRScanner:${this.id}] Stream event emitted`);
// Start QR code scanning
this.scanQRCode();
@@ -595,7 +595,7 @@ export class WebInlineQRScanner implements QRScannerService {
}
try {
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping scan`, {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, {
scanAttempts: this.scanAttempts,
duration: Date.now() - this.lastScanTime,
});
@@ -604,7 +604,7 @@ export class WebInlineQRScanner implements QRScannerService {
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Animation frame cancelled`,
);
}
@@ -613,13 +613,13 @@ export class WebInlineQRScanner implements QRScannerService {
if (this.video) {
this.video.pause();
this.video.srcObject = null;
logger.debug(`[WebInlineQRScanner:${this.id}] Video element stopped`);
logger.error(`[WebInlineQRScanner:${this.id}] Video element stopped`);
}
// Stop all tracks in the stream
if (this.stream) {
this.stream.getTracks().forEach((track) => {
logger.debug(`[WebInlineQRScanner:${this.id}] Stopping track:`, {
logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, {
kind: track.kind,
label: track.label,
readyState: track.readyState,
@@ -631,7 +631,7 @@ export class WebInlineQRScanner implements QRScannerService {
// Emit stream stopped event
this.events.emit("stream", null);
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Stream stopped event emitted`,
);
} catch (error) {
@@ -643,17 +643,17 @@ export class WebInlineQRScanner implements QRScannerService {
throw error;
} finally {
this.isScanning = false;
logger.debug(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
logger.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`);
}
}
addListener(listener: ScanListener): void {
logger.debug(`[WebInlineQRScanner:${this.id}] Adding scan listener`);
logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`);
this.scanListener = listener;
}
onStream(callback: (stream: MediaStream | null) => void): void {
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Adding stream event listener`,
);
this.events.on("stream", callback);
@@ -661,24 +661,24 @@ export class WebInlineQRScanner implements QRScannerService {
async cleanup(): Promise<void> {
try {
logger.debug(`[WebInlineQRScanner:${this.id}] Starting cleanup`);
logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`);
await this.stopScan();
this.events.removeAllListeners();
logger.debug(`[WebInlineQRScanner:${this.id}] Event listeners removed`);
logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`);
// Clean up DOM elements
if (this.video) {
this.video.remove();
this.video = null;
logger.debug(`[WebInlineQRScanner:${this.id}] Video element removed`);
logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`);
}
if (this.canvas) {
this.canvas.remove();
this.canvas = null;
logger.debug(`[WebInlineQRScanner:${this.id}] Canvas element removed`);
logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`);
}
this.context = null;
logger.debug(
logger.error(
`[WebInlineQRScanner:${this.id}] Cleanup completed successfully`,
);
} catch (error) {

View File

@@ -73,8 +73,6 @@ interface Migration {
name: string;
/** SQL statement(s) to execute for this migration */
sql: string;
/** Optional array of individual SQL statements for better error handling */
statements?: string[];
}
/**
@@ -227,104 +225,6 @@ export function registerMigration(migration: Migration): void {
* }
* ```
*/
/**
* Helper function to check if a SQLite result indicates a table exists
* @param result - The result from a sqlite_master query
* @returns true if the table exists
*/
function checkSqliteTableResult(result: unknown): boolean {
return (
(result as unknown as { values: unknown[][] })?.values?.length > 0 ||
(Array.isArray(result) && result.length > 0)
);
}
/**
* Helper function to validate that a table exists in the database
* @param tableName - Name of the table to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if table exists
*/
async function validateTableExists<T>(
tableName: string,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
try {
const result = await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`,
);
return checkSqliteTableResult(result);
} catch (error) {
logger.error(`❌ [Validation] Error checking table ${tableName}:`, error);
return false;
}
}
/**
* Helper function to validate that a column exists in a table
* @param tableName - Name of the table
* @param columnName - Name of the column to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to true if column exists
*/
async function validateColumnExists<T>(
tableName: string,
columnName: string,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<boolean> {
try {
await sqlQuery(`SELECT ${columnName} FROM ${tableName} LIMIT 1`);
return true;
} catch (error) {
logger.error(
`❌ [Validation] Error checking column ${columnName} in ${tableName}:`,
error,
);
return false;
}
}
/**
* Helper function to validate multiple tables exist
* @param tableNames - Array of table names to check
* @param sqlQuery - Function to execute SQL queries
* @returns Promise resolving to array of validation results
*/
async function validateMultipleTables<T>(
tableNames: string[],
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
): Promise<{ exists: boolean; missing: string[] }> {
const missing: string[] = [];
for (const tableName of tableNames) {
const exists = await validateTableExists(tableName, sqlQuery);
if (!exists) {
missing.push(tableName);
}
}
return {
exists: missing.length === 0,
missing,
};
}
/**
* Helper function to add validation error with consistent logging
* @param validation - The validation object to update
* @param message - Error message to add
* @param error - The error object for logging
*/
function addValidationError(
validation: MigrationValidation,
message: string,
error: unknown,
): void {
validation.isValid = false;
validation.errors.push(message);
logger.error(`❌ [Migration-Validation] ${message}:`, error);
}
async function validateMigrationApplication<T>(
migration: Migration,
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
@@ -348,82 +248,36 @@ async function validateMigrationApplication<T>(
"temp",
];
const tableValidation = await validateMultipleTables(tables, sqlQuery);
if (!tableValidation.exists) {
validation.isValid = false;
validation.errors.push(
`Missing tables: ${tableValidation.missing.join(", ")}`,
);
logger.error(
`❌ [Migration-Validation] Missing tables:`,
tableValidation.missing,
);
}
validation.tableExists = tableValidation.exists;
} else if (migration.name === "002_add_iViewContent_to_contacts") {
// Validate iViewContent column exists in contacts table
const columnExists = await validateColumnExists(
"contacts",
"iViewContent",
sqlQuery,
);
if (!columnExists) {
addValidationError(
validation,
"Column iViewContent missing from contacts table",
new Error("Column not found"),
);
} else {
validation.hasExpectedColumns = true;
}
} else if (migration.name === "004_active_identity_management") {
// Validate active_identity table exists and has correct structure
const activeIdentityExists = await validateTableExists(
"active_identity",
sqlQuery,
);
if (!activeIdentityExists) {
addValidationError(
validation,
"Table active_identity missing",
new Error("Table not found"),
);
} else {
validation.tableExists = true;
// Check that active_identity has the expected structure
const hasExpectedColumns = await validateColumnExists(
"active_identity",
"id, activeDid, lastUpdated",
sqlQuery,
);
if (!hasExpectedColumns) {
addValidationError(
validation,
"active_identity table missing expected columns",
new Error("Columns not found"),
for (const tableName of tables) {
try {
await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='${tableName}'`,
);
// Reduced logging - only log on error
} catch (error) {
validation.isValid = false;
validation.errors.push(`Table ${tableName} missing`);
logger.error(
`❌ [Migration-Validation] Table ${tableName} missing:`,
error,
);
} else {
validation.hasExpectedColumns = true;
}
}
// Check that hasBackedUpSeed column exists in settings table
// Note: This validation is included here because migration 004 is consolidated
// and includes the functionality from the original migration 003
const hasBackedUpSeedExists = await validateColumnExists(
"settings",
"hasBackedUpSeed",
sqlQuery,
);
if (!hasBackedUpSeedExists) {
addValidationError(
validation,
"Column hasBackedUpSeed missing from settings table",
new Error("Column not found"),
validation.tableExists = validation.errors.length === 0;
} else if (migration.name === "002_add_iViewContent_to_contacts") {
// Validate iViewContent column exists in contacts table
try {
await sqlQuery(`SELECT iViewContent FROM contacts LIMIT 1`);
validation.hasExpectedColumns = true;
// Reduced logging - only log on error
} catch (error) {
validation.isValid = false;
validation.errors.push(
`Column iViewContent missing from contacts table`,
);
logger.error(
`❌ [Migration-Validation] Column iViewContent missing:`,
error,
);
}
}
@@ -489,55 +343,6 @@ async function isSchemaAlreadyPresent<T>(
// Reduced logging - only log on error
return false;
}
} else if (migration.name === "003_add_hasBackedUpSeed_to_settings") {
// Check if hasBackedUpSeed column exists in settings table
try {
await sqlQuery(`SELECT hasBackedUpSeed FROM settings LIMIT 1`);
return true;
} catch (error) {
return false;
}
} else if (migration.name === "004_active_identity_management") {
// Check if active_identity table exists and has correct structure
try {
// Check that active_identity table exists
const activeIdentityResult = await sqlQuery(
`SELECT name FROM sqlite_master WHERE type='table' AND name='active_identity'`,
);
const hasActiveIdentityTable =
(activeIdentityResult as unknown as { values: unknown[][] })?.values
?.length > 0 ||
(Array.isArray(activeIdentityResult) &&
activeIdentityResult.length > 0);
if (!hasActiveIdentityTable) {
return false;
}
// Check that active_identity has the expected structure
try {
await sqlQuery(
`SELECT id, activeDid, lastUpdated FROM active_identity LIMIT 1`,
);
// Also check that hasBackedUpSeed column exists in settings
// This is included because migration 004 is consolidated
try {
await sqlQuery(`SELECT hasBackedUpSeed FROM settings LIMIT 1`);
return true;
} catch (error) {
return false;
}
} catch (error) {
return false;
}
} catch (error) {
logger.error(
`🔍 [Migration-Schema] Schema check failed for ${migration.name}, assuming not present:`,
error,
);
return false;
}
}
// Add schema checks for future migrations here
@@ -599,10 +404,15 @@ export async function runMigrations<T>(
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
extractMigrationNames: (result: T) => Set<string>,
): Promise<void> {
try {
logger.debug("📋 [Migration] Starting migration process...");
const isDevelopment = process.env.VITE_PLATFORM === "development";
// Create migrations table if it doesn't exist
// Use debug level for routine migration messages in development
const migrationLog = isDevelopment ? logger.debug : logger.log;
try {
migrationLog("📋 [Migration] Starting migration process...");
// Step 1: Create migrations table if it doesn't exist
// Note: We use IF NOT EXISTS here because this is infrastructure, not a business migration
await sqlExec(`
CREATE TABLE IF NOT EXISTS migrations (
@@ -626,8 +436,7 @@ export async function runMigrations<T>(
return;
}
// Only log migration counts in development
logger.debug(
migrationLog(
`📊 [Migration] Found ${migrations.length} total migrations, ${appliedMigrations.size} already applied`,
);
@@ -639,22 +448,22 @@ export async function runMigrations<T>(
// Check 1: Is it recorded as applied in migrations table?
const isRecordedAsApplied = appliedMigrations.has(migration.name);
// Skip if already recorded as applied (name-only check)
// Check 2: Does the schema already exist in the database?
const isSchemaPresent = await isSchemaAlreadyPresent(migration, sqlQuery);
// Skip if already recorded as applied
if (isRecordedAsApplied) {
skippedCount++;
continue;
}
// Check 2: Does the schema already exist in the database?
const isSchemaPresent = await isSchemaAlreadyPresent(migration, sqlQuery);
// Handle case where schema exists but isn't recorded
if (isSchemaPresent) {
try {
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.debug(
migrationLog(
`✅ [Migration] Marked existing schema as applied: ${migration.name}`,
);
skippedCount++;
@@ -669,20 +478,11 @@ export async function runMigrations<T>(
}
// Apply the migration
logger.debug(`🔄 [Migration] Applying migration: ${migration.name}`);
migrationLog(`🔄 [Migration] Applying migration: ${migration.name}`);
try {
// Execute the migration SQL as single atomic operation
logger.debug(`🔧 [Migration] Executing SQL for: ${migration.name}`);
logger.debug(`🔧 [Migration] SQL content: ${migration.sql}`);
// Execute the migration SQL directly - it should be atomic
// The SQL itself should handle any necessary transactions
const execResult = await sqlExec(migration.sql);
logger.debug(
`🔧 [Migration] SQL execution result: ${JSON.stringify(execResult)}`,
);
// Execute the migration SQL
await sqlExec(migration.sql);
// Validate the migration was applied correctly
const validation = await validateMigrationApplication(
@@ -701,33 +501,11 @@ export async function runMigrations<T>(
migration.name,
]);
logger.debug(`🎉 [Migration] Successfully applied: ${migration.name}`);
migrationLog(`🎉 [Migration] Successfully applied: ${migration.name}`);
appliedCount++;
} catch (error) {
logger.error(`❌ [Migration] Error applying ${migration.name}:`, error);
// Provide explicit rollback instructions for migration failures
logger.error(
`🔄 [Migration] ROLLBACK INSTRUCTIONS for ${migration.name}:`,
);
logger.error(` 1. Stop the application immediately`);
logger.error(
` 2. Restore database from pre-migration backup/snapshot`,
);
logger.error(
` 3. Remove migration entry: DELETE FROM migrations WHERE name = '${migration.name}'`,
);
logger.error(
` 4. Verify database state matches pre-migration condition`,
);
logger.error(` 5. Restart application and investigate root cause`);
logger.error(
` FAILURE CAUSE: ${error instanceof Error ? error.message : String(error)}`,
);
logger.error(
` REQUIRED OPERATOR ACTION: Manual database restoration required`,
);
// Handle specific cases where the migration might be partially applied
const errorMessage = String(error).toLowerCase();
@@ -739,7 +517,7 @@ export async function runMigrations<T>(
(errorMessage.includes("table") &&
errorMessage.includes("already exists"))
) {
logger.debug(
migrationLog(
`⚠️ [Migration] ${migration.name} appears already applied (${errorMessage}). Validating and marking as complete.`,
);
@@ -753,8 +531,6 @@ export async function runMigrations<T>(
`⚠️ [Migration] Schema validation failed for ${migration.name}:`,
validation.errors,
);
// Don't mark as applied if validation fails
continue;
}
// Mark the migration as applied since the schema change already exists
@@ -762,7 +538,7 @@ export async function runMigrations<T>(
await sqlExec("INSERT INTO migrations (name) VALUES (?)", [
migration.name,
]);
logger.debug(`✅ [Migration] Marked as applied: ${migration.name}`);
migrationLog(`✅ [Migration] Marked as applied: ${migration.name}`);
appliedCount++;
} catch (insertError) {
// If we can't insert the migration record, log it but don't fail
@@ -782,7 +558,7 @@ export async function runMigrations<T>(
}
}
// Step 6: Final validation - verify all migrations are properly recorded
// Step 5: Final validation - verify all migrations are properly recorded
const finalMigrationsResult = await sqlQuery("SELECT name FROM migrations");
const finalAppliedMigrations = extractMigrationNames(finalMigrationsResult);
@@ -798,7 +574,7 @@ export async function runMigrations<T>(
);
}
// Only show completion message in development
// Always show completion message
logger.log(
`🎉 [Migration] Migration process complete! Summary: ${appliedCount} applied, ${skippedCount} skipped`,
);

View File

@@ -24,7 +24,7 @@ import {
import { logger } from "../../utils/logger";
interface QueuedOperation {
type: "run" | "query" | "rawQuery";
type: "run" | "query";
sql: string;
params: unknown[];
resolve: (value: unknown) => void;
@@ -66,13 +66,13 @@ export class CapacitorPlatformService implements PlatformService {
return this.initializationPromise;
}
// Start initialization
this.initializationPromise = this._initialize();
try {
// Start initialization
this.initializationPromise = this._initialize();
await this.initializationPromise;
} catch (error) {
logger.error(
"[CapacitorPlatformService] Initialize database method failed:",
"[CapacitorPlatformService] Initialize method failed:",
error,
);
this.initializationPromise = null; // Reset on failure
@@ -159,14 +159,6 @@ export class CapacitorPlatformService implements PlatformService {
};
break;
}
case "rawQuery": {
const queryResult = await this.db.query(
operation.sql,
operation.params,
);
result = queryResult;
break;
}
}
operation.resolve(result);
} catch (error) {
@@ -508,24 +500,9 @@ export class CapacitorPlatformService implements PlatformService {
// This is essential for proper parameter binding and SQL injection prevention
await this.db!.run(sql, params);
} else {
// For multi-statement SQL (like migrations), use executeSet method
// This handles multiple statements properly
if (
sql.includes(";") &&
sql.split(";").filter((s) => s.trim()).length > 1
) {
// Multi-statement SQL - use executeSet for proper handling
const statements = sql.split(";").filter((s) => s.trim());
await this.db!.executeSet(
statements.map((stmt) => ({
statement: stmt.trim(),
values: [], // Empty values array for non-parameterized statements
})),
);
} else {
// Single statement - use execute method
await this.db!.execute(sql);
}
// Use execute method for non-parameterized queries
// This is more efficient for simple DDL statements
await this.db!.execute(sql);
}
};
@@ -1293,14 +1270,6 @@ export class CapacitorPlatformService implements PlatformService {
return undefined;
}
/**
* @see PlatformService.dbRawQuery
*/
async dbRawQuery(sql: string, params?: unknown[]): Promise<unknown> {
await this.waitForInitialization();
return this.queueOperation("rawQuery", sql, params || []);
}
/**
* Checks if running on Capacitor platform.
* @returns true, as this is the Capacitor implementation
@@ -1350,24 +1319,8 @@ export class CapacitorPlatformService implements PlatformService {
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],
);
await this.dbExec("INSERT INTO settings (accountDid) VALUES (?)", [did]);
}
async updateDidSpecificSettings(

View File

@@ -636,17 +636,6 @@ export class WebPlatformService implements PlatformService {
} as GetOneRowRequest);
}
/**
* @see PlatformService.dbRawQuery
*/
async dbRawQuery(
sql: string,
params?: unknown[],
): Promise<unknown | undefined> {
// This class doesn't post-process the result, so we can just use it.
return this.dbQuery(sql, params);
}
/**
* Rotates the camera between front and back cameras.
* @returns Promise that resolves when the camera is rotated
@@ -685,51 +674,15 @@ export class WebPlatformService implements PlatformService {
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];
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(
"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],
);
await this.dbExec("INSERT INTO settings (accountDid) VALUES (?)", [did]);
}
async updateDidSpecificSettings(

View File

@@ -85,7 +85,6 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { MASTER_SETTINGS_KEY } from "@/db/tables/settings";
@Component({
mixins: [PlatformServiceMixin],
@@ -197,10 +196,10 @@ This tests the helper method only - no database interaction`;
const success = await this.$saveSettings(testSettings);
if (success) {
// Now query the raw database to see how it's actually stored
// Now query the raw database to see how it's actually stored.
// Note that new users probably have settings with ID of 1 but old migrated users might skip to 2.
const rawResult = await this.$dbQuery(
"SELECT searchBoxes FROM settings WHERE id = ?",
[MASTER_SETTINGS_KEY],
"SELECT searchBoxes FROM settings limit 1",
);
if (rawResult?.values?.length) {

View File

@@ -66,7 +66,7 @@ export async function testServerRegisterUser() {
// Make a payload for the claim
const vcPayload = {
sub: identity0.did,
sub: "RegisterAction",
vc: {
"@context": ["https://www.w3.org/2018/credentials/v1"],
type: ["VerifiableCredential"],

View File

@@ -45,6 +45,7 @@ import type {
PlatformCapabilities,
} from "@/services/PlatformService";
import {
MASTER_SETTINGS_KEY,
type Settings,
type SettingsWithJsonStrings,
} from "@/db/tables/settings";
@@ -52,11 +53,7 @@ import { logger } from "@/utils/logger";
import { Contact, ContactMaybeWithJsonStrings } from "@/db/tables/contacts";
import { Account } from "@/db/tables/accounts";
import { Temp } from "@/db/tables/temp";
import {
QueryExecResult,
DatabaseExecResult,
SqlValue,
} from "@/interfaces/database";
import { QueryExecResult, DatabaseExecResult } from "@/interfaces/database";
import {
generateInsertStatement,
generateUpdateStatement,
@@ -213,53 +210,11 @@ export const PlatformServiceMixin = {
logger.debug(
`[PlatformServiceMixin] ActiveDid updated from ${oldDid} to ${newDid}`,
);
// Write only to active_identity table (single source of truth)
try {
await this.$dbExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[newDid || ""],
);
logger.debug(
`[PlatformServiceMixin] ActiveDid updated in active_identity table: ${newDid}`,
);
} catch (error) {
logger.error(
`[PlatformServiceMixin] Error updating activeDid in active_identity table ${newDid}:`,
error,
);
// Continue with in-memory update even if database write fails
}
// // Clear caches that might be affected by the change
// this.$clearAllCaches();
}
},
/**
* Get available account DIDs for user selection
* Returns array of DIDs that can be set as active identity
*/
async $getAvailableAccountDids(): Promise<string[]> {
try {
const result = await this.$dbQuery(
"SELECT did FROM accounts ORDER BY did",
);
if (!result?.values?.length) {
return [];
}
return result.values.map((row: SqlValue[]) => row[0] as string);
} catch (error) {
logger.error(
"[PlatformServiceMixin] Error getting available account DIDs:",
error,
);
return [];
}
},
/**
* Map database columns to values with proper type conversion
* Handles boolean conversion from SQLite integers (0/1) to boolean values
@@ -275,22 +230,16 @@ export const PlatformServiceMixin = {
// Convert SQLite integer booleans to JavaScript booleans
if (
// settings
column === "isRegistered" ||
column === "finishedOnboarding" ||
column === "filterFeedByVisible" ||
column === "filterFeedByNearby" ||
column === "hasBackedUpSeed" ||
column === "hideRegisterPromptOnNewContact" ||
column === "showContactGivesInline" ||
column === "showGeneralAdvanced" ||
column === "showShortcutBvc" ||
column === "warnIfProdServer" ||
column === "warnIfTestServer" ||
// contacts
column === "iViewContent" ||
column === "registered" ||
column === "seesMe"
column === "warnIfTestServer"
) {
if (value === 1) {
value = true;
@@ -301,7 +250,11 @@ export const PlatformServiceMixin = {
}
// Convert SQLite JSON strings to objects/arrays
if (column === "contactMethods" || column === "searchBoxes") {
if (
column === "contactMethods" ||
column === "searchBoxes" ||
column === "starredPlanHandleIds"
) {
value = this._parseJsonField(value, []);
}
@@ -312,13 +265,10 @@ export const PlatformServiceMixin = {
},
/**
* Safely parses JSON strings with fallback to default value.
* Handles different SQLite implementations:
* - Web SQLite (wa-sqlite/absurd-sql): Auto-parses JSON strings to objects
* - Capacitor SQLite: Returns raw strings that need manual parsing
* Self-contained implementation of parseJsonField
* Safely parses JSON strings with fallback to default value
*
* See also src/db/databaseUtil.ts parseJsonField
* and maybe consolidate
* Consolidate this with src/libs/util.ts parseJsonField
*/
_parseJsonField<T>(value: unknown, defaultValue: T): T {
if (typeof value === "string") {
@@ -468,10 +418,7 @@ export const PlatformServiceMixin = {
/**
* Enhanced database single row query method with error handling
*/
async $dbGetOneRow(
sql: string,
params?: unknown[],
): Promise<SqlValue[] | undefined> {
async $dbGetOneRow(sql: string, params?: unknown[]) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (this as any).platformService.dbGetOneRow(sql, params);
@@ -489,47 +436,18 @@ export const PlatformServiceMixin = {
}
},
/**
* Database raw query method with error handling
*/
async $dbRawQuery(sql: string, params?: unknown[]) {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (this as any).platformService.dbRawQuery(sql, params);
} catch (error) {
logger.error(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
`[${(this as any).$options.name}] Database raw query failed:`,
{
sql,
params,
error,
},
);
throw error;
}
},
/**
* Utility method for retrieving master settings
* Common pattern used across many components
*/
async _getMasterSettings(
async $getMasterSettings(
fallback: Settings | null = null,
): Promise<Settings | null> {
try {
// Get current active identity
const activeIdentity = await this.$getActiveIdentity();
const activeDid = activeIdentity.activeDid;
if (!activeDid) {
return fallback;
}
// Get identity-specific settings
// Master settings: query by id
const result = await this.$dbQuery(
"SELECT * FROM settings WHERE accountDid = ?",
[activeDid],
"SELECT * FROM settings WHERE id = ?",
[MASTER_SETTINGS_KEY],
);
if (!result?.values?.length) {
@@ -566,12 +484,13 @@ export const PlatformServiceMixin = {
* Handles the common pattern of layered settings
*/
async $getMergedSettings(
defaultKey: string,
accountDid?: string,
defaultFallback: Settings = {},
): Promise<Settings> {
try {
// Get default settings
const defaultSettings = await this._getMasterSettings(defaultFallback);
const defaultSettings = await this.$getMasterSettings(defaultFallback);
// If no account DID, return defaults
if (!accountDid) {
@@ -621,6 +540,7 @@ export const PlatformServiceMixin = {
return mergedSettings;
} catch (error) {
logger.error(`[Settings Trace] ❌ Failed to get merged settings:`, {
defaultKey,
accountDid,
error,
});
@@ -628,73 +548,6 @@ export const PlatformServiceMixin = {
}
},
/**
* Get active identity from the new active_identity table
* This replaces the activeDid field in settings for better architecture
*/
async $getActiveIdentity(): Promise<{ activeDid: string }> {
try {
const result = await this.$dbQuery(
"SELECT activeDid FROM active_identity WHERE id = 1",
);
if (!result?.values?.length) {
logger.warn(
"[PlatformServiceMixin] Active identity table is empty - this may indicate a migration issue",
);
return { activeDid: "" };
}
const activeDid = result.values[0][0] as string | null;
// Handle null activeDid (initial state after migration) - auto-select first account
if (activeDid === null) {
const firstAccount = await this.$dbQuery(
"SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1",
);
if (firstAccount?.values?.length) {
const firstAccountDid = firstAccount.values[0][0] as string;
await this.$dbExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[firstAccountDid],
);
return { activeDid: firstAccountDid };
}
logger.warn(
"[PlatformServiceMixin] No accounts available for auto-selection",
);
return { activeDid: "" };
}
// Validate activeDid exists in accounts
const accountExists = await this.$dbQuery(
"SELECT did FROM accounts WHERE did = ?",
[activeDid],
);
if (accountExists?.values?.length) {
return { activeDid };
}
// Clear corrupted activeDid and return empty
logger.warn(
"[PlatformServiceMixin] Active identity not found in accounts, clearing",
);
await this.$dbExec(
"UPDATE active_identity SET activeDid = NULL, lastUpdated = datetime('now') WHERE id = 1",
);
return { activeDid: "" };
} catch (error) {
logger.error(
"[PlatformServiceMixin] Error getting active identity:",
error,
);
return { activeDid: "" };
}
},
/**
* Transaction wrapper with automatic rollback on error
*/
@@ -710,76 +563,6 @@ export const PlatformServiceMixin = {
}
},
// =================================================
// SMART DELETION PATTERN DAL METHODS
// =================================================
/**
* Get account DID by ID
* Required for smart deletion pattern
*/
async $getAccountDidById(id: number): Promise<string> {
const result = await this.$dbQuery(
"SELECT did FROM accounts WHERE id = ?",
[id],
);
return result?.values?.[0]?.[0] as string;
},
/**
* Get active DID (returns null if none selected)
* Required for smart deletion pattern
*/
async $getActiveDid(): Promise<string | null> {
const result = await this.$dbQuery(
"SELECT activeDid FROM active_identity WHERE id = 1",
);
return (result?.values?.[0]?.[0] as string) || null;
},
/**
* Set active DID (can be null for no selection)
* Required for smart deletion pattern
*/
async $setActiveDid(did: string | null): Promise<void> {
await this.$dbExec(
"UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1",
[did],
);
},
/**
* Count total accounts
* Required for smart deletion pattern
*/
async $countAccounts(): Promise<number> {
const result = await this.$dbQuery("SELECT COUNT(*) FROM accounts");
return (result?.values?.[0]?.[0] as number) || 0;
},
/**
* Deterministic "next" picker for account selection
* Required for smart deletion pattern
*/
$pickNextAccountDid(all: string[], current?: string): string {
const sorted = [...all].sort();
if (!current) return sorted[0];
const i = sorted.indexOf(current);
return sorted[(i + 1) % sorted.length];
},
/**
* Ensure an active account is selected (repair hook)
* Required for smart deletion pattern bootstrapping
*/
async $ensureActiveSelected(): Promise<void> {
const active = await this.$getActiveDid();
const all = await this.$getAllAccountDids();
if (active === null && all.length > 0) {
await this.$setActiveDid(this.$pickNextAccountDid(all));
}
},
// =================================================
// ULTRA-CONCISE DATABASE METHODS (shortest names)
// =================================================
@@ -818,7 +601,7 @@ export const PlatformServiceMixin = {
async $one(
sql: string,
params: unknown[] = [],
): Promise<SqlValue[] | undefined> {
): Promise<unknown[] | undefined> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (this as any).platformService.dbGetOneRow(sql, params);
},
@@ -970,20 +753,20 @@ export const PlatformServiceMixin = {
* @returns Fresh settings object from database
*/
async $settings(defaults: Settings = {}): Promise<Settings> {
const settings = await this._getMasterSettings(defaults);
const settings = await this.$getMasterSettings(defaults);
if (!settings) {
return defaults;
}
// FIXED: Set default apiServer for all platforms, not just Electron
// FIXED: Remove forced override - respect user preferences
// Only set default if no user preference exists
if (!settings.apiServer) {
if (!settings.apiServer && process.env.VITE_PLATFORM === "electron") {
// Import constants dynamically to get platform-specific values
const { DEFAULT_ENDORSER_API_SERVER } = await import(
"../constants/app"
);
// Set default for all platforms when apiServer is empty
// Only set if user hasn't specified a preference
settings.apiServer = DEFAULT_ENDORSER_API_SERVER;
}
@@ -1003,15 +786,14 @@ export const PlatformServiceMixin = {
): Promise<Settings> {
try {
// Get default settings first
const defaultSettings = await this._getMasterSettings(defaults);
const defaultSettings = await this.$getMasterSettings(defaults);
if (!defaultSettings) {
return defaults;
}
// Get DID from active_identity table (single source of truth)
const activeIdentity = await this.$getActiveIdentity();
const targetDid = did || activeIdentity.activeDid;
// Determine which DID to use
const targetDid = did || defaultSettings.activeDid;
// If no target DID, return default settings
if (!targetDid) {
@@ -1020,29 +802,22 @@ export const PlatformServiceMixin = {
// Get merged settings using existing method
const mergedSettings = await this.$getMergedSettings(
MASTER_SETTINGS_KEY,
targetDid,
defaultSettings,
);
// Set activeDid from active_identity table (single source of truth)
mergedSettings.activeDid = activeIdentity.activeDid;
logger.debug(
"[PlatformServiceMixin] Using activeDid from active_identity table:",
{ activeDid: activeIdentity.activeDid },
);
logger.debug(
"[PlatformServiceMixin] $accountSettings() returning activeDid:",
{ activeDid: mergedSettings.activeDid },
);
// FIXED: Set default apiServer for all platforms, not just Electron
// FIXED: Remove forced override - respect user preferences
// Only set default if no user preference exists
if (!mergedSettings.apiServer) {
if (
!mergedSettings.apiServer &&
process.env.VITE_PLATFORM === "electron"
) {
// Import constants dynamically to get platform-specific values
const { DEFAULT_ENDORSER_API_SERVER } = await import(
"../constants/app"
);
// Set default for all platforms when apiServer is empty
// Only set if user hasn't specified a preference
mergedSettings.apiServer = DEFAULT_ENDORSER_API_SERVER;
}
@@ -1080,36 +855,16 @@ export const PlatformServiceMixin = {
async $saveSettings(changes: Partial<Settings>): Promise<boolean> {
try {
// Remove fields that shouldn't be updated
const {
accountDid,
id,
activeDid: activeDidField,
...safeChanges
} = changes;
const { accountDid, id, ...safeChanges } = changes;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void accountDid;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void id;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
void activeDidField;
logger.debug(
"[PlatformServiceMixin] $saveSettings - Original changes:",
changes,
);
logger.debug(
"[PlatformServiceMixin] $saveSettings - Safe changes:",
safeChanges,
);
if (Object.keys(safeChanges).length === 0) return true;
// Convert settings for database storage (handles searchBoxes conversion)
const convertedChanges = this._convertSettingsForStorage(safeChanges);
logger.debug(
"[PlatformServiceMixin] $saveSettings - Converted changes:",
convertedChanges,
);
const setParts: string[] = [];
const params: unknown[] = [];
@@ -1121,33 +876,17 @@ export const PlatformServiceMixin = {
}
});
logger.debug(
"[PlatformServiceMixin] $saveSettings - Set parts:",
setParts,
);
logger.debug("[PlatformServiceMixin] $saveSettings - Params:", params);
if (setParts.length === 0) return true;
// Get current active DID and update that identity's settings
const activeIdentity = await this.$getActiveIdentity();
const currentActiveDid = activeIdentity.activeDid;
if (currentActiveDid) {
params.push(currentActiveDid);
await this.$dbExec(
`UPDATE settings SET ${setParts.join(", ")} WHERE accountDid = ?`,
params,
);
} else {
logger.warn(
"[PlatformServiceMixin] No active DID found, cannot save settings",
);
}
params.push(MASTER_SETTINGS_KEY);
await this.$dbExec(
`UPDATE settings SET ${setParts.join(", ")} WHERE id = ?`,
params,
);
// Update activeDid tracking if it changed
if (activeDidField !== undefined) {
await this.$updateActiveDid(activeDidField);
if (changes.activeDid !== undefined) {
await this.$updateActiveDid(changes.activeDid);
}
return true;
@@ -1212,11 +951,6 @@ export const PlatformServiceMixin = {
* @param changes Settings changes to save
* @returns Promise<boolean> Success status
*/
/**
* Since this is unused, and since it relies on this.activeDid which isn't guaranteed to exist,
* let's take this out for the sake of safety.
* Totally remove after start of 2026 (since it would be obvious by then that it's not used).
*
async $saveMySettings(changes: Partial<Settings>): Promise<boolean> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const currentDid = (this as any).activeDid;
@@ -1226,7 +960,6 @@ export const PlatformServiceMixin = {
}
return await this.$saveUserSettings(currentDid, changes);
},
**/
// =================================================
// CACHE MANAGEMENT METHODS
@@ -1477,15 +1210,8 @@ export const PlatformServiceMixin = {
*/
async $getAllAccountDids(): Promise<string[]> {
try {
const result = await this.$dbQuery(
"SELECT did FROM accounts ORDER BY did",
);
if (!result?.values?.length) {
return [];
}
return result.values.map((row: SqlValue[]) => row[0] as string);
const accounts = await this.$query<Account>("SELECT did FROM accounts");
return accounts.map((account) => account.did);
} catch (error) {
logger.error(
"[PlatformServiceMixin] Error getting all account DIDs:",
@@ -1610,16 +1336,13 @@ export const PlatformServiceMixin = {
fields: string[],
did?: string,
): Promise<unknown[] | undefined> {
// Use current active DID if no specific DID provided
const targetDid = did || (await this.$getActiveIdentity()).activeDid;
if (!targetDid) {
return undefined;
}
// Use correct settings table schema
const whereClause = did ? "WHERE accountDid = ?" : "WHERE id = ?";
const params = did ? [did] : [MASTER_SETTINGS_KEY];
return await this.$one(
`SELECT ${fields.join(", ")} FROM settings WHERE accountDid = ?`,
[targetDid],
`SELECT ${fields.join(", ")} FROM settings ${whereClause}`,
params,
);
},
@@ -1822,7 +1545,7 @@ export const PlatformServiceMixin = {
const settings = mappedResults[0] as Settings;
logger.debug(`[PlatformServiceMixin] Settings for DID ${did}:`, {
logger.info(`[PlatformServiceMixin] Settings for DID ${did}:`, {
firstName: settings.firstName,
isRegistered: settings.isRegistered,
activeDid: settings.activeDid,
@@ -1848,8 +1571,8 @@ export const PlatformServiceMixin = {
async $debugMergedSettings(did: string): Promise<void> {
try {
// Get default settings
const defaultSettings = await this._getMasterSettings({});
logger.debug(
const defaultSettings = await this.$getMasterSettings({});
logger.info(
`[PlatformServiceMixin] Default settings:`,
defaultSettings,
);
@@ -1859,11 +1582,12 @@ export const PlatformServiceMixin = {
// Get merged settings
const mergedSettings = await this.$getMergedSettings(
MASTER_SETTINGS_KEY,
did,
defaultSettings || {},
);
logger.debug(`[PlatformServiceMixin] Merged settings for ${did}:`, {
logger.info(`[PlatformServiceMixin] Merged settings for ${did}:`, {
defaultSettings,
didSettings,
mergedSettings,
@@ -1893,19 +1617,14 @@ export interface IPlatformServiceMixin {
params?: unknown[],
): Promise<QueryExecResult | undefined>;
$dbExec(sql: string, params?: unknown[]): Promise<DatabaseExecResult>;
$dbGetOneRow(
sql: string,
params?: unknown[],
): Promise<SqlValue[] | undefined>;
$dbRawQuery(sql: string, params?: unknown[]): Promise<unknown | undefined>;
$dbGetOneRow(sql: string, params?: unknown[]): Promise<unknown[] | undefined>;
$getMasterSettings(fallback?: Settings | null): Promise<Settings | null>;
$getMergedSettings(
defaultKey: string,
accountDid?: string,
defaultFallback?: Settings,
): Promise<Settings>;
$getActiveIdentity(): Promise<{ activeDid: string }>;
$withTransaction<T>(callback: () => Promise<T>): Promise<T>;
$getAvailableAccountDids(): Promise<string[]>;
isCapacitor: boolean;
isWeb: boolean;
isElectron: boolean;
@@ -1999,7 +1718,7 @@ declare module "@vue/runtime-core" {
// Ultra-concise database methods (shortest possible names)
$db(sql: string, params?: unknown[]): Promise<QueryExecResult | undefined>;
$exec(sql: string, params?: unknown[]): Promise<DatabaseExecResult>;
$one(sql: string, params?: unknown[]): Promise<SqlValue[] | undefined>;
$one(sql: string, params?: unknown[]): Promise<unknown[] | undefined>;
// Query + mapping combo methods
$query<T = Record<string, unknown>>(
@@ -2021,15 +1740,13 @@ declare module "@vue/runtime-core" {
sql: string,
params?: unknown[],
): Promise<unknown[] | undefined>;
$dbRawQuery(sql: string, params?: unknown[]): Promise<unknown | undefined>;
$getMasterSettings(defaults?: Settings | null): Promise<Settings | null>;
$getMergedSettings(
key: string,
did?: string,
defaults?: Settings,
): Promise<Settings>;
$getActiveIdentity(): Promise<{ activeDid: string }>;
$withTransaction<T>(fn: () => Promise<T>): Promise<T>;
$getAvailableAccountDids(): Promise<string[]>;
// Specialized shortcuts - contacts cached, settings fresh
$contacts(): Promise<Contact[]>;
@@ -2044,8 +1761,7 @@ declare module "@vue/runtime-core" {
did: string,
changes: Partial<Settings>,
): Promise<boolean>;
// @deprecated; see implementation note above
// $saveMySettings(changes: Partial<Settings>): Promise<boolean>;
$saveMySettings(changes: Partial<Settings>): Promise<boolean>;
// Cache management methods
$refreshSettings(): Promise<Settings>;

View File

@@ -59,27 +59,10 @@ type LogLevel = keyof typeof LOG_LEVELS;
// Parse VITE_LOG_LEVEL environment variable
const getLogLevel = (): LogLevel => {
// Try to get VITE_LOG_LEVEL from different sources
let envLogLevel: string | undefined;
const envLogLevel = process.env.VITE_LOG_LEVEL?.toLowerCase();
try {
// In browser/Vite environment, use import.meta.env
if (
typeof import.meta !== "undefined" &&
import.meta?.env?.VITE_LOG_LEVEL
) {
envLogLevel = import.meta.env.VITE_LOG_LEVEL;
}
// Fallback to process.env for Node.js environments
else if (process.env.VITE_LOG_LEVEL) {
envLogLevel = process.env.VITE_LOG_LEVEL;
}
} catch (error) {
// Silently handle cases where import.meta is not available
}
if (envLogLevel && envLogLevel.toLowerCase() in LOG_LEVELS) {
return envLogLevel.toLowerCase() as LogLevel;
if (envLogLevel && envLogLevel in LOG_LEVELS) {
return envLogLevel as LogLevel;
}
// Default log levels based on environment

View File

@@ -1,90 +0,0 @@
import { NotificationIface } from "@/constants/app";
const SEED_REMINDER_KEY = "seedPhraseReminderLastShown";
const REMINDER_COOLDOWN_MS = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
/**
* Checks if the seed phrase backup reminder should be shown
* @param hasBackedUpSeed - Whether the user has backed up their seed phrase
* @returns true if the reminder should be shown, false otherwise
*/
export function shouldShowSeedReminder(hasBackedUpSeed: boolean): boolean {
// Don't show if user has already backed up
if (hasBackedUpSeed) {
return false;
}
// Check localStorage for last shown time
const lastShown = localStorage.getItem(SEED_REMINDER_KEY);
if (!lastShown) {
return true; // First time, show the reminder
}
try {
const lastShownTime = parseInt(lastShown, 10);
const now = Date.now();
const timeSinceLastShown = now - lastShownTime;
// Show if more than 24 hours have passed
return timeSinceLastShown >= REMINDER_COOLDOWN_MS;
} catch (error) {
// If there's an error parsing the timestamp, show the reminder
return true;
}
}
/**
* Marks the seed phrase reminder as shown by updating localStorage
*/
export function markSeedReminderShown(): void {
localStorage.setItem(SEED_REMINDER_KEY, Date.now().toString());
}
/**
* Creates the seed phrase backup reminder notification
* @returns NotificationIface configuration for the reminder modal
*/
export function createSeedReminderNotification(): NotificationIface {
return {
group: "modal",
type: "confirm",
title: "Backup Your Identifier Seed?",
text: "It looks like you haven't backed up your identifier seed yet. It's important to back it up as soon as possible to secure your identity.",
yesText: "Backup Identifier Seed",
noText: "Remind me Later",
onYes: async () => {
// Navigate to seed backup page
window.location.href = "/seed-backup";
},
onNo: async () => {
// Mark as shown so it won't appear again for 24 hours
markSeedReminderShown();
},
onCancel: async () => {
// Mark as shown so it won't appear again for 24 hours
markSeedReminderShown();
},
};
}
/**
* Shows the seed phrase backup reminder if conditions are met
* @param hasBackedUpSeed - Whether the user has backed up their seed phrase
* @param notifyFunction - Function to show notifications
* @returns true if the reminder was shown, false otherwise
*/
export function showSeedPhraseReminder(
hasBackedUpSeed: boolean,
notifyFunction: (notification: NotificationIface, timeout?: number) => void,
): boolean {
if (shouldShowSeedReminder(hasBackedUpSeed)) {
const notification = createSeedReminderNotification();
// Add 1-second delay before showing the modal to allow success message to be visible
setTimeout(() => {
// Pass -1 as timeout to ensure modal stays open until user interaction
notifyFunction(notification, -1);
}, 1000);
return true;
}
return false;
}

View File

@@ -27,7 +27,7 @@
need an identifier.
</p>
<router-link
:to="{ name: 'new-identifier' }"
:to="{ name: 'start' }"
class="inline-block text-md bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-4 py-2 rounded-md"
>
Create An Identifier
@@ -150,8 +150,6 @@
</section>
<PushNotificationPermission ref="pushNotificationPermission" />
<LocationSearchSection :search-box="searchBox" />
<!-- User Profile -->
<section
v-if="isRegistered"
@@ -244,6 +242,8 @@
<div v-else>Saving...</div>
</section>
<LocationSearchSection :search-box="searchBox" />
<UsageLimitsSection
v-if="activeDid"
:loading-limits="loadingLimits"
@@ -764,7 +764,7 @@ import { IIdentifier } from "@veramo/core";
import { ref } from "vue";
import { Component, Vue } from "vue-facing-decorator";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
import { Capacitor } from "@capacitor/core";
@@ -811,7 +811,6 @@ import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
AccountSettings,
isApiError,
@@ -1051,11 +1050,7 @@ export default class AccountViewView extends Vue {
// Then get the account-specific settings
const settings: AccountSettings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.apiServerInput = settings.apiServer || "";
this.givenName =
@@ -1064,8 +1059,8 @@ export default class AccountViewView extends Vue {
this.hideRegisterPromptOnNewContact =
!!settings.hideRegisterPromptOnNewContact;
this.isRegistered = !!settings?.isRegistered;
this.isSearchAreasSet = !!settings.searchBoxes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.isSearchAreasSet =
!!settings.searchBoxes && settings.searchBoxes.length > 0;
this.notifyingNewActivity = !!settings.notifyingNewActivityTime;
this.notifyingNewActivityTime = settings.notifyingNewActivityTime || "";
this.notifyingReminder = !!settings.notifyingReminderTime;
@@ -1079,6 +1074,7 @@ export default class AccountViewView extends Vue {
this.passkeyExpirationMinutes =
settings.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES;
this.previousPasskeyExpirationMinutes = this.passkeyExpirationMinutes;
this.searchBox = settings.searchBoxes?.[0] || null;
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
this.showShortcutBvc = !!settings.showShortcutBvc;
this.warnIfProdServer = !!settings.warnIfProdServer;
@@ -1088,15 +1084,11 @@ export default class AccountViewView extends Vue {
}
// call fn, copy text to the clipboard, then redo fn after 2 seconds
async doCopyTwoSecRedo(text: string, fn: () => void): Promise<void> {
doCopyTwoSecRedo(text: string, fn: () => void): void {
fn();
try {
await copyToClipboard(text);
setTimeout(fn, 2000);
} catch (error) {
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
this.notify.error("Failed to copy to clipboard.");
}
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
async toggleShowContactAmounts(): Promise<void> {
@@ -1450,10 +1442,12 @@ export default class AccountViewView extends Vue {
this.DEFAULT_IMAGE_API_SERVER,
);
if (imageResp && imageResp.status === 200) {
if (imageResp.status === 200) {
this.imageLimits = imageResp.data;
} else {
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_IMAGE_ACCESS;
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.LIMITS.CANNOT_UPLOAD_IMAGES);
return;
}
const endorserResp = await fetchEndorserRateLimits(
@@ -1467,6 +1461,7 @@ export default class AccountViewView extends Vue {
} else {
this.limitsMessage = ACCOUNT_VIEW_CONSTANTS.LIMITS.NO_LIMITS_FOUND;
this.notify.warning(ACCOUNT_VIEW_CONSTANTS.LIMITS.BAD_SERVER_RESPONSE);
return;
}
} catch (error) {
this.limitsMessage =
@@ -1483,7 +1478,6 @@ export default class AccountViewView extends Vue {
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,
@@ -1702,14 +1696,6 @@ export default class AccountViewView extends Vue {
);
if (success) {
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
} else {
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
}
@@ -1998,7 +1984,7 @@ export default class AccountViewView extends Vue {
error: error instanceof Error ? error.message : String(error),
timestamp: new Date().toISOString(),
});
return null;
throw new Error("Failed to load profile");
}
}

View File

@@ -41,7 +41,6 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
// Type guard for API responses
function isApiResponse(response: unknown): response is AxiosResponse {
@@ -113,12 +112,7 @@ export default class ClaimAddRawView extends Vue {
*/
private async initializeSettings() {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
}
@@ -229,14 +223,6 @@ export default class ClaimAddRawView extends Vue {
);
if (result.success) {
this.notify.success("Claim submitted.", TIMEOUTS.LONG);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
} else {
logger.error("Got error submitting the claim:", result);
this.notify.error(

View File

@@ -40,12 +40,7 @@ export default class ClaimCertificateView extends Vue {
async created() {
this.notify = createNotifyHelpers(this.$notify);
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring(
"/claim-cert/".length,

View File

@@ -53,13 +53,8 @@ export default class ClaimReportCertificateView extends Vue {
// Initialize notification helper
this.notify = createNotifyHelpers(this.$notify);
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
const settings = await this.$settings();
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
const pathParams = window.location.pathname.substring(
"/claim-cert/".length,

View File

@@ -24,9 +24,7 @@
<div class="flex columns-3">
<h2 class="text-md font-bold w-full">
{{
serverUtil.capitalizeAndInsertSpacesBeforeCaps(
veriClaim.claimType || "",
)
capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType || "")
}}
<button
v-if="canEditClaim"
@@ -58,7 +56,7 @@
title="Copy Printable Certificate Link"
aria-label="Copy printable certificate link"
@click="
copyTextToClipboard(
copyToClipboard(
'A link to the certificate page',
`${APP_SERVER}/deep-link/claim-cert/${veriClaim.id}`,
)
@@ -72,9 +70,7 @@
<button
title="Copy Link"
aria-label="Copy page link"
@click="
copyTextToClipboard('A link to this page', windowDeepLink)
"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>
<font-awesome icon="link" class="text-slate-500" />
</button>
@@ -83,7 +79,10 @@
<div class="text-sm">
<div data-testId="description">
<font-awesome icon="message" class="fa-fw text-slate-400" />
{{ claimDescription }}
<vue-markdown
:source="claimDescription"
class="markdown-content"
/>
</div>
<div>
<font-awesome icon="user" class="fa-fw text-slate-400" />
@@ -110,91 +109,77 @@
</div>
<!-- Fullfills Links -->
<div class="mt-4 empty:hidden">
<!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForGive?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2"
>
This fulfills a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- Show offer fulfillment if this give fulfills an offer -->
<div v-if="detailsForGiveOfferFulfillment?.offerHandleId">
<!-- router-link to /claim/ only changes URL path -->
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="
showDifferentClaimPage(
detailsForGiveOfferFulfillment.offerHandleId,
)
"
>
This fulfills
{{
serverUtil.capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
detailsForGiveOfferFulfillment.offerType || "Offer",
)
}}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</div>
<!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId" class="mt-4">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForGive?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-2"
>
Fulfills a bigger plan...
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
detailsForGive?.fulfillsType &&
detailsForGive?.fulfillsType !== 'PlanAction' &&
detailsForGive?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="
showDifferentClaimPage(detailsForGive?.fulfillsHandleId)
"
>
Fulfills
{{
capitalizeAndInsertSpacesBeforeCaps(
detailsForGive.fulfillsType,
)
}}...
</a>
</div>
<!-- fullfills links for an offer -->
<div v-if="detailsForOffer?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForOffer?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-4"
>
Offered to a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- fullfills links for an offer -->
<div v-if="detailsForOffer?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(detailsForOffer?.fulfillsPlanHandleId)
"
class="text-blue-500 mt-4"
>
Offered to a bigger plan...
</router-link>
</div>
<!-- Providers -->
<div v-if="providersForGive?.length > 0">
<span>Other assistance provided by:</span>
<ul class="ml-4">
<li
v-for="provider of providersForGive"
:key="provider.identifier"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="handleProviderClick(provider)"
>
an activity
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a>
</div>
<!-- Providers -->
<div v-if="providersForGive?.length > 0" class="mt-4">
<span>Other assistance provided by:</span>
<ul class="ml-4">
<li
v-for="provider of providersForGive"
:key="provider.identifier"
class="list-disc ml-4"
>
<div class="flex gap-4">
<div class="grow overflow-hidden">
<a
class="text-blue-500 mt-4 cursor-pointer"
@click="handleProviderClick(provider)"
>
an activity...
</a>
</div>
</li>
</ul>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
@@ -401,7 +386,7 @@
contacts can see more details:
<a
class="text-blue-500"
@click="copyTextToClipboard('A link to this page', windowDeepLink)"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -424,7 +409,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="copyTextToClipboard('A link to this page', windowDeepLink)"
@click="copyToClipboard('A link to this page', windowDeepLink)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -533,8 +518,10 @@ import { AxiosError } from "axios";
import * as yaml from "js-yaml";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { GenericVerifiableCredential } from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
@@ -553,7 +540,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { APP_SERVER } from "@/constants/app";
@Component({
components: { GiftedDialog, QuickNav },
components: { GiftedDialog, QuickNav, VueMarkdown },
mixins: [PlatformServiceMixin],
})
export default class ClaimView extends Vue {
@@ -574,17 +561,6 @@ export default class ClaimView extends Vue {
fulfillsPlanHandleId?: string;
fulfillsType?: string;
fulfillsHandleId?: string;
fullClaim?: {
fulfills?: Array<{
"@type": string;
identifier?: string;
}>;
};
} | null = null;
// Additional offer information extracted from the fulfills array
detailsForGiveOfferFulfillment: {
offerHandleId?: string;
offerType?: string;
} | null = null;
detailsForOffer: { fulfillsPlanHandleId?: string } | null = null;
// Project information for fulfillsPlanHandleId
@@ -718,7 +694,6 @@ export default class ClaimView extends Vue {
this.confsVisibleToIdList = [];
this.detailsForGive = null;
this.detailsForOffer = null;
this.detailsForGiveOfferFulfillment = null;
this.projectInfo = null;
this.fullClaim = null;
this.fullClaimDump = "";
@@ -731,15 +706,6 @@ export default class ClaimView extends Vue {
this.veriClaimDidsVisible = {};
}
/**
* Extract offer fulfillment information from the fulfills array
*/
extractOfferFulfillment() {
this.detailsForGiveOfferFulfillment = libsUtil.extractOfferFulfillment(
this.detailsForGive?.fullClaim?.fulfills,
);
}
// =================================================
// UTILITY METHODS
// =================================================
@@ -767,11 +733,7 @@ export default class ClaimView extends Vue {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.allContacts = await this.$contacts();
@@ -801,6 +763,13 @@ export default class ClaimView extends Vue {
this.canShare = !!navigator.share;
}
// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
capitalizeAndInsertSpacesBeforeCaps(text: string): string {
if (!text) return "";
return text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
totalConfirmers() {
return (
this.numConfsNotVisible +
@@ -857,8 +826,6 @@ export default class ClaimView extends Vue {
});
if (giveResp.status === 200 && giveResp.data.data?.length > 0) {
this.detailsForGive = giveResp.data.data[0];
// Extract offer information from the fulfills array
this.extractOfferFulfillment();
} else {
await this.$logError(
"Error getting detailed give info: " + JSON.stringify(giveResp),
@@ -1135,21 +1102,16 @@ export default class ClaimView extends Vue {
);
}
async copyTextToClipboard(name: string, text: string) {
try {
await copyToClipboard(text);
this.notify.copied(name || "That");
} catch (error) {
this.$logAndConsole(
`Error copying ${name || "content"} to clipboard: ${error}`,
true,
);
this.notify.error(`Failed to copy ${name || "content"} to clipboard.`);
}
copyToClipboard(name: string, text: string) {
useClipboard()
.copy(text)
.then(() => {
this.notify.copied(name || "That");
});
}
onClickShareClaim() {
this.copyTextToClipboard("A link to this page", this.windowDeepLink);
this.copyToClipboard("A link to this page", this.windowDeepLink);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the people who recorded this. Can you help me?",

View File

@@ -96,50 +96,50 @@
</div>
<!-- Fullfills Links -->
<div class="mt-4">
<!-- fullfills links for a give -->
<div v-if="giveDetails?.fulfillsPlanHandleId">
<router-link
:to="
'/project/' +
encodeURIComponent(
giveDetails?.fulfillsPlanHandleId || '',
)
"
class="text-blue-500 mt-2 cursor-pointer"
>
This fulfills a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- Show offer fulfillment if this give fulfills an offer -->
<div v-if="giveDetailsOfferFulfillment?.offerHandleId">
<!-- router-link to /claim/ only changes URL path -->
<router-link
:to="
'/claim/' +
encodeURIComponent(
giveDetailsOfferFulfillment.offerHandleId || '',
)
"
class="text-blue-500 mt-2 cursor-pointer"
>
This fulfills
{{
serverUtil.capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetailsOfferFulfillment.offerType || "Offer",
)
}}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- fullfills links for a give -->
<div v-if="giveDetails?.fulfillsPlanHandleId" class="mt-2">
<router-link
:to="
'/project/' +
encodeURIComponent(giveDetails?.fulfillsPlanHandleId || '')
"
class="text-blue-500 mt-2 cursor-pointer"
>
This fulfills a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div
v-if="
giveDetails?.fulfillsType &&
giveDetails?.fulfillsType !== 'PlanAction' &&
giveDetails?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path -->
<router-link
:to="
'/claim/' +
encodeURIComponent(giveDetails?.fulfillsHandleId || '')
"
class="text-blue-500 mt-2 cursor-pointer"
>
This fulfills
{{
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetails?.fulfillsType || "",
)
}}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link>
</div>
</div>
</div>
@@ -192,7 +192,7 @@
<span v-if="!serverUtil.isEmptyOrHiddenDid(confirmerId)">
<button
@click="
copyTextToClipboard(
copyToClipboard(
'The DID of ' + confirmerId,
confirmerId,
)
@@ -238,7 +238,7 @@
>
<button
@click="
copyTextToClipboard(
copyToClipboard(
'The DID of ' + confsVisibleTo,
confsVisibleTo,
)
@@ -309,9 +309,7 @@
contacts can see more details:
<a
class="text-blue-500"
@click="
copyTextToClipboard('A link to this page', windowLocation)
"
@click="copyToClipboard('A link to this page', windowLocation)"
>click to copy this page info</a
>
and see if they can make an introduction. Someone is connected to
@@ -334,9 +332,7 @@
If you'd like an introduction,
<a
class="text-blue-500"
@click="
copyTextToClipboard('A link to this page', windowLocation)
"
@click="copyToClipboard('A link to this page', windowLocation)"
>share this page with them and ask if they'll tell you more about
about the participants.</a
>
@@ -364,7 +360,7 @@
<span v-if="!serverUtil.isEmptyOrHiddenDid(visDid)">
<button
@click="
copyTextToClipboard('The DID of ' + visDid, visDid)
copyToClipboard('The DID of ' + visDid, visDid)
"
>
<font-awesome
@@ -437,7 +433,7 @@
import * as yaml from "js-yaml";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
import { NotificationIface } from "../constants/app";
@@ -497,11 +493,6 @@ export default class ConfirmGiftView extends Vue {
confsVisibleErrorMessage = "";
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
giveDetails?: GiveSummaryRecord;
// Additional offer information extracted from the fulfills array
giveDetailsOfferFulfillment: {
offerHandleId?: string;
offerType?: string;
} | null = null;
giverName = "";
issuerName = "";
isLoading = false;
@@ -556,12 +547,7 @@ export default class ConfirmGiftView extends Vue {
*/
private async initializeSettings() {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.allContacts = await this.$getAllContacts();
this.isRegistered = settings.isRegistered || false;
@@ -662,8 +648,6 @@ export default class ConfirmGiftView extends Vue {
if (resp.status === 200) {
this.giveDetails = resp.data.data[0];
// Extract offer information from the fulfills array
this.extractOfferFulfillment();
} else {
throw new Error("Error getting detailed give info: " + resp.status);
}
@@ -723,15 +707,6 @@ export default class ConfirmGiftView extends Vue {
}
}
/**
* Extract offer fulfillment information from the fulfills array
*/
private extractOfferFulfillment() {
this.giveDetailsOfferFulfillment = libsUtil.extractOfferFulfillment(
this.giveDetails?.fullClaim?.fulfills,
);
}
/**
* Fetches confirmer information for the claim
*/
@@ -788,21 +763,16 @@ export default class ConfirmGiftView extends Vue {
* @param description - Description of copied content
* @param text - Text to copy
*/
async copyTextToClipboard(description: string, text: string): Promise<void> {
try {
await copyToClipboard(text);
this.notify.toast(
NOTIFY_COPIED_TO_CLIPBOARD.title,
NOTIFY_COPIED_TO_CLIPBOARD.message(description),
TIMEOUTS.SHORT,
);
} catch (error) {
this.$logAndConsole(
`Error copying ${description} to clipboard: ${error}`,
true,
);
this.notify.error(`Failed to copy ${description} to clipboard.`);
}
copyToClipboard(description: string, text: string): void {
useClipboard()
.copy(text)
.then(() => {
this.notify.toast(
NOTIFY_COPIED_TO_CLIPBOARD.title,
NOTIFY_COPIED_TO_CLIPBOARD.message(description),
TIMEOUTS.SHORT,
);
});
}
/**
@@ -879,12 +849,33 @@ export default class ConfirmGiftView extends Vue {
);
}
/**
* Formats type string for display by adding spaces before capitals
* Optionally adds a prefix
*
* @param text - Text to format
* @param prefix - Optional prefix to add
* @returns Formatted string
*/
capitalizeAndInsertSpacesBeforeCapsWithAPrefix(text: string): string {
const word = this.capitalizeAndInsertSpacesBeforeCaps(text);
if (word) {
// if the word starts with a vowel, use "an" instead of "a"
const firstLetter = word[0].toLowerCase();
const vowels = ["a", "e", "i", "o", "u"];
const particle = vowels.includes(firstLetter) ? "an" : "a";
return particle + " " + word;
} else {
return "";
}
}
/**
* Initiates sharing of claim information
* Handles share functionality based on platform capabilities
*/
async onClickShareClaim(): Promise<void> {
this.copyTextToClipboard("A link to this page", this.windowLocation);
this.copyToClipboard("A link to this page", this.windowLocation);
window.navigator.share({
title: "Help Connect Me",
text: "I'm trying to find the full details of this claim. Can you help me?",
@@ -903,5 +894,11 @@ export default class ConfirmGiftView extends Vue {
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = "";
}
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
}
</script>

View File

@@ -223,13 +223,8 @@ export default class ContactAmountssView extends Vue {
const contact = await this.$getContact(contactDid);
this.contact = contact;
const settings = await this.$settings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
const settings = await this.$getMasterSettings();
this.activeDid = settings?.activeDid || "";
this.apiServer = settings?.apiServer || "";
if (this.activeDid && this.contact) {

View File

@@ -164,11 +164,7 @@ export default class ContactGiftingView extends Vue {
try {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.allContacts = await this.$getAllContacts();

View File

@@ -340,12 +340,7 @@ export default class ContactImportView extends Vue {
*/
private async initializeSettings() {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
}

View File

@@ -104,7 +104,7 @@ import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { logger } from "../utils/logger";
import { QRScannerFactory } from "../services/QRScanner/QRScannerFactory";
@@ -144,7 +144,6 @@ import {
QR_TIMEOUT_LONG,
} from "@/constants/notifications";
import { createNotifyHelpers, NotifyFunction } from "../utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
interface QRScanResult {
rawValue?: string;
@@ -196,7 +195,7 @@ export default class ContactQRScanFull extends Vue {
$router!: Router;
// Notification helper system
private notify!: ReturnType<typeof createNotifyHelpers>;
private notify = createNotifyHelpers(this.$notify);
isScanning = false;
error: string | null = null;
@@ -264,17 +263,9 @@ export default class ContactQRScanFull extends Vue {
* Loads user settings and generates QR code for contact sharing
*/
async created() {
// Initialize notification helper system
this.notify = createNotifyHelpers(this.$notify);
try {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
this.isRegistered = !!settings.isRegistered;
@@ -398,7 +389,7 @@ export default class ContactQRScanFull extends Vue {
this.isCleaningUp = true;
try {
logger.debug("Cleaning up QR scanner resources");
logger.info("Cleaning up QR scanner resources");
await this.stopScanning();
await QRScannerFactory.cleanup();
} catch (error) {
@@ -432,7 +423,7 @@ export default class ContactQRScanFull extends Vue {
rawValue === this.lastScannedValue &&
now - this.lastScanTime < this.SCAN_DEBOUNCE_MS
) {
logger.debug("Ignoring duplicate scan:", rawValue);
logger.info("Ignoring duplicate scan:", rawValue);
return;
}
@@ -440,7 +431,7 @@ export default class ContactQRScanFull extends Vue {
this.lastScannedValue = rawValue;
this.lastScanTime = now;
logger.debug("Processing QR code scan result:", rawValue);
logger.info("Processing QR code scan result:", rawValue);
let contact: Contact;
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
@@ -453,7 +444,7 @@ export default class ContactQRScanFull extends Vue {
}
// Process JWT and contact info
logger.debug("Decoding JWT payload from QR code");
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
if (!decodedJwt?.payload?.own) {
logger.warn("Invalid JWT payload - missing 'own' field");
@@ -492,7 +483,7 @@ export default class ContactQRScanFull extends Vue {
}
// Add contact but keep scanning
logger.debug("Adding new contact to database:", {
logger.info("Adding new contact to database:", {
did: contact.did,
name: contact.name,
});
@@ -551,7 +542,7 @@ export default class ContactQRScanFull extends Vue {
*/
async addNewContact(contact: Contact) {
try {
logger.debug("Opening database connection for new contact");
logger.info("Opening database connection for new contact");
// Check if contact already exists
const existingContact = await this.$getContact(contact.did);
@@ -565,7 +556,7 @@ export default class ContactQRScanFull extends Vue {
await this.$insertContact(contact);
if (this.activeDid) {
logger.debug("Setting contact visibility", { did: contact.did });
logger.info("Setting contact visibility", { did: contact.did });
await this.setVisibility(contact, true);
contact.seesMe = true;
}
@@ -612,7 +603,7 @@ export default class ContactQRScanFull extends Vue {
async handleAppPause() {
if (!this.isMounted) return;
logger.debug("App paused, stopping scanner");
logger.info("App paused, stopping scanner");
await this.stopScanning();
}
@@ -622,7 +613,7 @@ export default class ContactQRScanFull extends Vue {
handleAppResume() {
if (!this.isMounted) return;
logger.debug("App resumed, scanner can be restarted by user");
logger.info("App resumed, scanner can be restarted by user");
this.isScanning = false;
}
@@ -631,15 +622,6 @@ export default class ContactQRScanFull extends Vue {
*/
async handleBack() {
await this.cleanupScanner();
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
this.$router.back();
}
@@ -654,51 +636,36 @@ export default class ContactQRScanFull extends Vue {
* Copies contact URL to clipboard for sharing
*/
async onCopyUrlToClipboard() {
try {
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
// Use the platform-specific ClipboardService for reliable iOS support
await copyToClipboard(jwtUrl);
this.notify.toast(
NOTIFY_QR_URL_COPIED.title,
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
} catch (error) {
logger.error("Error copying URL to clipboard:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
const account = (await libsUtil.retrieveFullyDecryptedAccount(
this.activeDid,
)) as Account;
const jwtUrl = await generateEndorserJwtUrlForAccount(
account,
this.isRegistered,
this.givenName,
this.profileImageUrl,
true,
);
useClipboard()
.copy(jwtUrl)
.then(() => {
this.notify.toast(
NOTIFY_QR_URL_COPIED.title,
NOTIFY_QR_URL_COPIED.message,
QR_TIMEOUT_MEDIUM,
);
});
this.notify.error("Failed to copy URL to clipboard.");
}
}
/**
* Copies DID to clipboard for manual sharing
*/
async onCopyDidToClipboard() {
try {
// Use the platform-specific ClipboardService for reliable iOS support
await copyToClipboard(this.activeDid);
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
} catch (error) {
logger.error("Error copying DID to clipboard:", {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
onCopyDidToClipboard() {
useClipboard()
.copy(this.activeDid)
.then(() => {
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
});
this.notify.error("Failed to copy DID to clipboard.");
}
}
/**

View File

@@ -140,7 +140,6 @@ import { AxiosError } from "axios";
import { Buffer } from "buffer/";
import QRCodeVue3 from "qr-code-generator-vue3";
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { QrcodeStream } from "vue-qrcode-reader";
@@ -164,7 +163,6 @@ import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_QR_INITIALIZATION_ERROR,
NOTIFY_QR_CAMERA_IN_USE,
@@ -288,12 +286,7 @@ export default class ContactQRScanShow extends Vue {
try {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.givenName = settings.firstName || "";
this.hideRegisterPromptOnNewContact =
@@ -326,15 +319,6 @@ export default class ContactQRScanShow extends Vue {
async handleBack(): Promise<void> {
await this.cleanupScanner();
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
this.$router.back();
}
@@ -433,7 +417,7 @@ export default class ContactQRScanShow extends Vue {
this.isCleaningUp = true;
try {
logger.debug("Cleaning up QR scanner resources");
logger.info("Cleaning up QR scanner resources");
await this.stopScanning();
await QRScannerFactory.cleanup();
} catch (error) {
@@ -467,7 +451,7 @@ export default class ContactQRScanShow extends Vue {
rawValue === this.lastScannedValue &&
now - this.lastScanTime < this.SCAN_DEBOUNCE_MS
) {
logger.debug("Ignoring duplicate scan:", rawValue);
logger.info("Ignoring duplicate scan:", rawValue);
return;
}
@@ -475,7 +459,7 @@ export default class ContactQRScanShow extends Vue {
this.lastScannedValue = rawValue;
this.lastScanTime = now;
logger.debug("Processing QR code scan result:", rawValue);
logger.info("Processing QR code scan result:", rawValue);
let contact: Contact;
if (rawValue.includes(CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI)) {
@@ -485,7 +469,7 @@ export default class ContactQRScanShow extends Vue {
this.notify.error(NOTIFY_QR_INVALID_QR_CODE.message);
return;
}
logger.debug("Decoding JWT payload from QR code");
logger.info("Decoding JWT payload from QR code");
const decodedJwt = await decodeEndorserJwt(jwt);
// Process JWT and contact info
@@ -520,7 +504,7 @@ export default class ContactQRScanShow extends Vue {
}
// Add contact but keep scanning
logger.debug("Adding new contact to database:", {
logger.info("Adding new contact to database:", {
did: contact.did,
name: contact.name,
});
@@ -554,7 +538,7 @@ export default class ContactQRScanShow extends Vue {
}
async register(contact: Contact) {
logger.debug("Submitting contact registration", {
logger.info("Submitting contact registration", {
did: contact.did,
name: contact.name,
});
@@ -570,7 +554,7 @@ export default class ContactQRScanShow extends Vue {
if (regResult.success) {
contact.registered = true;
await this.$updateContact(contact.did, { registered: true });
logger.debug("Contact registration successful", { did: contact.did });
logger.info("Contact registration successful", { did: contact.did });
this.notify.success(
createQRRegistrationSuccessMessage(contact.name || ""),
@@ -634,6 +618,7 @@ export default class ContactQRScanShow extends Vue {
);
// Copy the URL to clipboard
const { copyToClipboard } = await import("../services/ClipboardService");
await copyToClipboard(jwtUrl);
this.notify.toast(
NOTIFY_QR_URL_COPIED.title,
@@ -652,6 +637,7 @@ export default class ContactQRScanShow extends Vue {
async onCopyDidToClipboard() {
//this.onScanDetect([{ rawValue: this.qrValue }]); // good for testing
try {
const { copyToClipboard } = await import("../services/ClipboardService");
await copyToClipboard(this.activeDid);
this.notify.info(NOTIFY_QR_DID_COPIED.message, QR_TIMEOUT_LONG);
} catch (error) {
@@ -696,20 +682,20 @@ export default class ContactQRScanShow extends Vue {
async handleAppPause() {
if (!this.isMounted) return;
logger.debug("App paused, stopping scanner");
logger.info("App paused, stopping scanner");
await this.stopScanning();
}
handleAppResume() {
if (!this.isMounted) return;
logger.debug("App resumed, scanner can be restarted by user");
logger.info("App resumed, scanner can be restarted by user");
this.isScanning = false;
}
async addNewContact(contact: Contact) {
try {
logger.debug("Opening database connection for new contact");
logger.info("Opening database connection for new contact");
// Check if contact already exists
const existingContact = await this.$getContact(contact.did);
@@ -736,7 +722,7 @@ export default class ContactQRScanShow extends Vue {
await this.$insertContact(contact);
if (this.activeDid) {
logger.debug("Setting contact visibility", { did: contact.did });
logger.info("Setting contact visibility", { did: contact.did });
await this.setVisibility(contact, true);
contact.seesMe = true;
}
@@ -752,17 +738,24 @@ export default class ContactQRScanShow extends Vue {
!contact.registered
) {
setTimeout(() => {
this.$notify(
this.notify.confirm(
"Do you want to register them?",
{
group: "modal",
type: "confirm",
title: "Register",
text: "Do you want to register them?",
onCancel: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
if (stopAsking) {
await this.$updateSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onNo: async (stopAsking?: boolean) => {
await this.handleRegistrationPromptResponse(stopAsking);
if (stopAsking) {
await this.$updateSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
},
onYes: async () => {
await this.register(contact);
@@ -892,17 +885,6 @@ export default class ContactQRScanShow extends Vue {
videoElement.style.transform = shouldMirror ? "scaleX(-1)" : "none";
}
}
private async handleRegistrationPromptResponse(
stopAsking?: boolean,
): Promise<void> {
if (stopAsking) {
await this.$saveSettings({
hideRegisterPromptOnNewContact: stopAsking,
});
this.hideRegisterPromptOnNewContact = stopAsking;
}
}
}
</script>

View File

@@ -174,7 +174,7 @@ import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { isDatabaseError } from "@/interfaces/common";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { APP_SERVER, DEFAULT_ENDORSER_API_SERVER } from "@/constants/app";
import { APP_SERVER } from "@/constants/app";
import { QRNavigationService } from "@/services/QRNavigationService";
import {
NOTIFY_CONTACT_NO_INFO,
@@ -294,19 +294,10 @@ export default class ContactsView extends Vue {
this.notify = createNotifyHelpers(this.$notify);
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.apiServer = settings.apiServer || DEFAULT_ENDORSER_API_SERVER;
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
logger.debug("[ContactsView] Created with settings:", {
activeDid: this.activeDid,
apiServer: this.apiServer,
isRegistered: this.isRegistered,
});
// if these detect a query parameter, they can and then redirect to this URL without a query parameter
// to avoid problems when they reload or they go forward & back and it tries to reprocess
await this.processContactJwt();
@@ -355,34 +346,15 @@ export default class ContactsView extends Vue {
// this happens when a platform (eg iOS) doesn't include anything after the "=" in a shared link.
this.notify.error(NOTIFY_BLANK_INVITE.message, TIMEOUTS.VERY_LONG);
} else if (importedInviteJwt) {
logger.debug("[ContactsView] Processing invite JWT, current activeDid:", {
activeDid: this.activeDid,
});
// Re-fetch settings after ensuring active_identity is populated
const updatedSettings = await this.$accountSettings();
this.activeDid = updatedSettings.activeDid || "";
this.apiServer = updatedSettings.apiServer || DEFAULT_ENDORSER_API_SERVER;
// Identity creation should be handled by router guard, but keep as fallback for invite processing
if (!this.activeDid) {
logger.info(
"[ContactsView] No active DID found, creating identity as fallback for invite processing",
);
this.activeDid = await generateSaveAndActivateIdentity();
logger.info("[ContactsView] Created new identity:", {
activeDid: this.activeDid,
});
}
// send invite directly to server, with auth for this user
const headers = await getHeaders(this.activeDid);
logger.debug("[ContactsView] Making API request to claim invite:", {
apiServer: this.apiServer,
activeDid: this.activeDid,
hasApiServer: !!this.apiServer,
apiServerLength: this.apiServer?.length || 0,
fullUrl: this.apiServer + "/api/v2/claim",
});
try {
const response = await this.axios.post(
this.apiServer + "/api/v2/claim",
@@ -404,9 +376,6 @@ export default class ContactsView extends Vue {
const payload: JWTPayload =
decodeEndorserJwt(importedInviteJwt).payload;
const registration = payload as VerifiableCredential;
logger.debug(
"[ContactsView] Opening ContactNameDialog for invite processing",
);
(this.$refs.contactNameDialog as ContactNameDialog).open(
"Who Invited You?",
"",
@@ -445,28 +414,17 @@ export default class ContactsView extends Vue {
this.$logAndConsole(fullError, true);
let message = "Got an error sending the invite.";
if (
error &&
typeof error === "object" &&
"response" in error &&
error.response &&
typeof error.response === "object" &&
"data" in error.response &&
error.response.data &&
typeof error.response.data === "object" &&
"error" in error.response.data
error.response.data.error
) {
const responseData = error.response.data as { error: unknown };
if (
responseData.error &&
typeof responseData.error === "object" &&
"message" in responseData.error
) {
message = (responseData.error as { message: string }).message;
if (error.response.data.error.message) {
message = error.response.data.error.message;
} else {
message = String(responseData.error);
message = error.response.data.error;
}
} else if (error && typeof error === "object" && "message" in error) {
message = (error as { message: string }).message;
} else if (error.message) {
message = error.message;
}
this.notify.error(message, TIMEOUTS.MODAL);
}

View File

@@ -71,22 +71,22 @@
contactFromDid?.seesMe && contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They can see your activity"
title="They can see you"
@click="confirmSetVisibility(contactFromDid, false)"
>
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
v-else-if="
!contactFromDid?.seesMe && contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="They cannot see your activity"
title="They cannot see you"
@click="confirmSetVisibility(contactFromDid, true)"
>
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-up" class="fa-fw" />
</button>
<button
@@ -95,11 +95,11 @@
contactFromDid.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You watch their activity"
title="I view their content"
@click="confirmViewContent(contactFromDid, false)"
>
<font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
v-else-if="
@@ -107,11 +107,11 @@
contactFromDid?.did !== activeDid
"
class="text-sm uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white mx-0.5 my-0.5 px-2 py-1.5 rounded-md"
title="You do not watch their activity"
title="I do not view their content"
@click="confirmViewContent(contactFromDid, true)"
>
<font-awesome icon="arrow-down" class="fa-fw" />
<font-awesome icon="eye-slash" class="fa-fw" />
<font-awesome icon="arrow-down" class="fa-fw" />
</button>
<button
@@ -376,12 +376,7 @@ export default class DIDView extends Vue {
*/
private async initializeSettings() {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
}

View File

@@ -1003,7 +1003,7 @@
<h2>Exported Data</h2>
<span
class="text-blue-500 cursor-pointer hover:text-blue-700"
@click="copyExportedDataToClipboard"
@click="copyToClipboard"
>
Copy to Clipboard
</span>
@@ -1014,7 +1014,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router";
import {
@@ -1072,6 +1072,8 @@ export default class DatabaseMigration extends Vue {
private exportedData: Record<string, any> | null = null;
private successMessage = "";
useClipboard = useClipboard;
/**
* Computed property to get the display name for a setting
* Handles both live comparison data and exported JSON format
@@ -1131,11 +1133,13 @@ export default class DatabaseMigration extends Vue {
/**
* Copies exported data to clipboard and shows success message
*/
async copyExportedDataToClipboard(): Promise<void> {
async copyToClipboard(): Promise<void> {
if (!this.exportedData) return;
try {
await copyToClipboard(JSON.stringify(this.exportedData, null, 2));
await this.useClipboard().copy(
JSON.stringify(this.exportedData, null, 2),
);
// Use global window object properly
if (typeof window !== "undefined") {
window.alert("Copied to clipboard!");
@@ -1261,7 +1265,7 @@ export default class DatabaseMigration extends Vue {
this.comparison.differences.settings.added.length +
this.comparison.differences.accounts.added.length;
this.successMessage = `Comparison completed successfully. Found ${totalItems} items to migrate.`;
logger.debug(
logger.info(
"[DatabaseMigration] Database comparison completed successfully",
);
} catch (error) {
@@ -1313,7 +1317,7 @@ export default class DatabaseMigration extends Vue {
this.successMessage += ` ${result.warnings.length} warnings.`;
this.warning += result.warnings.join(", ");
}
logger.debug(
logger.info(
"[DatabaseMigration] Settings migration completed successfully",
result,
);
@@ -1356,7 +1360,7 @@ export default class DatabaseMigration extends Vue {
this.successMessage += ` ${result.warnings.length} warnings.`;
this.warning += result.warnings.join(", ");
}
logger.debug(
logger.info(
"[DatabaseMigration] Account migration completed successfully",
result,
);
@@ -1406,7 +1410,7 @@ export default class DatabaseMigration extends Vue {
URL.revokeObjectURL(url);
this.successMessage = "Comparison data exported successfully";
logger.debug("[DatabaseMigration] Comparison data exported successfully");
logger.info("[DatabaseMigration] Comparison data exported successfully");
} catch (error) {
this.error = `Failed to export comparison data: ${error}`;
logger.error("[DatabaseMigration] Export failed:", error);

View File

@@ -51,6 +51,33 @@
<!-- Secondary Tabs -->
<div class="text-center text-slate-500 border-b border-slate-300">
<ul class="flex flex-wrap justify-center gap-4 -mb-px">
<li v-if="isProjectsActive">
<a
href="#"
:class="computedStarredTabStyleClassNames()"
@click="
projects = [];
userProfiles = [];
isStarredActive = true;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = false;
isSearchVisible = false;
tempSearchBox = null;
searchStarred();
"
>
Starred
<!-- restore when the links don't jump around for different numbers
<span
class="font-semibold text-sm bg-slate-200 px-1.5 py-0.5 rounded-md"
v-if="isLocalActive"
>
{{ localCount > -1 ? localCount : "?" }}
</span>
-->
</a>
</li>
<li>
<a
href="#"
@@ -58,9 +85,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = true;
isMappedActive = false;
isAnywhereActive = false;
isStarredActive = false;
isSearchVisible = true;
tempSearchBox = null;
searchLocal();
@@ -84,9 +113,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = false;
isMappedActive = true;
isAnywhereActive = false;
isStarredActive = false;
isSearchVisible = false;
searchTerms = '';
tempSearchBox = null;
@@ -103,9 +134,11 @@
@click="
projects = [];
userProfiles = [];
isStarredActive = false;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = true;
isStarredActive = false;
isSearchVisible = true;
tempSearchBox = null;
searchAll();
@@ -201,6 +234,15 @@
>No {{ isProjectsActive ? "projects" : "people" }} were found with
that search.</span
>
<span v-else-if="isStarredActive">
<p>
You have no starred projects. Star some projects to see them here.
</p>
<p class="mt-4">
When you star projects, you will get a notice on the front page when
they change.
</p>
</span>
</p>
</div>
@@ -383,9 +425,12 @@ export default class DiscoverView extends Vue {
allMyDids: Array<string> = [];
apiServer = "";
isLoading = false;
isLocalActive = false;
isMappedActive = false;
isAnywhereActive = true;
isStarredActive = false;
isProjectsActive = true;
isPeopleActive = false;
isSearchVisible = true;
@@ -415,11 +460,7 @@ export default class DiscoverView extends Vue {
const searchPeople = !!this.$route.query["searchPeople"];
const settings = await this.$accountSettings();
// Get activeDid from new active_identity table (ActiveDid migration)
const activeIdentity = await this.$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = (settings.activeDid as string) || "";
this.apiServer = (settings.apiServer as string) || "";
this.partnerApiServer =
(settings.partnerApiServer as string) || this.partnerApiServer;
@@ -474,6 +515,8 @@ export default class DiscoverView extends Vue {
leafletObject: L.Map;
};
this.requestTiles(mapRef.leafletObject); // not ideal because I found this from experimentation, not documentation
} else if (this.isStarredActive) {
await this.searchStarred();
} else {
await this.searchAll();
}
@@ -544,6 +587,60 @@ export default class DiscoverView extends Vue {
}
}
public async searchStarred() {
this.resetCounts();
// Clear any previous results
this.projects = [];
this.userProfiles = [];
try {
this.isLoading = true;
// Get starred project IDs from settings
const settings = await this.$accountSettings();
const starredIds = settings.starredPlanHandleIds || [];
if (starredIds.length === 0) {
// No starred projects
return;
}
// This could be optimized to only pull those not already in the cache (endorserServer.ts)
const planHandleIdsJson = JSON.stringify(starredIds);
const endpoint =
this.apiServer +
"/api/v2/report/plans?planHandleIds=" +
encodeURIComponent(planHandleIdsJson);
const response = await this.axios.get(endpoint, {
headers: await getHeaders(this.activeDid),
});
if (response.status !== 200) {
this.notify.error("Failed to load starred projects", TIMEOUTS.SHORT);
return;
}
const starredPlans: PlanData[] = response.data.data;
if (response.data.hitLimit) {
// someday we'll have to let them incrementally load the rest
this.notify.warning(
"Beware: you have so many starred projects that we cannot load them all.",
TIMEOUTS.SHORT,
);
}
this.projects = starredPlans;
} catch (error: unknown) {
logger.error("Error loading starred projects:", error);
this.notify.error(
"Failed to load starred projects. Please try again.",
TIMEOUTS.LONG,
);
} finally {
this.isLoading = false;
}
}
public async searchLocal(beforeId?: string) {
this.resetCounts();
@@ -637,9 +734,12 @@ export default class DiscoverView extends Vue {
const latestProject = this.projects[this.projects.length - 1];
if (this.isLocalActive || this.isMappedActive) {
this.searchLocal(latestProject.rowId);
} else if (this.isStarredActive) {
this.searchStarred();
} else if (this.isAnywhereActive) {
this.searchAll(latestProject.rowId);
}
// Note: Starred tab doesn't support pagination since we load all starred projects at once
} else if (this.isPeopleActive && this.userProfiles.length > 0) {
const latestProfile = this.userProfiles[this.userProfiles.length - 1];
if (this.isLocalActive || this.isMappedActive) {
@@ -779,6 +879,24 @@ export default class DiscoverView extends Vue {
this.$router.push(route);
}
public computedStarredTabStyleClassNames() {
return {
"inline-block": true,
"py-3": true,
"rounded-t-lg": true,
"border-b-2": true,
active: this.isStarredActive,
"text-black": this.isStarredActive,
"border-black": this.isStarredActive,
"font-semibold": this.isStarredActive,
"text-blue-600": !this.isStarredActive,
"border-transparent": !this.isStarredActive,
"hover:border-slate-400": !this.isStarredActive,
};
}
public computedLocalTabStyleClassNames() {
return {
"inline-block": true,

View File

@@ -280,7 +280,6 @@ import { logger } from "../utils/logger";
import { Contact } from "@/db/tables/contacts";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import {
NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
@@ -442,11 +441,7 @@ export default class GiftedDetails extends Vue {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
if (
(this.giverDid && !this.giverName) ||
@@ -775,15 +770,6 @@ export default class GiftedDetails extends Vue {
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message,
TIMEOUTS.SHORT,
);
// Show seed phrase backup reminder if needed
try {
const settings = await this.$accountSettings();
showSeedPhraseReminder(!!settings.hasBackedUpSeed, this.$notify);
} catch (error) {
logger.error("Error checking seed backup status:", error);
}
localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter });

View File

@@ -394,7 +394,7 @@ export default class HelpNotificationsView extends Vue {
notifyingReminderTime = "";
// Notification helper system
notify!: ReturnType<typeof createNotifyHelpers>;
notify = createNotifyHelpers(this.$notify);
/**
* Computed property for consistent button styling
@@ -430,9 +430,6 @@ export default class HelpNotificationsView extends Vue {
* Handles errors gracefully with proper logging without exposing sensitive data.
*/
async mounted() {
// Initialize notification helpers
this.notify = createNotifyHelpers(this.$notify);
try {
const registration = await navigator.serviceWorker?.ready;
const fullSub = await registration?.pushManager.getSubscription();

View File

@@ -319,9 +319,8 @@
<ul class="list-disc list-outside ml-4">
<li>
Go to Your Identity <font-awesome icon="circle-user" class="fa-fw" /> page,
click Advanced, and follow the instructions to "Import Contacts".
(There is currently no way to import other settings, so you'll have to recreate
by hand your search area, filters, etc.)
click Advanced, and follow the instructions for the Contacts & Settings Database "Import".
Beware that this will erase your existing contact & settings.
</li>
</ul>
</div>
@@ -337,18 +336,14 @@
<h2 class="text-xl font-semibold">How do I erase my data from my device?</h2>
<p>
Before doing this, you should back up your data with the instructions above.
Note that this does not erase data sent to our servers (see contact info below)
Before doing this, you may want to back up your data with the instructions above.
</p>
<ul>
<li class="list-disc list-outside ml-4">
Mobile
<ul>
<li class="list-disc list-outside ml-4">
App Store app: hold down on the icon, then uninstall it
</li>
<li class="list-disc list-outside ml-4">
Home Screen PWA: hold down on the icon, and delete it
Home Screen: hold down on the icon, and choose to delete it
</li>
<li class="list-disc list-outside ml-4">
Chrome: Settings -> Privacy and Security -> Clear Browsing Data
@@ -420,6 +415,15 @@
different page.
</p>
<h2 class="text-xl font-semibold">
Where do I get help with notifications?
</h2>
<p>
<router-link class="text-blue-500" to="/help-notifications"
>Here.</router-link
>
</p>
<h2 class="text-xl font-semibold">
This app is misbehaving, like showing me a blank screen or failing to show my personal data.
What can I do?
@@ -430,13 +434,10 @@
</p>
<ul class="list-disc list-outside ml-4">
<li>
For mobile apps, make sure you're connected to the internet.
</li>
<li>
For PWAs, drag down on the screen to refresh it; do that multiple times, because
Drag down on the screen to refresh it; do that multiple times, because
it sometimes takes multiple tries for the app to refresh to the latest version.
You can see the version information at the bottom of this page; the best
way to determine the latest version is to open TimeSafari.app in an incognito/private
way to determine the latest version is to open this page in an incognito/private
browser window and look at the version there.
</li>
<li>
@@ -467,6 +468,9 @@
</ul>
Then reload Time Safari.
</li>
<li>
Restart your device.
</li>
</ul>
<p>
If you still have problems, you can clear the cache (see "erase my data" above)
@@ -504,12 +508,16 @@
</p>
<ul class="list-disc list-outside ml-4">
<li>
If sending images, a server stores them. They can be removed by editing each claim
and deleting the image.
If using notifications, a server stores push token data. That can be revoked at any time
by disabling notifications on the Profile <font-awesome icon="circle-user" class="fa-fw" /> page.
</li>
<li>
If sending images, a server stores them, too. They can be removed by editing the claim
and deleting them.
</li>
<li>
If sending other partner system data (eg. to Trustroots) a public key and message
data are stored on a server. Those can be removed via direct personal request (via contact below).
data are stored on a server. Those can be removed via direct personal request.
</li>
<li>
For all other claim data,
@@ -584,16 +592,15 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
// Capacitor import removed - using QRNavigationService instead
import * as Package from "../../package.json";
import QuickNav from "../components/QuickNav.vue";
import { APP_SERVER, NotificationIface } from "../constants/app";
import { APP_SERVER } from "../constants/app";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { QRNavigationService } from "@/services/QRNavigationService";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
/**
* HelpView.vue - Comprehensive Help System Component
@@ -627,10 +634,8 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
})
export default class HelpView extends Vue {
$router!: Router;
$notify!: (notification: NotificationIface, timeout?: number) => void;
package = Package;
notify!: ReturnType<typeof createNotifyHelpers>;
commitHash = import.meta.env.VITE_GIT_HASH;
showAlpha = false;
showBasics = false;
@@ -643,13 +648,6 @@ export default class HelpView extends Vue {
APP_SERVER = APP_SERVER;
// Capacitor reference removed - using QRNavigationService instead
/**
* Initialize notification helpers
*/
created() {
this.notify = createNotifyHelpers(this.$notify);
}
/**
* Get the unnamed entity name constant
*/
@@ -670,15 +668,11 @@ export default class HelpView extends Vue {
* @param {string} text - The text to copy to clipboard
* @param {Function} fn - Callback function to execute before and after copying
*/
async doCopyTwoSecRedo(text: string, fn: () => void): Promise<void> {
doCopyTwoSecRedo(text: string, fn: () => void): void {
fn();
try {
await copyToClipboard(text);
setTimeout(fn, 2000);
} catch (error) {
this.$logAndConsole(`Error copying to clipboard: ${error}`, true);
this.notify.error("Failed to copy to clipboard.", TIMEOUTS.SHORT);
}
useClipboard()
.copy(text)
.then(() => setTimeout(fn, 2000));
}
/**
@@ -694,10 +688,7 @@ export default class HelpView extends Vue {
try {
const settings = await this.$accountSettings();
// Get activeDid from new active_identity table (ActiveDid migration)
const activeIdentity = await this.$getActiveIdentity();
if (activeIdentity.activeDid) {
if (settings.activeDid) {
await this.$updateSettings({
...settings,
finishedOnboarding: false,
@@ -705,7 +696,7 @@ export default class HelpView extends Vue {
this.$log(
"[HelpView] Onboarding reset successfully for DID: " +
activeIdentity.activeDid,
settings.activeDid,
);
}

View File

@@ -170,10 +170,10 @@ Raymer * @version 1.0.0 */
class="border-t p-2 border-slate-300"
@click="goToActivityToUserPage()"
>
<div class="flex justify-center">
<div class="flex justify-center gap-2">
<div
v-if="numNewOffersToUser"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
@@ -187,7 +187,7 @@ Raymer * @version 1.0.0 */
</div>
<div
v-if="numNewOffersToUserProjects"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] m-1 px-4 py-4 rounded-md text-white"
class="bg-gradient-to-b from-green-400 to-green-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
@@ -201,6 +201,22 @@ Raymer * @version 1.0.0 */
projects
</p>
</div>
<div
v-if="numNewStarredProjectChanges"
class="bg-gradient-to-b from-blue-400 to-blue-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] flex-1 px-4 py-4 rounded-md text-white cursor-pointer"
>
<span
class="block text-center text-6xl"
data-testId="newStarredProjectChangesActivityNumber"
>
{{ numNewStarredProjectChanges
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}
</span>
<p class="text-center">
favorite project{{ numNewStarredProjectChanges === 1 ? "" : "s" }}
with changes
</p>
</div>
</div>
<div class="flex justify-end mt-2">
<button class="text-blue-500">View All New Activity For You</button>
@@ -238,7 +254,7 @@ Raymer * @version 1.0.0 */
<script lang="ts">
import { UAParser } from "ua-parser-js";
import { Component, Vue, Watch } from "vue-facing-decorator";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
//import App from "../App.vue";
@@ -268,6 +284,7 @@ import {
getHeaders,
getNewOffersToUser,
getNewOffersToUserProjects,
getStarredProjectsWithChanges,
getPlanFromCache,
} from "../libs/endorserServer";
import {
@@ -283,7 +300,7 @@ import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONTACT_LOADING_ISSUE } from "@/constants/notifications";
import * as Package from "../../package.json";
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
import { errorStringForLog } from "../libs/endorserServer";
import * as databaseUtil from "../db/databaseUtil";
// consolidate this with GiveActionClaim in src/interfaces/claims.ts
interface Claim {
@@ -396,48 +413,14 @@ export default class HomeView extends Vue {
isRegistered = false;
lastAckedOfferToUserJwtId?: string; // the last JWT ID for offer-to-user that they've acknowledged seeing
lastAckedOfferToUserProjectsJwtId?: string; // the last JWT ID for offers-to-user's-projects that they've acknowledged seeing
lastAckedStarredPlanChangesJwtId?: string; // the last JWT ID for starred project changes that they've acknowledged seeing
newOffersToUserHitLimit: boolean = false;
newOffersToUserProjectsHitLimit: boolean = false;
newStarredProjectChangesHitLimit: boolean = false;
numNewOffersToUser: number = 0; // number of new offers-to-user
numNewOffersToUserProjects: number = 0; // number of new offers-to-user's-projects
/**
* CRITICAL VUE REACTIVITY BUG WORKAROUND
*
* This watcher is required for the component to render correctly.
* Without it, the newDirectOffersActivityNumber element fails to render
* even when numNewOffersToUser has the correct value.
*
* This appears to be a Vue reactivity issue where property changes
* don't trigger proper template updates.
*
* DO NOT REMOVE until the underlying Vue reactivity issue is resolved.
*
* See: doc/activeDid-migration-plan.md for details
*/
@Watch("numNewOffersToUser")
onNumNewOffersToUserChange(newValue: number, oldValue: number) {
logger.debug("[HomeView] numNewOffersToUser changed", {
oldValue,
newValue,
willRender: !!newValue,
vIfCondition: `v-if="numNewOffersToUser"`,
elementTestId: "newDirectOffersActivityNumber",
shouldShowElement: newValue > 0,
timestamp: new Date().toISOString(),
});
}
// get shouldShowNewOffersToUser() {
// const shouldShow = !!this.numNewOffersToUser;
// logger.debug("[HomeView] shouldShowNewOffersToUser computed", {
// numNewOffersToUser: this.numNewOffersToUser,
// shouldShow,
// timestamp: new Date().toISOString()
// });
// return shouldShow;
// }
numNewStarredProjectChanges: number = 0; // number of new starred project changes
starredPlanHandleIds: Array<string> = []; // list of starred project IDs
searchBoxes: Array<{
name: string;
bbox: BoundingBox;
@@ -471,44 +454,14 @@ export default class HomeView extends Vue {
*/
async mounted() {
try {
logger.debug("[HomeView] mounted() - component lifecycle started", {
timestamp: new Date().toISOString(),
componentName: "HomeView",
});
await this.initializeIdentity();
// Settings already loaded in initializeIdentity()
// Contacts already loaded in initializeIdentity()
await this.loadContacts();
// Registration check already handled in initializeIdentity()
await this.loadFeedData();
logger.debug("[HomeView] mounted() - about to call loadNewOffers()", {
timestamp: new Date().toISOString(),
activeDid: this.activeDid,
hasActiveDid: !!this.activeDid,
});
await this.loadNewOffers();
logger.debug("[HomeView] mounted() - loadNewOffers() completed", {
timestamp: new Date().toISOString(),
numNewOffersToUser: this.numNewOffersToUser,
numNewOffersToUserProjects: this.numNewOffersToUserProjects,
shouldShowElement:
this.numNewOffersToUser + this.numNewOffersToUserProjects > 0,
});
await this.loadNewStarredProjectChanges();
await this.checkOnboarding();
logger.debug("[HomeView] mounted() - component lifecycle completed", {
timestamp: new Date().toISOString(),
finalState: {
numNewOffersToUser: this.numNewOffersToUser,
numNewOffersToUserProjects: this.numNewOffersToUserProjects,
shouldShowElement:
this.numNewOffersToUser + this.numNewOffersToUserProjects > 0,
},
});
} catch (err: unknown) {
this.handleError(err);
}
@@ -585,22 +538,11 @@ export default class HomeView extends Vue {
// **CRITICAL**: Ensure correct API server for platform
await this.ensureCorrectApiServer();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
logger.debug("[HomeView] ActiveDid migration - using new API", {
activeDid: this.activeDid,
source: "active_identity table",
hasActiveDid: !!this.activeDid,
activeIdentityResult: activeIdentity,
isRegistered: this.isRegistered,
timestamp: new Date().toISOString(),
});
this.activeDid = settings.activeDid || "";
// Load contacts with graceful fallback
try {
await this.loadContacts();
this.loadContacts();
} catch (error) {
this.$logAndConsole(
`[HomeView] Failed to retrieve contacts: ${error}`,
@@ -623,8 +565,14 @@ export default class HomeView extends Vue {
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId;
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId;
this.lastAckedStarredPlanChangesJwtId =
settings.lastAckedStarredPlanChangesJwtId;
this.searchBoxes = settings.searchBoxes || [];
this.showShortcutBvc = !!settings.showShortcutBvc;
this.starredPlanHandleIds = databaseUtil.parseJsonField(
settings.starredPlanHandleIds,
[],
);
this.isAnyFeedFilterOn = checkIsAnyFeedFilterOn(settings);
// Check onboarding status
@@ -645,9 +593,7 @@ export default class HomeView extends Vue {
if (resp.status === 200) {
// Ultra-concise settings update with automatic cache invalidation!
await this.$saveUserSettings(this.activeDid, {
isRegistered: true,
});
await this.$saveMySettings({ isRegistered: true });
this.isRegistered = true;
}
} catch (error) {
@@ -737,103 +683,61 @@ export default class HomeView extends Vue {
* @requires Active DID
*/
private async loadNewOffers() {
logger.debug("[HomeView] loadNewOffers() called with activeDid:", {
activeDid: this.activeDid,
hasActiveDid: !!this.activeDid,
length: this.activeDid?.length || 0,
});
if (this.activeDid) {
logger.debug(
"[HomeView] loadNewOffers() - activeDid found, calling API",
{
activeDid: this.activeDid,
apiServer: this.apiServer,
isRegistered: this.isRegistered,
lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId,
},
const offersToUserData = await getNewOffersToUser(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
);
this.numNewOffersToUser = offersToUserData.data.length;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
const offersToUserProjects = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
}
}
/**
* Loads new changes for starred projects
* Updates:
* - Number of new starred project changes
* - Rate limit status for starred project changes
*
* @internal
* Called by mounted() and initializeIdentity()
* @requires Active DID
*/
private async loadNewStarredProjectChanges() {
if (this.activeDid && this.starredPlanHandleIds.length > 0) {
try {
const offersToUserData = await getNewOffersToUser(
const starredProjectChanges = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserJwtId,
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
logger.debug(
"[HomeView] loadNewOffers() - getNewOffersToUser successful",
{
activeDid: this.activeDid,
dataLength: offersToUserData.data.length,
hitLimit: offersToUserData.hitLimit,
},
);
this.numNewOffersToUser = offersToUserData.data.length;
this.newOffersToUserHitLimit = offersToUserData.hitLimit;
logger.debug("[HomeView] loadNewOffers() - updated component state", {
activeDid: this.activeDid,
numNewOffersToUser: this.numNewOffersToUser,
newOffersToUserHitLimit: this.newOffersToUserHitLimit,
willRender: !!this.numNewOffersToUser,
timestamp: new Date().toISOString(),
});
const offersToUserProjects = await getNewOffersToUserProjects(
this.axios,
this.apiServer,
this.activeDid,
this.lastAckedOfferToUserProjectsJwtId,
);
logger.debug(
"[HomeView] loadNewOffers() - getNewOffersToUserProjects successful",
{
activeDid: this.activeDid,
dataLength: offersToUserProjects.data.length,
hitLimit: offersToUserProjects.hitLimit,
},
);
this.numNewOffersToUserProjects = offersToUserProjects.data.length;
this.newOffersToUserProjectsHitLimit = offersToUserProjects.hitLimit;
logger.debug("[HomeView] loadNewOffers() - all API calls completed", {
numNewOffersToUser: this.numNewOffersToUser,
numNewOffersToUserProjects: this.numNewOffersToUserProjects,
shouldRenderElement: !!this.numNewOffersToUser,
elementTestId: "newDirectOffersActivityNumber",
timestamp: new Date().toISOString(),
});
// Additional logging for template rendering debugging
logger.debug("[HomeView] loadNewOffers() - template rendering check", {
numNewOffersToUser: this.numNewOffersToUser,
numNewOffersToUserProjects: this.numNewOffersToUserProjects,
totalNewOffers:
this.numNewOffersToUser + this.numNewOffersToUserProjects,
shouldShowElement:
this.numNewOffersToUser + this.numNewOffersToUserProjects > 0,
vIfCondition: `v-if="numNewOffersToUser + numNewOffersToUserProjects"`,
elementWillRender:
this.numNewOffersToUser + this.numNewOffersToUserProjects > 0,
timestamp: new Date().toISOString(),
});
this.numNewStarredProjectChanges = starredProjectChanges.data.length;
this.newStarredProjectChangesHitLimit = starredProjectChanges.hitLimit;
} catch (error) {
logger.error("[HomeView] loadNewOffers() - API call failed", {
activeDid: this.activeDid,
apiServer: this.apiServer,
isRegistered: this.isRegistered,
error: errorStringForLog(error),
errorMessage: error instanceof Error ? error.message : String(error),
});
// Don't show errors for starred project changes as it's a secondary feature
logger.warn(
"[HomeView] Failed to load starred project changes:",
error,
);
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
} else {
logger.warn("[HomeView] loadNewOffers() - no activeDid available", {
activeDid: this.activeDid,
timestamp: new Date().toISOString(),
});
this.numNewStarredProjectChanges = 0;
this.newStarredProjectChangesHitLimit = false;
}
}

View File

@@ -200,12 +200,7 @@ export default class IdentitySwitcherView extends Vue {
async created() {
try {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.apiServerInput = settings.apiServer || "";
@@ -227,8 +222,8 @@ export default class IdentitySwitcherView extends Vue {
}
async switchAccount(did?: string) {
// Update the active DID in the active_identity table
await this.$updateActiveDid(did);
// Save the new active DID to master settings
await this.$saveSettings({ activeDid: did });
// Check if we need to load user-specific settings for the new DID
if (did) {
@@ -272,51 +267,15 @@ export default class IdentitySwitcherView extends Vue {
this.notify.confirm(
NOTIFY_DELETE_IDENTITY_CONFIRM.text,
async () => {
await this.smartDeleteAccount(id);
await this.$exec(`DELETE FROM accounts WHERE id = ?`, [id]);
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
},
-1,
);
}
/**
* Smart deletion with atomic transaction and last account protection
* Follows the Active Pointer + Smart Deletion Pattern
*/
async smartDeleteAccount(id: string) {
await this.$withTransaction(async () => {
const total = await this.$countAccounts();
if (total <= 1) {
this.notify.warning(
"Cannot delete the last account. Keep at least one.",
);
throw new Error("blocked:last-item");
}
const accountDid = await this.$getAccountDidById(parseInt(id));
const activeDid = await this.$getActiveDid();
if (activeDid === accountDid) {
const allDids = await this.$getAllAccountDids();
const nextDid = this.$pickNextAccountDid(
allDids.filter((d) => d !== accountDid),
accountDid,
);
await this.$setActiveDid(nextDid);
this.notify.success(`Switched active to ${nextDid} before deletion.`);
}
await this.$exec("DELETE FROM accounts WHERE id = ?", [id]);
await this.$exec("DELETE FROM settings WHERE accountDid = ?", [
accountDid,
]);
});
// Update UI
this.otherIdentities = this.otherIdentities.filter(
(ident) => ident.id !== id,
);
}
notifyCannotDelete() {
this.notify.warning(
NOTIFY_CANNOT_DELETE_ACTIVE_IDENTITY.message,

View File

@@ -88,15 +88,9 @@ import { Router } from "vue-router";
import { AppString, NotificationIface } from "../constants/app";
import { DEFAULT_ROOT_DERIVATION_PATH } from "../libs/crypto";
import {
retrieveAccountCount,
importFromMnemonic,
checkForDuplicateAccount,
DUPLICATE_ACCOUNT_ERROR,
} from "../libs/util";
import { retrieveAccountCount, importFromMnemonic } from "../libs/util";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_DUPLICATE_ACCOUNT_IMPORT } from "@/constants/notifications";
/**
* Import Account View Component
@@ -204,19 +198,6 @@ export default class ImportAccountView extends Vue {
}
try {
// Check for duplicate account before importing
const isDuplicate = await checkForDuplicateAccount(
this.mnemonic,
this.derivationPath,
);
if (isDuplicate) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
await importFromMnemonic(
this.mnemonic,
this.derivationPath,
@@ -224,14 +205,13 @@ export default class ImportAccountView extends Vue {
);
// Check what was actually imported
// Check account-specific settings
// Get activeDid from new active_identity table (ActiveDid migration)
const activeIdentity = await this.$getActiveIdentity();
const settings = await this.$accountSettings();
if (activeIdentity.activeDid) {
// Check account-specific settings
if (settings?.activeDid) {
try {
await this.$query("SELECT * FROM settings WHERE accountDid = ?", [
activeIdentity.activeDid,
settings.activeDid,
]);
} catch (error) {
// Log error but don't interrupt import flow
@@ -243,20 +223,9 @@ export default class ImportAccountView extends Vue {
this.$router.push({ name: "account" });
} catch (error: unknown) {
this.$logError("Import failed: " + error);
// Check if this is a duplicate account error from saveNewIdentity
const errorMessage =
error instanceof Error ? error.message : String(error);
if (errorMessage.includes(DUPLICATE_ACCOUNT_ERROR)) {
this.notify.warning(
NOTIFY_DUPLICATE_ACCOUNT_IMPORT.message,
TIMEOUTS.LONG,
);
return;
}
this.notify.error(
errorMessage || "Failed to import account.",
(error instanceof Error ? error.message : String(error)) ||
"Failed to import account.",
TIMEOUTS.LONG,
);
}

View File

@@ -83,7 +83,6 @@ import {
retrieveAllAccountsMetadata,
retrieveFullyDecryptedAccount,
saveNewIdentity,
checkForDuplicateAccount,
} from "../libs/util";
import { logger } from "../utils/logger";
import { Account, AccountEncrypted } from "../db/tables/accounts";
@@ -172,16 +171,6 @@ export default class ImportAccountView extends Vue {
const newId = newIdentifier(address, publicHex, privateHex, newDerivPath);
try {
// Check for duplicate account before creating
const isDuplicate = await checkForDuplicateAccount(newId.did);
if (isDuplicate) {
this.notify.warning(
"This derived account already exists. Please try a different derivation path.",
TIMEOUTS.LONG,
);
return;
}
await saveNewIdentity(newId, mne, newDerivPath);
// record that as the active DID

View File

@@ -120,12 +120,7 @@ export default class InviteOneAcceptView extends Vue {
// Load or generate identity
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
// Identity creation should be handled by router guard, but keep as fallback for deep links

View File

@@ -128,7 +128,7 @@
<script lang="ts">
import axios from "axios";
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { Router } from "vue-router";
import ContactNameDialog from "../components/ContactNameDialog.vue";
@@ -283,12 +283,7 @@ export default class InviteOneView extends Vue {
try {
// Use PlatformServiceMixin for account settings
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
@@ -338,27 +333,17 @@ export default class InviteOneView extends Vue {
return `${APP_SERVER}/deep-link/invite-one-accept/${jwt}`;
}
async copyInviteAndNotify(inviteId: string, jwt: string) {
try {
await copyToClipboard(this.inviteLink(jwt));
this.notify.success(createInviteLinkCopyMessage(inviteId), TIMEOUTS.LONG);
} catch (error) {
this.$logAndConsole(`Error copying invite link: ${error}`, true);
this.notify.error("Failed to copy invite link.");
}
copyInviteAndNotify(inviteId: string, jwt: string) {
useClipboard().copy(this.inviteLink(jwt));
this.notify.success(createInviteLinkCopyMessage(inviteId), TIMEOUTS.LONG);
}
async showInvite(inviteId: string, redeemed: boolean, expired: boolean) {
try {
await copyToClipboard(inviteId);
this.notify.success(
createInviteIdCopyMessage(inviteId, redeemed, expired),
TIMEOUTS.LONG,
);
} catch (error) {
this.$logAndConsole(`Error copying invite ID: ${error}`, true);
this.notify.error("Failed to copy invite ID.");
}
showInvite(inviteId: string, redeemed: boolean, expired: boolean) {
useClipboard().copy(inviteId);
this.notify.success(
createInviteIdCopyMessage(inviteId, redeemed, expired),
TIMEOUTS.LONG,
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any

View File

@@ -29,12 +29,12 @@
v-if="newOffersToUser.length > 0"
:icon="showOffersDetails ? 'chevron-down' : 'chevron-right'"
class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandOffersToUserAndMarkRead()"
@click.prevent="expandOffersToUserAndMarkRead()"
/>
</div>
<a class="text-blue-500 cursor-pointer" @click="handleSeeAllOffersToUser">
<router-link to="/recent-offers-to-user" class="text-blue-500">
See&nbsp;all
</a>
</router-link>
</div>
<div v-if="showOffersDetails" class="ml-4 mt-4">
@@ -48,7 +48,7 @@
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts)
}}</span>
offered
<span v-if="offer.objectDescription">{{
<span v-if="offer.objectDescription" class="truncate">{{
offer.objectDescription
}}</span
>{{ offer.objectDescription && offer.amount ? ", and " : "" }}
@@ -67,10 +67,10 @@
<!-- New line that appears on hover or when the offer is clicked -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="markOffersAsReadStartingWith(offer.jwtId)"
@click.prevent="markOffersAsReadStartingWith(offer.jwtId)"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers
Click to keep all above as unread offers
</div>
</li>
</ul>
@@ -96,15 +96,12 @@
showOffersToUserProjectsDetails ? 'chevron-down' : 'chevron-right'
"
class="cursor-pointer ml-4 mr-4 text-lg"
@click="expandOffersToUserProjectsAndMarkRead()"
@click.prevent="expandOffersToUserProjectsAndMarkRead()"
/>
</div>
<a
class="text-blue-500 cursor-pointer"
@click="handleSeeAllOffersToUserProjects"
>
<router-link to="/recent-offers-to-user-projects" class="text-blue-500">
See&nbsp;all
</a>
</router-link>
</div>
<div v-if="showOffersToUserProjectsDetails" class="ml-4 mt-4">
@@ -118,7 +115,7 @@
didInfo(offer.offeredByDid, activeDid, allMyDids, allContacts)
}}</span>
offered
<span v-if="offer.objectDescription">{{
<span v-if="offer.objectDescription" class="truncate">{{
offer.objectDescription
}}</span
>{{ offer.objectDescription && offer.amount ? ", and " : "" }}
@@ -139,10 +136,153 @@
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click="markOffersToUserProjectsAsReadStartingWith(offer.jwtId)"
@click.prevent="
markOffersToUserProjectsAsReadStartingWith(offer.jwtId)
"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as new offers
Click to keep all above as unread offers
</div>
</li>
</ul>
</div>
<!-- Starred Projects with Changes Section -->
<div
class="flex justify-between mt-6"
data-testId="showStarredProjectChanges"
>
<div>
<span class="text-lg font-medium"
>{{ newStarredProjectChanges.length
}}{{ newStarredProjectChangesHitLimit ? "+" : "" }}</span
>
<span class="text-lg font-medium ml-4"
>Favorite Project{{
newStarredProjectChanges.length === 1 ? "" : "s"
}}
With Changes</span
>
<font-awesome
v-if="newStarredProjectChanges.length > 0"
:icon="
showStarredProjectChangesDetails ? 'chevron-down' : 'chevron-right'
"
class="cursor-pointer ml-4 mr-4 text-lg"
@click.prevent="expandStarredProjectChangesAndMarkRead()"
/>
</div>
</div>
<div v-if="showStarredProjectChangesDetails" class="ml-4 mt-4">
<ul class="list-disc ml-4">
<li
v-for="projectChange in newStarredProjectChanges"
:key="projectChange.plan.handleId"
class="mt-4 relative group"
>
<div class="flex items-center gap-2">
<div class="flex-1 min-w-0">
<span class="font-medium">{{
projectChange.plan.name || "Unnamed Project"
}}</span>
<span
v-if="projectChange.plan.description"
class="text-gray-600 block truncate"
>
{{ projectChange.plan.description }}
</span>
</div>
<router-link
:to="{
path:
'/project/' + encodeURIComponent(projectChange.plan.handleId),
}"
class="text-blue-500 flex-shrink-0"
>
<font-awesome
icon="file-lines"
class="text-blue-500 cursor-pointer"
/>
</router-link>
</div>
<!-- Show what changed -->
<div
v-if="getPlanDifferences(projectChange.plan.handleId)"
class="text-sm mt-2"
>
<div class="font-medium mb-2">Changes</div>
<div class="overflow-x-auto">
<table
class="w-full text-xs border-collapse border border-gray-300 rounded-lg shadow-sm bg-white"
>
<thead>
<tr class="bg-gray-50">
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
></th>
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
>
Previous
</th>
<th
class="border border-gray-300 px-3 py-2 text-left font-semibold text-gray-700"
>
Current
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(difference, field) in getPlanDifferences(
projectChange.plan.handleId,
)"
:key="field"
class="hover:bg-gray-50"
>
<td
class="border border-gray-300 px-3 py-2 font-medium text-gray-800 break-words"
>
{{ getDisplayFieldName(field) }}
</td>
<td
class="border border-gray-300 px-3 py-2 text-gray-600 break-words align-top"
>
<vue-markdown
v-if="field === 'description' && difference.old"
:source="formatFieldValue(difference.old)"
class="text-sm markdown-content"
/>
<span v-else>{{ formatFieldValue(difference.old) }}</span>
</td>
<td
class="border border-gray-300 px-3 py-2 text-green-700 font-medium break-words align-top"
>
<vue-markdown
v-if="field === 'description' && difference.new"
:source="formatFieldValue(difference.new)"
class="text-sm markdown-content"
/>
<span v-else>{{ formatFieldValue(difference.new) }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-else>The changes did not affect essential project data.</div>
<!-- New line that appears on hover -->
<div
class="absolute left-0 w-full text-left text-gray-500 text-sm hidden group-hover:flex cursor-pointer items-center"
@click.prevent="
markStarredProjectChangesAsReadStartingWith(
projectChange.plan.jwtId!,
)
"
>
<span class="inline-block w-8 h-px bg-gray-500 mr-2" />
Click to keep all above as unread changes
</div>
</li>
</ul>
@@ -152,6 +292,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import GiftedDialog from "../components/GiftedDialog.vue";
import QuickNav from "../components/QuickNav.vue";
@@ -162,20 +303,28 @@ import { Router } from "vue-router";
import {
OfferSummaryRecord,
OfferToPlanSummaryRecord,
PlanSummaryAndPreviousClaim,
PlanSummaryRecord,
} from "../interfaces/records";
import {
didInfo,
didInfoOrNobody,
displayAmount,
getNewOffersToUser,
getNewOffersToUserProjects,
getStarredProjectsWithChanges,
} from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import * as databaseUtil from "../db/databaseUtil";
import * as R from "ramda";
import { PlanActionClaim } from "../interfaces/claims";
import { GenericCredWrapper } from "@/interfaces";
@Component({
components: { GiftedDialog, QuickNav, EntityIcon },
components: { GiftedDialog, QuickNav, EntityIcon, VueMarkdown },
mixins: [PlatformServiceMixin],
})
export default class NewActivityView extends Vue {
@@ -189,13 +338,22 @@ export default class NewActivityView extends Vue {
apiServer = "";
lastAckedOfferToUserJwtId = "";
lastAckedOfferToUserProjectsJwtId = "";
lastAckedStarredPlanChangesJwtId = "";
newOffersToUser: Array<OfferSummaryRecord> = [];
newOffersToUserHitLimit = false;
newOffersToUserProjects: Array<OfferToPlanSummaryRecord> = [];
newOffersToUserProjectsHitLimit = false;
newStarredProjectChanges: Array<PlanSummaryAndPreviousClaim> = [];
newStarredProjectChangesHitLimit = false;
starredPlanHandleIds: Array<string> = [];
planDifferences: Record<
string,
Record<string, { old: unknown; new: unknown }>
> = {};
showOffersDetails = false;
showOffersToUserProjectsDetails = false;
showStarredProjectChangesDetails = false;
didInfo = didInfo;
displayAmount = displayAmount;
@@ -205,15 +363,16 @@ export default class NewActivityView extends Vue {
try {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.lastAckedOfferToUserJwtId = settings.lastAckedOfferToUserJwtId || "";
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || "";
this.lastAckedStarredPlanChangesJwtId =
settings.lastAckedStarredPlanChangesJwtId || "";
this.starredPlanHandleIds = databaseUtil.parseJsonField(
settings.starredPlanHandleIds,
[],
);
this.allContacts = await this.$getAllContacts();
@@ -237,6 +396,29 @@ export default class NewActivityView extends Vue {
this.newOffersToUserProjects = offersToUserProjectsData.data;
this.newOffersToUserProjectsHitLimit = offersToUserProjectsData.hitLimit;
// Load starred project changes if user has starred projects
if (this.starredPlanHandleIds.length > 0) {
try {
const starredProjectChangesData = await getStarredProjectsWithChanges(
this.axios,
this.apiServer,
this.activeDid,
this.starredPlanHandleIds,
this.lastAckedStarredPlanChangesJwtId,
);
this.newStarredProjectChanges = starredProjectChangesData.data;
this.newStarredProjectChangesHitLimit =
starredProjectChangesData.hitLimit;
// Analyze differences between current plans and previous claims
this.analyzePlanDifferences(this.newStarredProjectChanges);
} catch (error) {
logger.warn("Failed to load starred project changes:", error);
this.newStarredProjectChanges = [];
this.newStarredProjectChangesHitLimit = false;
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error("Error retrieving settings & contacts:", err);
@@ -249,14 +431,14 @@ export default class NewActivityView extends Vue {
async expandOffersToUserAndMarkRead() {
this.showOffersDetails = !this.showOffersDetails;
if (this.showOffersDetails && this.newOffersToUser.length > 0) {
await this.$updateSettings({
if (this.showOffersDetails) {
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[0].jwtId,
});
// note that we don't update this.lastAckedOfferToUserJwtId in case they
// later choose the last one to keep the offers as new
this.notify.info(
"The offers are marked as viewed. Click in the list to keep them as new.",
"The offers are marked read. Click in the list to keep them unread.",
TIMEOUTS.LONG,
);
}
@@ -268,12 +450,12 @@ export default class NewActivityView extends Vue {
);
if (index !== -1 && index < this.newOffersToUser.length - 1) {
// Set to the next offer's jwtId
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.newOffersToUser[index + 1].jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserJwtId: this.lastAckedOfferToUserJwtId,
});
}
@@ -286,18 +468,15 @@ export default class NewActivityView extends Vue {
async expandOffersToUserProjectsAndMarkRead() {
this.showOffersToUserProjectsDetails =
!this.showOffersToUserProjectsDetails;
if (
this.showOffersToUserProjectsDetails &&
this.newOffersToUserProjects.length > 0
) {
await this.$updateSettings({
if (this.showOffersToUserProjectsDetails) {
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[0].jwtId,
});
// note that we don't update this.lastAckedOfferToUserProjectsJwtId in case
// they later choose the last one to keep the offers as new
this.notify.info(
"The offers are marked as viewed. Click in the list to keep them as new.",
"The offers are now marked read. Click in the list to keep them unread.",
TIMEOUTS.LONG,
);
}
@@ -309,13 +488,13 @@ export default class NewActivityView extends Vue {
);
if (index !== -1 && index < this.newOffersToUserProjects.length - 1) {
// Set to the next offer's jwtId
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[index + 1].jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await this.$updateSettings({
await this.$saveUserSettings(this.activeDid, {
lastAckedOfferToUserProjectsJwtId:
this.lastAckedOfferToUserProjectsJwtId,
});
@@ -326,12 +505,381 @@ export default class NewActivityView extends Vue {
);
}
async handleSeeAllOffersToUser() {
this.$router.push("/recent-offers-to-user");
async expandStarredProjectChangesAndMarkRead() {
this.showStarredProjectChangesDetails =
!this.showStarredProjectChangesDetails;
if (
this.showStarredProjectChangesDetails &&
this.newStarredProjectChanges.length > 0
) {
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId:
this.newStarredProjectChanges[0].plan.jwtId,
});
this.notify.info(
"The starred project changes are now marked read. Click in the list to keep them unread.",
TIMEOUTS.LONG,
);
}
}
async handleSeeAllOffersToUserProjects() {
this.$router.push("/recent-offers-to-user-projects");
async markStarredProjectChangesAsReadStartingWith(jwtId: string) {
const index = this.newStarredProjectChanges.findIndex(
(change) => change.plan.jwtId === jwtId,
);
if (index !== -1 && index < this.newStarredProjectChanges.length - 1) {
// Set to the next change's jwtId
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId:
this.newStarredProjectChanges[index + 1].plan.jwtId,
});
} else {
// it's the last entry (or not found), so just keep it the same
await this.$saveUserSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId: this.lastAckedStarredPlanChangesJwtId,
});
}
this.notify.info(
"All starred project changes above that line are marked as unread.",
TIMEOUTS.STANDARD,
);
}
/**
* Analyzes differences between current plans and their previous claims
*
* Walks through a list of PlanSummaryAndPreviousClaim items and stores the
* differences between the previous claim and the current plan. This method
* extracts the claim from the wrappedClaimBefore object and compares relevant
* fields with the current plan.
*
* @param planChanges Array of PlanSummaryAndPreviousClaim objects to analyze
*/
analyzePlanDifferences(planChanges: Array<PlanSummaryAndPreviousClaim>) {
this.planDifferences = {};
for (const planChange of planChanges) {
const currentPlan: PlanSummaryRecord = planChange.plan;
const wrappedClaim: GenericCredWrapper<PlanActionClaim> =
planChange.wrappedClaimBefore;
// Extract the actual claim from the wrapped claim
let previousClaim: PlanActionClaim;
const embeddedClaim: PlanActionClaim = wrappedClaim.claim;
if (
embeddedClaim &&
typeof embeddedClaim === "object" &&
"credentialSubject" in embeddedClaim
) {
// It's a Verifiable Credential
previousClaim =
(embeddedClaim.credentialSubject as PlanActionClaim) || embeddedClaim;
} else {
// It's a direct claim
previousClaim = embeddedClaim;
}
if (!previousClaim || !currentPlan.handleId) {
continue;
}
const differences: Record<string, { old: unknown; new: unknown }> = {};
// Compare name
const normalizedOldName = this.normalizeValueForComparison(
previousClaim.name,
);
const normalizedNewName = this.normalizeValueForComparison(
currentPlan.name,
);
if (!R.equals(normalizedOldName, normalizedNewName)) {
differences.name = {
old: previousClaim.name,
new: currentPlan.name,
};
}
// Compare description
const normalizedOldDescription = this.normalizeValueForComparison(
previousClaim.description,
);
const normalizedNewDescription = this.normalizeValueForComparison(
currentPlan.description,
);
if (!R.equals(normalizedOldDescription, normalizedNewDescription)) {
differences.description = {
old: previousClaim.description,
new: currentPlan.description,
};
}
// Compare location (combine latitude and longitude into one row)
const oldLat = this.normalizeValueForComparison(
previousClaim.location?.geo?.latitude,
);
const oldLon = this.normalizeValueForComparison(
previousClaim.location?.geo?.longitude,
);
const newLat = this.normalizeValueForComparison(currentPlan.locLat);
const newLon = this.normalizeValueForComparison(currentPlan.locLon);
if (!R.equals(oldLat, newLat) || !R.equals(oldLon, newLon)) {
differences.location = {
old: this.formatLocationValue(oldLat, oldLon, true),
new: this.formatLocationValue(newLat, newLon, false),
};
}
// Compare agent (issuer)
const oldAgent = didInfoOrNobody(
previousClaim.agent?.identifier,
this.activeDid,
this.allMyDids,
this.allContacts,
);
const newAgent = didInfoOrNobody(
currentPlan.agentDid,
this.activeDid,
this.allMyDids,
this.allContacts,
);
const normalizedOldAgent = this.normalizeValueForComparison(oldAgent);
const normalizedNewAgent = this.normalizeValueForComparison(newAgent);
if (!R.equals(normalizedOldAgent, normalizedNewAgent)) {
differences.agent = {
old: oldAgent,
new: newAgent,
};
}
// Compare start time
const oldStartTime = previousClaim.startTime;
const newStartTime = currentPlan.startTime;
const normalizedOldStartTime =
this.normalizeDateForComparison(oldStartTime);
const normalizedNewStartTime =
this.normalizeDateForComparison(newStartTime);
if (!R.equals(normalizedOldStartTime, normalizedNewStartTime)) {
differences.startTime = {
old: oldStartTime,
new: newStartTime,
};
}
// Compare end time
const oldEndTime = previousClaim.endTime;
const newEndTime = currentPlan.endTime;
const normalizedOldEndTime = this.normalizeDateForComparison(oldEndTime);
const normalizedNewEndTime = this.normalizeDateForComparison(newEndTime);
if (!R.equals(normalizedOldEndTime, normalizedNewEndTime)) {
differences.endTime = {
old: oldEndTime,
new: newEndTime,
};
}
// Compare image
const oldImage = previousClaim.image;
const newImage = currentPlan.image;
const normalizedOldImage = this.normalizeValueForComparison(oldImage);
const normalizedNewImage = this.normalizeValueForComparison(newImage);
if (!R.equals(normalizedOldImage, normalizedNewImage)) {
differences.image = {
old: oldImage,
new: newImage,
};
}
// Compare url
const oldUrl = previousClaim.url;
const newUrl = currentPlan.url;
const normalizedOldUrl = this.normalizeValueForComparison(oldUrl);
const normalizedNewUrl = this.normalizeValueForComparison(newUrl);
if (!R.equals(normalizedOldUrl, normalizedNewUrl)) {
differences.url = {
old: oldUrl,
new: newUrl,
};
}
// Store differences if any were found
if (!R.isEmpty(differences)) {
this.planDifferences[currentPlan.handleId] = differences;
logger.debug(
"[NewActivityView] Plan differences found for",
currentPlan.handleId,
differences,
);
}
}
logger.debug(
"[NewActivityView] Analyzed",
planChanges.length,
"plan changes, found differences in",
Object.keys(this.planDifferences).length,
"plans",
);
}
/**
* Normalizes values for comparison - treats null, undefined, and empty string as equivalent
*
* @param value The value to normalize
* @returns The normalized value (null for null/undefined/empty, otherwise the original value)
*/
normalizeValueForComparison<T>(value: T | null | undefined): T | null {
if (value === null || value === undefined || value === "") {
return null;
}
return value;
}
/**
* Normalizes date values for comparison by converting strings to Date objects
* Returns null for null/undefined/empty values, Date objects for valid date strings
*/
normalizeDateForComparison(value: unknown): Date | null {
if (value === null || value === undefined || value === "") {
return null;
}
if (typeof value === "string") {
const date = new Date(value);
// Check if the date is valid
return isNaN(date.getTime()) ? null : date;
}
if (value instanceof Date) {
return isNaN(value.getTime()) ? null : value;
}
return null;
}
/**
* Gets the differences for a specific plan by handle ID
*
* @param handleId The handle ID of the plan to get differences for
* @returns The differences object or null if no differences found
*/
getPlanDifferences(
handleId: string,
): Record<string, { old: unknown; new: unknown }> | null {
return this.planDifferences[handleId] || null;
}
/**
* Formats a field value for display in the UI
*
* @param value The value to format
* @returns A human-readable string representation
*/
formatFieldValue(value: unknown): string {
if (value === null || value === undefined) {
return "Not set";
}
if (typeof value === "string") {
const stringValue = value || "Empty";
// Check if it's a date/time string
if (this.isDateTimeString(stringValue)) {
return this.formatDateTime(stringValue);
}
// Check if it's a URL
if (this.isUrl(stringValue)) {
return stringValue; // Keep URLs as-is for now
}
return stringValue;
}
if (typeof value === "number") {
return value.toString();
}
if (typeof value === "boolean") {
return value ? "Yes" : "No";
}
// For complex objects, stringify
const stringified = JSON.stringify(value);
return stringified;
}
/**
* Checks if a string appears to be a date/time string
*/
isDateTimeString(value: string): boolean {
if (!value) return false;
// Check for ISO 8601 format or other common date formats
const dateRegex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2})?(\.\d{3})?Z?$/;
return dateRegex.test(value) || !isNaN(Date.parse(value));
}
/**
* Checks if a string is a URL
*/
isUrl(value: string): boolean {
if (!value) return false;
try {
new URL(value);
return true;
} catch {
return false;
}
}
/**
* Formats a date/time string for display
*/
formatDateTime(value: string): string {
try {
const date = new Date(value);
return date.toLocaleString();
} catch {
return value; // Return original if parsing fails
}
}
/**
* Gets a human-readable field name for display
*
* @param fieldName The internal field name
* @returns A formatted field name for display
*/
getDisplayFieldName(fieldName: string): string {
const fieldNameMap: Record<string, string> = {
name: "Name",
description: "Description",
location: "Location",
agent: "Agent",
startTime: "Start Time",
endTime: "End Time",
image: "Image",
url: "URL",
};
return fieldNameMap[fieldName] || fieldName;
}
/**
* Formats location values for display
*
* @param latitude The latitude value
* @param longitude The longitude value
* @param isOldValue Whether this is the old value (true) or new value (false)
* @returns A formatted location string
*/
formatLocationValue(
latitude: number | undefined | null,
longitude: number | undefined | null,
isOldValue: boolean = false,
): string {
if (latitude == null && longitude == null) {
return "Not set";
}
// If there's any location data, show generic labels instead of coordinates
if (isOldValue) {
return "A Location";
} else {
return "New Location";
}
}
}
</script>

View File

@@ -110,22 +110,10 @@ export default class NewEditAccountView extends Vue {
* @async
*/
async onClickSaveChanges() {
// Get activeDid from new active_identity table (ActiveDid migration)
const activeIdentity = await this.$getActiveIdentity();
const activeDid = activeIdentity.activeDid;
if (activeDid) {
// Save to user-specific settings for the current identity
await this.$saveUserSettings(activeDid, {
firstName: this.givenName,
});
} else {
// Fallback to master settings if no active DID
await this.$saveSettings({
firstName: this.givenName,
});
}
await this.$updateSettings({
firstName: this.givenName,
lastName: "", // deprecated, pre v 0.1.3
});
this.$router.back();
}

View File

@@ -378,12 +378,7 @@ export default class NewEditProjectView extends Vue {
this.numAccounts = await retrieveAccountCount();
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;

View File

@@ -433,12 +433,7 @@ export default class OfferDetailsView extends Vue {
private async loadAccountSettings() {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer ?? "";
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid ?? "";
this.activeDid = settings.activeDid ?? "";
this.showGeneralAdvanced = settings.showGeneralAdvanced ?? false;
}

View File

@@ -270,7 +270,7 @@
<script lang="ts">
import { Component, Vue } from "vue-facing-decorator";
import { copyToClipboard } from "../services/ClipboardService";
import { useClipboard } from "@vueuse/core";
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
import QuickNav from "../components/QuickNav.vue";
@@ -676,17 +676,12 @@ export default class OnboardMeetingView extends Vue {
this.notify.error(message, TIMEOUTS.LONG);
}
async copyMembersLinkToClipboard() {
try {
await copyToClipboard(this.onboardMeetingMembersLink());
this.notify.info(NOTIFY_MEETING_LINK_COPIED.message, TIMEOUTS.LONG);
} catch (error) {
this.$logAndConsole(
`Error copying meeting link to clipboard: ${error}`,
true,
);
this.notify.error("Failed to copy meeting link to clipboard.");
}
copyMembersLinkToClipboard() {
useClipboard()
.copy(this.onboardMeetingMembersLink())
.then(() => {
this.notify.info(NOTIFY_MEETING_LINK_COPIED.message, TIMEOUTS.LONG);
});
}
}
</script>

View File

@@ -27,10 +27,18 @@
>
<font-awesome icon="pen" class="text-sm text-blue-500 ml-2 mb-1" />
</button>
<button title="Copy Link to Project" @click="onCopyLinkClick()">
<button
:title="
isStarred
? 'Remove from starred projects'
: 'Add to starred projects'
"
@click="toggleStar()"
>
<font-awesome
icon="link"
class="text-sm text-slate-500 ml-2 mb-1"
:icon="isStarred ? 'star' : ['far', 'star']"
:class="isStarred ? 'text-yellow-500' : 'text-slate-500'"
class="text-sm ml-2 mb-1"
/>
</button>
</h2>
@@ -58,13 +66,13 @@
icon="user"
class="fa-fw text-slate-400"
></font-awesome>
<span class="truncate inline-block max-w-[calc(100%-2rem)]">
<span class="truncate max-w-[calc(100%-2rem)] ml-1">
{{ issuerInfoObject?.displayName }}
</span>
<span
v-if="!serverUtil.isHiddenDid(issuer)"
class="inline-flex items-center"
class="inline-flex items-center ml-1"
>
<router-link
:to="{
@@ -140,18 +148,22 @@
<div class="text-sm text-slate-500">
<div v-if="!expanded">
{{ truncatedDesc }}
<vue-markdown
:source="truncatedDesc"
class="mb-4 markdown-content"
/>
<a
v-if="description.length >= truncateLength"
class="uppercase text-xs font-semibold text-slate-700"
class="mt-4 uppercase text-xs font-semibold text-blue-700 cursor-pointer"
@click="expandText"
>... Read More</a
>
</div>
<div v-else>
{{ description }}
<vue-markdown :source="description" class="mb-4 markdown-content" />
<a
class="uppercase text-xs font-semibold text-slate-700"
v-if="description.length >= truncateLength"
class="mt-4 uppercase text-xs font-semibold text-blue-700 cursor-pointer"
@click="collapseText"
>- Read Less</a
>
@@ -592,7 +604,10 @@
<script lang="ts">
import { AxiosError } from "axios";
import { Component, Vue } from "vue-facing-decorator";
import VueMarkdown from "vue-markdown-render";
import { Router } from "vue-router";
import { useClipboard } from "@vueuse/core";
import {
GenericVerifiableCredential,
GenericCredWrapper,
@@ -603,25 +618,24 @@ import {
PlanSummaryRecord,
} from "../interfaces";
import GiftedDialog from "../components/GiftedDialog.vue";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import OfferDialog from "../components/OfferDialog.vue";
import TopMessage from "../components/TopMessage.vue";
import QuickNav from "../components/QuickNav.vue";
import EntityIcon from "../components/EntityIcon.vue";
import ProjectIcon from "../components/ProjectIcon.vue";
import { NotificationIface } from "../constants/app";
// Removed legacy logging import - migrated to PlatformServiceMixin
import { APP_SERVER, NotificationIface } from "../constants/app";
import { UNNAMED_PROJECT } from "../constants/entities";
import { NOTIFY_CONFIRM_CLAIM } from "../constants/notifications";
import * as databaseUtil from "../db/databaseUtil";
import { Contact } from "../db/tables/contacts";
import * as libsUtil from "../libs/util";
import * as serverUtil from "../libs/endorserServer";
import { retrieveAccountDids } from "../libs/util";
import HiddenDidDialog from "../components/HiddenDidDialog.vue";
import { logger } from "../utils/logger";
import { copyToClipboard } from "../services/ClipboardService";
import { logger } from "@/utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { NOTIFY_CONFIRM_CLAIM } from "@/constants/notifications";
import { APP_SERVER } from "@/constants/app";
import { UNNAMED_PROJECT } from "@/constants/entities";
/**
* Project View Component
* @author Matthew Raymer
@@ -663,6 +677,7 @@ import { UNNAMED_PROJECT } from "@/constants/entities";
ProjectIcon,
QuickNav,
TopMessage,
VueMarkdown,
},
mixins: [PlatformServiceMixin],
})
@@ -718,6 +733,8 @@ export default class ProjectViewView extends Vue {
givesProvidedByHitLimit = false;
givesTotalsByUnit: Array<{ unit: string; amount: number }> = [];
imageUrl = "";
/** Whether this project is starred by the user */
isStarred = false;
/** Project issuer DID */
issuer = "";
/** Cached issuer information */
@@ -756,7 +773,7 @@ export default class ProjectViewView extends Vue {
totalsExpanded = false;
truncatedDesc = "";
/** Truncation length */
truncateLength = 40;
truncateLength = 200;
// Utility References
libsUtil = libsUtil;
@@ -780,12 +797,7 @@ export default class ProjectViewView extends Vue {
this.notify = createNotifyHelpers(this.$notify);
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.allContacts = await this.$getAllContacts();
this.isRegistered = !!settings.isRegistered;
@@ -810,6 +822,12 @@ export default class ProjectViewView extends Vue {
}
this.loadProject(this.projectId, this.activeDid);
this.loadTotals();
// Check if this project is starred when settings are loaded
if (this.projectId && settings.starredPlanHandleIds) {
const starredIds = settings.starredPlanHandleIds || [];
this.isStarred = starredIds.includes(this.projectId);
}
}
/**
@@ -822,7 +840,7 @@ export default class ProjectViewView extends Vue {
});
}
async onCopyLinkClick() {
onCopyLinkClick() {
const shortestProjectId = this.projectId.startsWith(
serverUtil.ENDORSER_CH_HANDLE_PREFIX,
)
@@ -830,13 +848,11 @@ export default class ProjectViewView extends Vue {
: this.projectId;
// Use production URL for sharing to avoid localhost issues in development
const deepLink = `${APP_SERVER}/deep-link/project/${shortestProjectId}`;
try {
await copyToClipboard(deepLink);
this.notify.copied("link to this project", TIMEOUTS.SHORT);
} catch (error) {
this.$logAndConsole(`Error copying project link: ${error}`, true);
this.notify.error("Failed to copy project link.");
}
useClipboard()
.copy(deepLink)
.then(() => {
this.notify.copied("link to this project", TIMEOUTS.SHORT);
});
}
// Isn't there a better way to make this available to the template?
@@ -887,7 +903,7 @@ export default class ProjectViewView extends Vue {
);
this.issuerVisibleToDids = resp.data.issuerVisibleToDids || [];
this.name = resp.data.claim?.name || "(no name)";
this.description = resp.data.claim?.description || "(no description)";
this.description = resp.data.claim?.description || "";
this.truncatedDesc = this.description.slice(0, this.truncateLength);
this.latitude = resp.data.claim?.location?.geo?.latitude || 0;
this.longitude = resp.data.claim?.location?.geo?.longitude || 0;
@@ -1477,5 +1493,72 @@ export default class ProjectViewView extends Vue {
this.givesTotalsByUnit.find((total) => total.unit === "HUR")?.amount || 0
);
}
/**
* Toggle the starred status of the current project
*/
async toggleStar() {
if (!this.projectId) return;
try {
if (!this.isStarred) {
// Add to starred projects
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
const starredIds = settings.starredPlanHandleIds || [];
if (!starredIds.includes(this.projectId)) {
const newStarredIds = [...starredIds, this.projectId];
const newIdsParam = JSON.stringify(newStarredIds);
const result = await databaseUtil.updateDidSpecificSettings(
this.activeDid,
// @ts-expect-error until we use SettingsWithJsonString properly
{ starredPlanHandleIds: newIdsParam },
);
if (result) {
this.isStarred = true;
} else {
// eslint-disable-next-line no-console
console.log(
"Still getting a bad result from SQL update to star a project.",
);
}
}
if (!settings.lastAckedStarredPlanChangesJwtId) {
await databaseUtil.updateDidSpecificSettings(this.activeDid, {
lastAckedStarredPlanChangesJwtId: settings.lastViewedClaimId,
});
}
} else {
// Remove from starred projects
const settings = await databaseUtil.retrieveSettingsForActiveAccount();
const starredIds = settings.starredPlanHandleIds || [];
const updatedIds = starredIds.filter((id) => id !== this.projectId);
const newIdsParam = JSON.stringify(updatedIds);
const result = await databaseUtil.updateDidSpecificSettings(
this.activeDid,
// @ts-expect-error until we use SettingsWithJsonString properly
{ starredPlanHandleIds: newIdsParam },
);
if (result) {
this.isStarred = false;
} else {
// eslint-disable-next-line no-console
logger.error("Got a bad result from SQL update to unstar a project.");
}
}
} catch (error) {
logger.error("Error toggling star status:", error);
this.$notify(
{
group: "alert",
type: "danger",
title: "Error",
text: "Failed to update starred status. Please try again.",
},
3000,
);
}
}
}
</script>

View File

@@ -391,12 +391,7 @@ export default class ProjectsView extends Vue {
*/
private async initializeUserSettings() {
const settings = await this.$accountSettings();
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.apiServer = settings.apiServer || "";
this.isRegistered = !!settings.isRegistered;
this.givenName = settings.firstName || "";

View File

@@ -99,7 +99,7 @@ export default class QuickActionBvcBeginView extends Vue {
$router!: Router;
// Notification helper system
private notify!: ReturnType<typeof createNotifyHelpers>;
private notify = createNotifyHelpers(this.$notify);
attended = true;
gaveTime = true;
@@ -111,9 +111,6 @@ export default class QuickActionBvcBeginView extends Vue {
* Uses America/Denver timezone for Bountiful location
*/
async mounted() {
// Initialize notification helpers
this.notify = createNotifyHelpers(this.$notify);
logger.debug(
"[QuickActionBvcBeginView] Mounted - calculating meeting date",
);
@@ -153,11 +150,7 @@ export default class QuickActionBvcBeginView extends Vue {
// Get account settings using PlatformServiceMixin
const settings = await this.$accountSettings();
// Get activeDid from new active_identity table (ActiveDid migration)
const activeIdentity = await this.$getActiveIdentity();
const activeDid = activeIdentity.activeDid || "";
const activeDid = settings.activeDid || "";
const apiServer = settings.apiServer || "";
if (!activeDid || !apiServer) {

View File

@@ -69,17 +69,10 @@
<div v-if="claimCountWithHidden > 0" class="border-b border-slate-300 pb-2">
<span>
{{ claimCountWithHiddenText }}
If you don't see expected info above for someone, ask them to check that
their activity is visible to you (
<font-awesome icon="arrow-up" class="fa-fw" />
<font-awesome icon="eye" class="fa-fw" />
) on
<a
class="text-blue-500 underline cursor-pointer"
@click="copyContactsLinkToClipboard"
>
this page </a
>.
so if you expected but do not see details from someone then ask them to
check that their activity is visible to you on their Contacts
<font-awesome icon="users" class="text-slate-500" />
page.
</span>
</div>
<div v-if="claimCountByUser > 0" class="border-b border-slate-300 pb-2">
@@ -127,11 +120,10 @@ import { DateTime } from "luxon";
import * as R from "ramda";
import { Component, Vue } from "vue-facing-decorator";
import { Router } from "vue-router";
import { copyToClipboard } from "../services/ClipboardService";
import QuickNav from "../components/QuickNav.vue";
import TopMessage from "../components/TopMessage.vue";
import { NotificationIface, APP_SERVER } from "../constants/app";
import { NotificationIface } from "../constants/app";
import { Contact } from "../db/tables/contacts";
import {
GenericCredWrapper,
@@ -156,7 +148,6 @@ import {
NOTIFY_ALL_CONFIRMATIONS_ERROR,
NOTIFY_GIVE_SEND_ERROR,
NOTIFY_CLAIMS_SEND_ERROR,
NOTIFY_COPIED_TO_CLIPBOARD,
createConfirmationSuccessMessage,
createCombinedSuccessMessage,
} from "@/constants/notifications";
@@ -204,8 +195,8 @@ export default class QuickActionBvcEndView extends Vue {
get claimCountWithHiddenText() {
if (this.claimCountWithHidden === 0) return "";
return this.claimCountWithHidden === 1
? "There is 1 other claim with hidden details."
: `There are ${this.claimCountWithHidden} other claims with hidden details.`;
? "There is 1 other claim with hidden details,"
: `There are ${this.claimCountWithHidden} other claims with hidden details,`;
}
get claimCountByUserText() {
@@ -234,13 +225,9 @@ export default class QuickActionBvcEndView extends Vue {
// Initialize notification helper
this.notify = createNotifyHelpers(this.$notify);
const settings = await this.$accountSettings();
const settings = await this.$settings();
this.apiServer = settings.apiServer || "";
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.allContacts = await this.$contacts();
@@ -309,23 +296,6 @@ export default class QuickActionBvcEndView extends Vue {
(this.$router as Router).push(route);
}
async copyContactsLinkToClipboard() {
const deepLinkUrl = `${APP_SERVER}/deep-link/did/${this.activeDid}`;
try {
await copyToClipboard(deepLinkUrl);
this.notify.success(
NOTIFY_COPIED_TO_CLIPBOARD.message("Your info link"),
TIMEOUTS.SHORT,
);
} catch (error) {
logger.error("Failed to copy to clipboard:", error);
this.notify.error(
"Failed to copy link to clipboard. Please try again.",
TIMEOUTS.SHORT,
);
}
}
async record() {
try {
if (this.claimsToConfirmSelected.length > 0) {

View File

@@ -32,20 +32,20 @@
</div>
<InfiniteScroll @reached-bottom="loadMoreOffersToUserProjects">
<ul data-testId="listRecentOffersToUserProjects">
<ul
data-testId="listRecentOffersToUserProjects"
class="border-t border-slate-300"
>
<li
v-for="offer in newOffersToUserProjects"
:key="offer.jwtId"
class="mt-4 relative group"
>
<!-- Last viewed separator -->
<div
v-if="offer.jwtId == lastAckedOfferToUserProjectsJwtId"
class="border-b border-dashed border-slate-300 text-orange-400 mt-4 mb-6 font-bold text-sm"
class="border-b border-slate-300 text-orange-400 pb-2 mb-2 font-bold text-sm"
>
<span class="block w-fit mx-auto -mb-2.5 bg-white px-2">
You've already seen all the following
</span>
You've already seen all the following
</div>
<span>{{
@@ -124,12 +124,7 @@ export default class RecentOffersToUserView extends Vue {
try {
const settings = await this.$accountSettings();
this.apiServer = settings.apiServer || "";
// Get activeDid from active_identity table (single source of truth)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const activeIdentity = await (this as any).$getActiveIdentity();
this.activeDid = activeIdentity.activeDid || "";
this.activeDid = settings.activeDid || "";
this.lastAckedOfferToUserProjectsJwtId =
settings.lastAckedOfferToUserProjectsJwtId || "";
@@ -147,14 +142,6 @@ export default class RecentOffersToUserView extends Vue {
this.newOffersToUserProjects = offersToUserProjectsData.data;
this.newOffersToUserProjectsAtEnd = !offersToUserProjectsData.hitLimit;
// Mark offers as read after data is loaded
if (this.newOffersToUserProjects.length > 0) {
await this.$updateSettings({
lastAckedOfferToUserProjectsJwtId:
this.newOffersToUserProjects[0].jwtId,
});
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
logger.error("Error retrieving settings & contacts:", err);

Some files were not shown because too many files have changed in this diff Show More