Browse Source

Merge branch 'master' into registration-prompt-parity

pull/197/head
Jose Olarte 3 1 week ago
parent
commit
631aa468e6
  1. 181
      doc/seed-phrase-reminder-implementation.md
  2. 30
      src/components/DataExportSection.vue
  3. 10
      src/components/GiftedDialog.vue
  4. 9
      src/components/OfferDialog.vue
  5. 6
      src/db-sql/migration.ts
  6. 1
      src/db/tables/settings.ts
  7. 22
      src/libs/endorserServer.ts
  8. 35
      src/libs/util.ts
  9. 64
      src/services/QRScanner/WebInlineQRScanner.ts
  10. 90
      src/utils/seedPhraseReminder.ts
  11. 9
      src/views/AccountViewView.vue
  12. 9
      src/views/ClaimAddRawView.vue
  13. 86
      src/views/ClaimView.vue
  14. 71
      src/views/ConfirmGiftView.vue
  15. 10
      src/views/ContactQRScanFullView.vue
  16. 10
      src/views/ContactQRScanShowView.vue
  17. 10
      src/views/GiftedDetailsView.vue
  18. 17
      src/views/SeedBackupView.vue

181
doc/seed-phrase-reminder-implementation.md

@ -0,0 +1,181 @@
# 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.

30
src/components/DataExportSection.vue

