diff --git a/doc/seed-phrase-reminder-implementation.md b/doc/seed-phrase-reminder-implementation.md new file mode 100644 index 00000000..400535f8 --- /dev/null +++ b/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. diff --git a/src/components/DataExportSection.vue b/src/components/DataExportSection.vue index 61bf4753..17524e79 100644 --- a/src/components/DataExportSection.vue +++ b/src/components/DataExportSection.vue @@ -16,6 +16,12 @@ messages * - Conditional UI based on platform capabilities * * @component * :to="{ name: 'seed-backup' }" :class="backupButtonClasses" > + + Backup Identifier Seed @@ -98,6 +104,12 @@ 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 @@ -129,7 +141,7 @@ export default class DataExportSection extends Vue { * CSS classes for the backup button (router link) */ 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() { 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 { + 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; + } } } diff --git a/src/components/GiftedDialog.vue b/src/components/GiftedDialog.vue index d91e68b3..abdeb4c4 100644 --- a/src/components/GiftedDialog.vue +++ b/src/components/GiftedDialog.vue @@ -82,6 +82,7 @@ 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, @@ -411,6 +412,15 @@ 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); } diff --git a/src/components/OfferDialog.vue b/src/components/OfferDialog.vue index a958bd06..eeedce82 100644 --- a/src/components/OfferDialog.vue +++ b/src/components/OfferDialog.vue @@ -64,6 +64,7 @@ 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, @@ -299,6 +300,14 @@ 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) { diff --git a/src/db-sql/migration.ts b/src/db-sql/migration.ts index 67944b75..0881dd02 100644 --- a/src/db-sql/migration.ts +++ b/src/db-sql/migration.ts @@ -124,6 +124,12 @@ const MIGRATIONS = [ 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; + `, + }, ]; /** diff --git a/src/db/tables/settings.ts b/src/db/tables/settings.ts index 0b86e355..ff43e0f8 100644 --- a/src/db/tables/settings.ts +++ b/src/db/tables/settings.ts @@ -29,6 +29,7 @@ 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 diff --git a/src/libs/endorserServer.ts b/src/libs/endorserServer.ts index 667083bf..ca8a9e97 100644 --- a/src/libs/endorserServer.ts +++ b/src/libs/endorserServer.ts @@ -1313,6 +1313,28 @@ 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 diff --git a/src/libs/util.ts b/src/libs/util.ts index dfd3dde5..c64916cc 100644 --- a/src/libs/util.ts +++ b/src/libs/util.ts @@ -160,6 +160,41 @@ export const isGiveAction = ( 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) => { if (did.startsWith("did:peer:")) { return ( diff --git a/src/services/QRScanner/WebInlineQRScanner.ts b/src/services/QRScanner/WebInlineQRScanner.ts index 5f5bceaf..d48775fe 100644 --- a/src/services/QRScanner/WebInlineQRScanner.ts +++ b/src/services/QRScanner/WebInlineQRScanner.ts @@ -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.error( + logger.debug( `[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.error( + logger.debug( `[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.info( + logger.debug( `[WebInlineQRScanner:${this.id}] Camera state changed to: ${state}`, { state, @@ -89,7 +89,7 @@ export class WebInlineQRScanner implements QRScannerService { async checkPermissions(): Promise { try { this.updateCameraState("initializing", "Checking camera permissions..."); - logger.error( + logger.debug( `[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.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Permission state from Permissions API:`, permissions.state, ); @@ -165,7 +165,7 @@ export class WebInlineQRScanner implements QRScannerService { "initializing", "Requesting camera permissions...", ); - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Requesting camera permissions...`, ); @@ -175,7 +175,7 @@ export class WebInlineQRScanner implements QRScannerService { (device) => device.kind === "videoinput", ); - logger.error(`[WebInlineQRScanner:${this.id}] Found video devices:`, { + logger.debug(`[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.error( + logger.debug( `[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.error(`[WebInlineQRScanner:${this.id}] Stopping test track:`, { + logger.debug(`[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 { try { - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Checking browser support...`, ); // Check for secure context first if (!window.isSecureContext) { - logger.error( + logger.debug( `[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.error(`[WebInlineQRScanner:${this.id}] Device support check:`, { + logger.debug(`[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.error(`[WebInlineQRScanner:${this.id}] Scanning frame:`, { + logger.debug(`[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.error(`[WebInlineQRScanner:${this.id}] QR Code detected:`, { + logger.debug(`[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.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Starting scan with options:`, this.options, ); // Get camera stream with options - logger.error( + logger.debug( `[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.error(`[WebInlineQRScanner:${this.id}] Camera stream obtained:`, { + logger.debug(`[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.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Video element started playing`, ); } // Emit stream to component 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 this.scanQRCode(); @@ -595,7 +595,7 @@ export class WebInlineQRScanner implements QRScannerService { } try { - logger.error(`[WebInlineQRScanner:${this.id}] Stopping scan`, { + logger.debug(`[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.error( + logger.debug( `[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.error(`[WebInlineQRScanner:${this.id}] Video element stopped`); + logger.debug(`[WebInlineQRScanner:${this.id}] Video element stopped`); } // Stop all tracks in the stream if (this.stream) { this.stream.getTracks().forEach((track) => { - logger.error(`[WebInlineQRScanner:${this.id}] Stopping track:`, { + logger.debug(`[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.error( + logger.debug( `[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.error(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); + logger.debug(`[WebInlineQRScanner:${this.id}] Scan stopped successfully`); } } addListener(listener: ScanListener): void { - logger.error(`[WebInlineQRScanner:${this.id}] Adding scan listener`); + logger.debug(`[WebInlineQRScanner:${this.id}] Adding scan listener`); this.scanListener = listener; } onStream(callback: (stream: MediaStream | null) => void): void { - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Adding stream event listener`, ); this.events.on("stream", callback); @@ -661,24 +661,24 @@ export class WebInlineQRScanner implements QRScannerService { async cleanup(): Promise { try { - logger.error(`[WebInlineQRScanner:${this.id}] Starting cleanup`); + logger.debug(`[WebInlineQRScanner:${this.id}] Starting cleanup`); await this.stopScan(); this.events.removeAllListeners(); - logger.error(`[WebInlineQRScanner:${this.id}] Event listeners removed`); + logger.debug(`[WebInlineQRScanner:${this.id}] Event listeners removed`); // Clean up DOM elements if (this.video) { this.video.remove(); this.video = null; - logger.error(`[WebInlineQRScanner:${this.id}] Video element removed`); + logger.debug(`[WebInlineQRScanner:${this.id}] Video element removed`); } if (this.canvas) { this.canvas.remove(); this.canvas = null; - logger.error(`[WebInlineQRScanner:${this.id}] Canvas element removed`); + logger.debug(`[WebInlineQRScanner:${this.id}] Canvas element removed`); } this.context = null; - logger.error( + logger.debug( `[WebInlineQRScanner:${this.id}] Cleanup completed successfully`, ); } catch (error) { diff --git a/src/utils/seedPhraseReminder.ts b/src/utils/seedPhraseReminder.ts new file mode 100644 index 00000000..9c0348fc --- /dev/null +++ b/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; +} diff --git a/src/views/AccountViewView.vue b/src/views/AccountViewView.vue index e68efca2..19872da6 100644 --- a/src/views/AccountViewView.vue +++ b/src/views/AccountViewView.vue @@ -811,6 +811,7 @@ 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, @@ -1695,6 +1696,14 @@ 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); } diff --git a/src/views/ClaimAddRawView.vue b/src/views/ClaimAddRawView.vue index 8784c7ef..ed96a79c 100644 --- a/src/views/ClaimAddRawView.vue +++ b/src/views/ClaimAddRawView.vue @@ -41,6 +41,7 @@ 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 { @@ -223,6 +224,14 @@ 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( diff --git a/src/views/ClaimView.vue b/src/views/ClaimView.vue index f594dc9b..2c441687 100644 --- a/src/views/ClaimView.vue +++ b/src/views/ClaimView.vue @@ -24,7 +24,9 @@

{{ - capitalizeAndInsertSpacesBeforeCaps(veriClaim.claimType || "") + serverUtil.capitalizeAndInsertSpacesBeforeCaps( + veriClaim.claimType || "", + ) }}

@@ -556,6 +572,17 @@ 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 @@ -689,6 +716,7 @@ 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 = ""; @@ -701,6 +729,15 @@ 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 // ================================================= @@ -758,13 +795,6 @@ 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 + @@ -821,6 +851,8 @@ 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), diff --git a/src/views/ConfirmGiftView.vue b/src/views/ConfirmGiftView.vue index c2274dab..95632bb7 100644 --- a/src/views/ConfirmGiftView.vue +++ b/src/views/ConfirmGiftView.vue @@ -96,50 +96,50 @@ +
+ +
+ + This fulfills a bigger plan + + +
- -
- - This fulfills a bigger plan - - -
- -
- - - This fulfills - {{ - capitalizeAndInsertSpacesBeforeCapsWithAPrefix( - giveDetails?.fulfillsType || "", - ) - }} - - + +
+ + + This fulfills + {{ + serverUtil.capitalizeAndInsertSpacesBeforeCapsWithAPrefix( + giveDetailsOfferFulfillment.offerType || "Offer", + ) + }} + + +
@@ -493,6 +493,11 @@ 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; @@ -648,6 +653,8 @@ 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); } @@ -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 */ @@ -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 * 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.veriClaimDump = ""; } - - capitalizeAndInsertSpacesBeforeCaps(text: string) { - return !text - ? "" - : text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1"); - } } diff --git a/src/views/ContactQRScanFullView.vue b/src/views/ContactQRScanFullView.vue index bb52bea4..42358b65 100644 --- a/src/views/ContactQRScanFullView.vue +++ b/src/views/ContactQRScanFullView.vue @@ -144,6 +144,7 @@ import { QR_TIMEOUT_LONG, } from "@/constants/notifications"; import { createNotifyHelpers, NotifyFunction } from "../utils/notify"; +import { showSeedPhraseReminder } from "@/utils/seedPhraseReminder"; interface QRScanResult { rawValue?: string; @@ -622,6 +623,15 @@ 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(); } diff --git a/src/views/ContactQRScanShowView.vue b/src/views/ContactQRScanShowView.vue index 45885a02..c5175d84 100644 --- a/src/views/ContactQRScanShowView.vue +++ b/src/views/ContactQRScanShowView.vue @@ -163,6 +163,7 @@ 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, @@ -319,6 +320,15 @@ export default class ContactQRScanShow extends Vue { async handleBack(): Promise { 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(); } diff --git a/src/views/GiftedDetailsView.vue b/src/views/GiftedDetailsView.vue index 983ee0b8..812c0b02 100644 --- a/src/views/GiftedDetailsView.vue +++ b/src/views/GiftedDetailsView.vue @@ -280,6 +280,7 @@ 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, @@ -770,6 +771,15 @@ 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 }); diff --git a/src/views/SeedBackupView.vue b/src/views/SeedBackupView.vue index c19caf13..fb9b7605 100644 --- a/src/views/SeedBackupView.vue +++ b/src/views/SeedBackupView.vue @@ -231,9 +231,24 @@ export default class SeedBackupView extends Vue { /** * Reveals the seed phrase to the user * 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 { 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 + } } /**