@ -16,6 +16,12 @@ messages * - Conditional UI based on platform capabilities * * @component *
:to="{ name: 'seed-backup' }" :to="{ name: 'seed-backup' }"
:class="backupButtonClasses" :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 Backup Identifier Seed
</router-link> </router-link>
@ -98,6 +104,12 @@ export default class DataExportSection extends Vue {
*/ */
isExporting = false; 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 * Notification helper for consistent notification patterns
* Created as a getter to ensure $notify is available when called * Created as a getter to ensure $notify is available when called
@ -129,7 +141,7 @@ export default class DataExportSection extends Vue {
* CSS classes for the backup button (router link) * CSS classes for the backup button (router link)
*/ */
get backupButtonClasses(): string { get backupButtonClasses(): string {
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"; 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";
} }
/** /**
@ -218,6 +230,22 @@ export default class DataExportSection extends Vue {
created() { created() {
this.notify = createNotifyHelpers(this.$notify); 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> </script>

10
src/components/GiftedDialog.vue

@ -82,6 +82,7 @@ import GiftDetailsStep from "../components/GiftDetailsStep.vue";
import { PlanData } from "../interfaces/records"; import { PlanData } from "../interfaces/records";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS, NotifyFunction } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { import {
NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT, NOTIFY_GIFT_ERROR_NEGATIVE_AMOUNT,
NOTIFY_GIFT_ERROR_NO_DESCRIPTION, NOTIFY_GIFT_ERROR_NO_DESCRIPTION,
@ -411,6 +412,15 @@ export default class GiftedDialog extends Vue {
); );
} else { } else {
this.safeNotify.success("That gift was recorded.", TIMEOUTS.VERY_LONG); 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) { if (this.callbackOnSuccess) {
this.callbackOnSuccess(amount); this.callbackOnSuccess(amount);
} }

9
src/components/OfferDialog.vue

@ -64,6 +64,7 @@ import * as libsUtil from "../libs/util";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { import {
NOTIFY_OFFER_SETTINGS_ERROR, NOTIFY_OFFER_SETTINGS_ERROR,
NOTIFY_OFFER_RECORDING, NOTIFY_OFFER_RECORDING,
@ -299,6 +300,14 @@ export default class OfferDialog extends Vue {
); );
} else { } else {
this.notify.success(NOTIFY_OFFER_SUCCESS.message, TIMEOUTS.VERY_LONG); 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) { } catch (error: any) {

6
src/db-sql/migration.ts

@ -124,6 +124,12 @@ const MIGRATIONS = [
ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE; ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE;
`, `,
}, },
{
name: "003_add_hasBackedUpSeed_to_settings",
sql: `
ALTER TABLE settings ADD COLUMN hasBackedUpSeed BOOLEAN DEFAULT FALSE;
`,
},
]; ];
/** /**

1
src/db/tables/settings.ts

@ -29,6 +29,7 @@ export type Settings = {
finishedOnboarding?: boolean; // the user has completed the onboarding process finishedOnboarding?: boolean; // the user has completed the onboarding process
firstName?: string; // user's full name, may be null if unwanted for a particular account 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; hideRegisterPromptOnNewContact?: boolean;
isRegistered?: boolean; isRegistered?: boolean;
// imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable // imageServer?: string; // if we want to allow modification then we should make image functionality optional -- or at least customizable

22
src/libs/endorserServer.ts

@ -1313,6 +1313,28 @@ export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); : 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 return readable summary of claim, or something generic

35
src/libs/util.ts

@ -160,6 +160,41 @@ export const isGiveAction = (
return isGiveClaimType(veriClaim.claimType); return isGiveClaimType(veriClaim.claimType);
}; };
export interface OfferFulfillment {
offerHandleId: string;
offerType: string;
}
/**
* Extract offer fulfillment information from the fulfills field
* Handles both array and single object cases
*/
export const extractOfferFulfillment = (fulfills: any): 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) => { export const shortDid = (did: string) => {
if (did.startsWith("did:peer:")) { if (did.startsWith("did:peer:")) {
return ( return (

64
src/services/QRScanner/WebInlineQRScanner.ts

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

90
src/utils/seedPhraseReminder.ts

@ -0,0 +1,90 @@
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;
}

9
src/views/AccountViewView.vue

@ -811,6 +811,7 @@ import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "../utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "../utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView"; import { ACCOUNT_VIEW_CONSTANTS } from "@/constants/accountView";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { import {
AccountSettings, AccountSettings,
isApiError, isApiError,
@ -1695,6 +1696,14 @@ export default class AccountViewView extends Vue {
); );
if (success) { if (success) {
this.notify.success(ACCOUNT_VIEW_CONSTANTS.SUCCESS.PROFILE_SAVED); 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 { } else {
this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR); this.notify.error(ACCOUNT_VIEW_CONSTANTS.ERRORS.PROFILE_SAVE_ERROR);
} }

9
src/views/ClaimAddRawView.vue

@ -41,6 +41,7 @@ import { Router, RouteLocationNormalizedLoaded } from "vue-router";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
// Type guard for API responses // Type guard for API responses
function isApiResponse(response: unknown): response is AxiosResponse { function isApiResponse(response: unknown): response is AxiosResponse {
@ -223,6 +224,14 @@ export default class ClaimAddRawView extends Vue {
); );
if (result.success) { if (result.success) {
this.notify.success("Claim submitted.", TIMEOUTS.LONG); 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 { } else {
logger.error("Got error submitting the claim:", result); logger.error("Got error submitting the claim:", result);
this.notify.error( this.notify.error(

86
src/views/ClaimView.vue

@ -24,7 +24,9 @@
<div class="flex columns-3"> <div class="flex columns-3">
<h2 class="text-md font-bold w-full"> <h2 class="text-md font-bold w-full">
{{ {{
capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType || "") serverUtil.capitalizeAndInsertSpacesBeforeCaps(
veriClaim.claimType || "",
)
}} }}
<button <button
v-if="canEditClaim" v-if="canEditClaim"
@ -106,9 +108,9 @@
</div> </div>
<!-- Fullfills Links --> <!-- Fullfills Links -->
<div class="mt-4 empty:hidden">
<!-- fullfills links for a give --> <!-- fullfills links for a give -->
<div v-if="detailsForGive?.fulfillsPlanHandleId" class="mt-4"> <div v-if="detailsForGive?.fulfillsPlanHandleId">
<router-link <router-link
:to=" :to="
'/project/' + '/project/' +
@ -116,30 +118,35 @@
" "
class="text-blue-500 mt-2" class="text-blue-500 mt-2"
> >
Fulfills a bigger plan... This fulfills a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link> </router-link>
</div> </div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div <!-- Show offer fulfillment if this give fulfills an offer -->
v-if=" <div v-if="detailsForGiveOfferFulfillment?.offerHandleId">
detailsForGive?.fulfillsType &&
detailsForGive?.fulfillsType !== 'PlanAction' &&
detailsForGive?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path --> <!-- router-link to /claim/ only changes URL path -->
<a <a
class="text-blue-500 mt-4 cursor-pointer" class="text-blue-500 mt-4 cursor-pointer"
@click=" @click="
showDifferentClaimPage(detailsForGive?.fulfillsHandleId) showDifferentClaimPage(
detailsForGiveOfferFulfillment.offerHandleId,
)
" "
> >
Fulfills This fulfills
{{ {{
capitalizeAndInsertSpacesBeforeCaps( serverUtil.capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
detailsForGive.fulfillsType, detailsForGiveOfferFulfillment.offerType || "Offer",
) )
}}... }}
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a> </a>
</div> </div>
@ -152,12 +159,16 @@
" "
class="text-blue-500 mt-4" class="text-blue-500 mt-4"
> >
Offered to a bigger plan... Offered to a bigger plan
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</router-link> </router-link>
</div> </div>
<!-- Providers --> <!-- Providers -->
<div v-if="providersForGive?.length > 0" class="mt-4"> <div v-if="providersForGive?.length > 0">
<span>Other assistance provided by:</span> <span>Other assistance provided by:</span>
<ul class="ml-4"> <ul class="ml-4">
<li <li
@ -171,7 +182,11 @@
class="text-blue-500 mt-4 cursor-pointer" class="text-blue-500 mt-4 cursor-pointer"
@click="handleProviderClick(provider)" @click="handleProviderClick(provider)"
> >
an activity... an activity
<font-awesome
icon="arrow-up-right-from-square"
class="fa-fw"
/>
</a> </a>
</div> </div>
</div> </div>
@ -182,6 +197,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="mt-2"> <div class="mt-2">
<font-awesome icon="comment" class="text-slate-400" /> <font-awesome icon="comment" class="text-slate-400" />
{{ issuerName }} posted that. {{ issuerName }} posted that.
@ -556,6 +572,17 @@ export default class ClaimView extends Vue {
fulfillsPlanHandleId?: string; fulfillsPlanHandleId?: string;
fulfillsType?: string; fulfillsType?: string;
fulfillsHandleId?: 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; } | null = null;
detailsForOffer: { fulfillsPlanHandleId?: string } | null = null; detailsForOffer: { fulfillsPlanHandleId?: string } | null = null;
// Project information for fulfillsPlanHandleId // Project information for fulfillsPlanHandleId
@ -689,6 +716,7 @@ export default class ClaimView extends Vue {
this.confsVisibleToIdList = []; this.confsVisibleToIdList = [];
this.detailsForGive = null; this.detailsForGive = null;
this.detailsForOffer = null; this.detailsForOffer = null;
this.detailsForGiveOfferFulfillment = null;
this.projectInfo = null; this.projectInfo = null;
this.fullClaim = null; this.fullClaim = null;
this.fullClaimDump = ""; this.fullClaimDump = "";
@ -701,6 +729,15 @@ export default class ClaimView extends Vue {
this.veriClaimDidsVisible = {}; this.veriClaimDidsVisible = {};
} }
/**
* Extract offer fulfillment information from the fulfills array
*/
extractOfferFulfillment() {
this.detailsForGiveOfferFulfillment = libsUtil.extractOfferFulfillment(
this.detailsForGive?.fullClaim?.fulfills
);
}
// ================================================= // =================================================
// UTILITY METHODS // UTILITY METHODS
// ================================================= // =================================================
@ -758,13 +795,6 @@ export default class ClaimView extends Vue {
this.canShare = !!navigator.share; 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() { totalConfirmers() {
return ( return (
this.numConfsNotVisible + this.numConfsNotVisible +
@ -821,6 +851,8 @@ export default class ClaimView extends Vue {
}); });
if (giveResp.status === 200 && giveResp.data.data?.length > 0) { if (giveResp.status === 200 && giveResp.data.data?.length > 0) {
this.detailsForGive = giveResp.data.data[0]; this.detailsForGive = giveResp.data.data[0];
// Extract offer information from the fulfills array
this.extractOfferFulfillment();
} else { } else {
await this.$logError( await this.$logError(
"Error getting detailed give info: " + JSON.stringify(giveResp), "Error getting detailed give info: " + JSON.stringify(giveResp),

71
src/views/ConfirmGiftView.vue

@ -96,13 +96,15 @@
</div> </div>
<!-- Fullfills Links --> <!-- Fullfills Links -->
<div class="mt-4">
<!-- fullfills links for a give --> <!-- fullfills links for a give -->
<div v-if="giveDetails?.fulfillsPlanHandleId" class="mt-2"> <div v-if="giveDetails?.fulfillsPlanHandleId">
<router-link <router-link
:to=" :to="
'/project/' + '/project/' +
encodeURIComponent(giveDetails?.fulfillsPlanHandleId || '') encodeURIComponent(
giveDetails?.fulfillsPlanHandleId || '',
)
" "
class="text-blue-500 mt-2 cursor-pointer" class="text-blue-500 mt-2 cursor-pointer"
> >
@ -113,26 +115,23 @@
/> />
</router-link> </router-link>
</div> </div>
<!-- if there's another, it's probably fulfilling an offer, too -->
<div <!-- Show offer fulfillment if this give fulfills an offer -->
v-if=" <div v-if="giveDetailsOfferFulfillment?.offerHandleId">
giveDetails?.fulfillsType &&
giveDetails?.fulfillsType !== 'PlanAction' &&
giveDetails?.fulfillsHandleId
"
>
<!-- router-link to /claim/ only changes URL path --> <!-- router-link to /claim/ only changes URL path -->
<router-link <router-link
:to=" :to="
'/claim/' + '/claim/' +
encodeURIComponent(giveDetails?.fulfillsHandleId || '') encodeURIComponent(
giveDetailsOfferFulfillment.offerHandleId || '',
)
" "
class="text-blue-500 mt-2 cursor-pointer" class="text-blue-500 mt-2 cursor-pointer"
> >
This fulfills This fulfills
{{ {{
capitalizeAndInsertSpacesBeforeCapsWithAPrefix( serverUtil.capitalizeAndInsertSpacesBeforeCapsWithAPrefix(
giveDetails?.fulfillsType || "", giveDetailsOfferFulfillment.offerType || "Offer",
) )
}} }}
<font-awesome <font-awesome
@ -145,6 +144,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="mt-2"> <div class="mt-2">
<font-awesome icon="comment" class="text-slate-400" /> <font-awesome icon="comment" class="text-slate-400" />
{{ issuerName }} posted that. {{ issuerName }} posted that.
@ -493,6 +493,11 @@ export default class ConfirmGiftView extends Vue {
confsVisibleErrorMessage = ""; confsVisibleErrorMessage = "";
confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer confsVisibleToIdList: string[] = []; // list of DIDs that can see any confirmer
giveDetails?: GiveSummaryRecord; giveDetails?: GiveSummaryRecord;
// Additional offer information extracted from the fulfills array
giveDetailsOfferFulfillment: {
offerHandleId?: string;
offerType?: string;
} | null = null;
giverName = ""; giverName = "";
issuerName = ""; issuerName = "";
isLoading = false; isLoading = false;
@ -648,6 +653,8 @@ export default class ConfirmGiftView extends Vue {
if (resp.status === 200) { if (resp.status === 200) {
this.giveDetails = resp.data.data[0]; this.giveDetails = resp.data.data[0];
// Extract offer information from the fulfills array
this.extractOfferFulfillment();
} else { } else {
throw new Error("Error getting detailed give info: " + resp.status); throw new Error("Error getting detailed give info: " + resp.status);
} }
@ -707,6 +714,15 @@ 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 * Fetches confirmer information for the claim
*/ */
@ -849,27 +865,6 @@ 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 * Initiates sharing of claim information
* Handles share functionality based on platform capabilities * Handles share functionality based on platform capabilities
@ -894,11 +889,5 @@ export default class ConfirmGiftView extends Vue {
this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD; this.veriClaim = serverUtil.BLANK_GENERIC_SERVER_RECORD;
this.veriClaimDump = ""; this.veriClaimDump = "";
} }
capitalizeAndInsertSpacesBeforeCaps(text: string) {
return !text
? ""
: text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
}
} }
</script> </script>

10
src/views/ContactQRScanFullView.vue

@ -144,6 +144,7 @@ import {
QR_TIMEOUT_LONG, QR_TIMEOUT_LONG,
} from "@/constants/notifications"; } from "@/constants/notifications";
import { createNotifyHelpers, NotifyFunction } from "../utils/notify"; import { createNotifyHelpers, NotifyFunction } from "../utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
interface QRScanResult { interface QRScanResult {
rawValue?: string; rawValue?: string;
@ -622,6 +623,15 @@ export default class ContactQRScanFull extends Vue {
*/ */
async handleBack() { async handleBack() {
await this.cleanupScanner(); 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(); this.$router.back();
} }

10
src/views/ContactQRScanShowView.vue

@ -163,6 +163,7 @@ import { QRScannerFactory } from "@/services/QRScanner/QRScannerFactory";
import { CameraState } from "@/services/QRScanner/types"; import { CameraState } from "@/services/QRScanner/types";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers } from "@/utils/notify"; import { createNotifyHelpers } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { import {
NOTIFY_QR_INITIALIZATION_ERROR, NOTIFY_QR_INITIALIZATION_ERROR,
NOTIFY_QR_CAMERA_IN_USE, NOTIFY_QR_CAMERA_IN_USE,
@ -319,6 +320,15 @@ export default class ContactQRScanShow extends Vue {
async handleBack(): Promise<void> { async handleBack(): Promise<void> {
await this.cleanupScanner(); 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(); this.$router.back();
} }

10
src/views/GiftedDetailsView.vue

@ -280,6 +280,7 @@ import { logger } from "../utils/logger";
import { Contact } from "@/db/tables/contacts"; import { Contact } from "@/db/tables/contacts";
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin"; import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify"; import { createNotifyHelpers, TIMEOUTS } from "@/utils/notify";
import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder";
import { import {
NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR, NOTIFY_GIFTED_DETAILS_RETRIEVAL_ERROR,
NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM, NOTIFY_GIFTED_DETAILS_DELETE_IMAGE_CONFIRM,
@ -770,6 +771,15 @@ export default class GiftedDetails extends Vue {
NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message, NOTIFY_GIFTED_DETAILS_GIFT_RECORDED.message,
TIMEOUTS.SHORT, 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"); localStorage.removeItem("imageUrl");
if (this.destinationPathAfter) { if (this.destinationPathAfter) {
(this.$router as Router).push({ path: this.destinationPathAfter }); (this.$router as Router).push({ path: this.destinationPathAfter });

17
src/views/SeedBackupView.vue

@ -231,9 +231,24 @@ export default class SeedBackupView extends Vue {
/** /**
* Reveals the seed phrase to the user * Reveals the seed phrase to the user
* Sets showSeed to true to display the sensitive seed phrase data * Sets showSeed to true to display the sensitive seed phrase data
* Updates the hasBackedUpSeed setting to true to track that user has backed up
*/ */
revealSeed(): void { async revealSeed(): Promise<void> {
this.showSeed = true; this.showSeed = true;
// Update the account setting to track that user has backed up their seed
try {
const settings = await this.$accountSettings();
if (settings.activeDid) {
await this.$saveUserSettings(settings.activeDid, {
hasBackedUpSeed: true,
});
}
} catch (err: unknown) {
logger.error("Failed to update hasBackedUpSeed setting:", err);
// Don't show error to user as this is not critical to the main functionality
// The seed phrase is still revealed, just the tracking won't work
}
} }
/** /**

Loading…
Cancel
Save