From 22dbfbcc09837ac8a5dea7ce99a196bef3714c86 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Thu, 20 Mar 2025 14:40:15 -0400 Subject: [PATCH 1/3] feat: added never remind me to key reminder. --- src/components/KeyBackupReminder.tsx | 4 - src/components/Toaster.tsx | 4 +- src/core/hooks/useKeyBackupReminder.tsx | 118 +++++++++++++----------- src/core/hooks/useLocalStorage.test.ts | 52 +++++++++++ src/core/hooks/useToast.test.tsx | 81 ++++++++++++++++ src/core/hooks/useToast.ts | 2 +- 6 files changed, 200 insertions(+), 61 deletions(-) create mode 100644 src/core/hooks/useLocalStorage.test.ts create mode 100644 src/core/hooks/useToast.test.tsx diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx index 7ae60ead..777c28cc 100644 --- a/src/components/KeyBackupReminder.tsx +++ b/src/components/KeyBackupReminder.tsx @@ -10,10 +10,6 @@ export const KeyBackupReminder = () => { "We recommend backing up your key data regularly. Would you like to back up now?", onAccept: () => setDialogOpen("pkiBackup", true), enabled: true, - cookieOptions: { - secure: true, - sameSite: "strict", - }, }); // deno-lint-ignore jsx-no-useless-fragment return <>; diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index b42044bb..b2e76e43 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -5,8 +5,8 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "./UI/Toast.tsx"; -import { useToast } from "../core/hooks/useToast.ts"; +} from "@components/UI/Toast.tsx"; +import { useToast } from "@core/hooks/useToast.ts"; export function Toaster() { const { toasts } = useToast(); diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index 1f6b17bd..f1009095 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -1,15 +1,13 @@ -import { Button } from "../../components/UI/Button.tsx"; -import type { CookieAttributes } from "js-cookie"; +import { Button } from "@components/UI/Button.tsx"; import { useCallback, useEffect, useRef } from "react"; -import useCookie from "./useCookie.ts"; -import { useToast } from "./useToast.ts"; +import { useToast } from "@core/hooks/useToast.ts"; +import useLocalStorage from "@core/hooks/useLocalStorage.ts"; interface UseBackupReminderOptions { reminderInDays?: number; message: string; onAccept?: () => void | Promise; enabled: boolean; - cookieOptions?: CookieAttributes; } interface ReminderState { @@ -17,17 +15,15 @@ interface ReminderState { lastShown: string; } -const TOAST_APPEAR_DELAY = 10_000; // 10 seconds; -const TOAST_DURATION = 30_000; // 30 seconds;: - -// remind user in 1 year to backup keys again, if they accept the reminder; +const TOAST_APPEAR_DELAY = 10_000; // 10 seconds +const TOAST_DURATION = 30_000; // 30 seconds const ON_ACCEPT_REMINDER_DAYS = 365; +const STORAGE_KEY = "key_backup_reminder"; function isReminderExpired(lastShown: string): boolean { const lastShownDate = new Date(lastShown); const now = new Date(); - const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) / - (1000 * 60 * 60 * 24); + const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) / (1000 * 60 * 60 * 24); return daysSinceLastShown >= 7; } @@ -35,36 +31,32 @@ export function useBackupReminder({ reminderInDays = 7, enabled, message, - onAccept = () => {}, - cookieOptions, + onAccept = () => { }, }: UseBackupReminderOptions) { const { toast } = useToast(); const toastShownRef = useRef(false); - const { value: reminderCookie, setCookie } = useCookie( - "key_backup_reminder", + const [reminderState, setReminderState] = useLocalStorage( + STORAGE_KEY, + null ); - const suppressReminder = useCallback( - (days: number) => { - const expiryDate = new Date(); - expiryDate.setDate(expiryDate.getDate() + days); + // Suppress reminder for 10 years if not specified + const suppressReminder = useCallback((days: number = 3563) => { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + days); - setCookie( - { - suppressed: true, - lastShown: new Date().toISOString(), - }, - { ...cookieOptions, expires: expiryDate }, - ); - }, - [setCookie, cookieOptions], - ); + setReminderState({ + suppressed: true, + lastShown: new Date().toISOString(), + }); + }, [setReminderState]); useEffect(() => { if (!enabled || toastShownRef.current) return; - const shouldShowReminder = !reminderCookie?.suppressed || - isReminderExpired(reminderCookie.lastShown); + const shouldShowReminder = + !reminderState?.suppressed || isReminderExpired(reminderState.lastShown); + if (!shouldShowReminder) return; toastShownRef.current = true; @@ -75,28 +67,46 @@ export function useBackupReminder({ delay: TOAST_APPEAR_DELAY, description: message, action: ( -
- - +
+
+ + + +
+
+ +
), }); @@ -113,6 +123,6 @@ export function useBackupReminder({ reminderInDays, suppressReminder, toast, - reminderCookie, + reminderState, ]); } diff --git a/src/core/hooks/useLocalStorage.test.ts b/src/core/hooks/useLocalStorage.test.ts new file mode 100644 index 00000000..8d621a57 --- /dev/null +++ b/src/core/hooks/useLocalStorage.test.ts @@ -0,0 +1,52 @@ +import { renderHook, act } from '@testing-library/react' +import useLocalStorage from './useLocalStorage' +import { beforeEach, describe, expect, it } from "vitest"; + +describe('useLocalStorage', () => { + const key = 'test-key' + + beforeEach(() => { + localStorage.clear() + }) + + it('should initialize with initial value if localStorage is empty', () => { + const { result } = renderHook(() => useLocalStorage(key, 'initial')) + const [value] = result.current + expect(value).toBe('initial') + }) + + it('should read existing value from localStorage', () => { + localStorage.setItem(key, JSON.stringify('stored')) + const { result } = renderHook(() => useLocalStorage(key, 'initial')) + const [value] = result.current + expect(value).toBe('stored') + }) + + it('should update localStorage when setValue is called', () => { + const { result } = renderHook(() => useLocalStorage(key, 'initial')) + const [, setValue] = result.current + + act(() => { + setValue('updated') + }) + + expect(localStorage.getItem(key)).toBe(JSON.stringify('updated')) + expect(result.current[0]).toBe('updated') + }) + + it('should remove value from localStorage when removeValue is called', () => { + const { result } = renderHook(() => useLocalStorage(key, 'initial')) + const [, setValue, removeValue] = result.current + + act(() => { + setValue('to-be-removed') + }) + + act(() => { + removeValue() + }) + + expect(localStorage.getItem(key)).toBeNull() + expect(result.current[0]).toBe('initial') + }) +}) diff --git a/src/core/hooks/useToast.test.tsx b/src/core/hooks/useToast.test.tsx new file mode 100644 index 00000000..9125da75 --- /dev/null +++ b/src/core/hooks/useToast.test.tsx @@ -0,0 +1,81 @@ +import { renderHook, act } from '@testing-library/react' +import { useToast } from "@core/hooks/useToast.ts" +import { Button } from '@components/UI/Button.tsx' +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +describe('useToast', () => { + beforeEach(() => { + // Reset toast memory state before each test + // our hook uses global memory to store toasts + // @ts-expect-error - internal test reset + globalThis.memoryState = { toasts: [] } + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('should create a toast with title, description, and action', () => { + const { result } = renderHook(() => useToast()) + + act(() => { + result.current.toast({ + title: 'Backup Reminder', + description: 'Don\'t forget to backup!', + action: + }) + vi.runAllTimers() + }) + + const toast = result.current.toasts[0] + expect(result.current.toasts.length).toBe(1) + expect(toast.title).toBe('Backup Reminder') + expect(toast.description).toBe('Don\'t forget to backup!') + expect(toast.action).toBeTruthy() + expect(toast.open).toBe(true) + }) + it('should dismiss a toast using returned dismiss function', () => { + const { result } = renderHook(() => useToast()) + vi.useFakeTimers() + + let toastRef: { id: string, dismiss: () => void } + + act(() => { + toastRef = result.current.toast({ title: 'Dismiss Me' }) + vi.runAllTimers() // Flush ADD_TOAST + }) + + act(() => { + toastRef.dismiss() + }) + + const toast = result.current.toasts.find(t => t.id === toastRef.id) + expect(toast?.open).toBe(false) + + vi.useRealTimers() + }) + + + it('should allow dismiss via hook dismiss function', () => { + const { result } = renderHook(() => useToast()) + vi.useFakeTimers() + + let toastRef: { id: string } + + act(() => { + toastRef = result.current.toast({ title: 'Manual Dismiss' }) + vi.runAllTimers() + }) + + act(() => { + result.current.dismiss(toastRef.id) + }) + + const toast = result.current.toasts.find(t => t.id === toastRef.id) + expect(toast?.open).toBe(false) + + vi.useRealTimers() + }) + +}) diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts index 3269eee9..d728537f 100644 --- a/src/core/hooks/useToast.ts +++ b/src/core/hooks/useToast.ts @@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) { ...props, id, open: true, - onOpenChange: (open) => { + onOpenChange: (open: boolean) => { if (!open) dismiss(); }, }, From 1780c6fb2a7dee58e973723eaee39c497ffe7c18 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 21 Mar 2025 22:39:40 -0400 Subject: [PATCH 2/3] refactor: updated how expiry dates are handled. --- src/components/KeyBackupReminder.tsx | 1 - src/core/hooks/useKeyBackupReminder.tsx | 76 ++++++++++--------------- 2 files changed, 29 insertions(+), 48 deletions(-) diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx index 777c28cc..80894cba 100644 --- a/src/components/KeyBackupReminder.tsx +++ b/src/components/KeyBackupReminder.tsx @@ -5,7 +5,6 @@ export const KeyBackupReminder = () => { const { setDialogOpen } = useDevice(); useBackupReminder({ - reminderInDays: 7, message: "We recommend backing up your key data regularly. Would you like to back up now?", onAccept: () => setDialogOpen("pkiBackup", true), diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index f1009095..1669c429 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -11,27 +11,27 @@ interface UseBackupReminderOptions { } interface ReminderState { - suppressed: boolean; - lastShown: string; + expires: string; } const TOAST_APPEAR_DELAY = 10_000; // 10 seconds const TOAST_DURATION = 30_000; // 30 seconds -const ON_ACCEPT_REMINDER_DAYS = 365; +const REMINDER_DAYS_ONE_WEEK = 7; +const REMINDER_DAYS_ONE_YEAR = 365; +const REMINDER_DAYS_FOREVER = 3650; const STORAGE_KEY = "key_backup_reminder"; -function isReminderExpired(lastShown: string): boolean { - const lastShownDate = new Date(lastShown); - const now = new Date(); - const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) / (1000 * 60 * 60 * 24); - return daysSinceLastShown >= 7; +function isReminderExpired(expires?: string): boolean { + if (!expires) return true; + const expiryDate = new Date(expires); + return isNaN(expiryDate.getTime()) || new Date() >= expiryDate; } export function useBackupReminder({ - reminderInDays = 7, enabled, message, onAccept = () => { }, + reminderInDays = REMINDER_DAYS_ONE_WEEK, }: UseBackupReminderOptions) { const { toast } = useToast(); const toastShownRef = useRef(false); @@ -40,24 +40,16 @@ export function useBackupReminder({ null ); - // Suppress reminder for 10 years if not specified - const suppressReminder = useCallback((days: number = 3563) => { + const setReminderExpiry = useCallback((days: number) => { const expiryDate = new Date(); expiryDate.setDate(expiryDate.getDate() + days); - - setReminderState({ - suppressed: true, - lastShown: new Date().toISOString(), - }); + setReminderState({ expires: expiryDate.toISOString() }); }, [setReminderState]); useEffect(() => { if (!enabled || toastShownRef.current) return; - const shouldShowReminder = - !reminderState?.suppressed || isReminderExpired(reminderState.lastShown); - - if (!shouldShowReminder) return; + if (!isReminderExpired(reminderState?.expires)) return; toastShownRef.current = true; @@ -69,14 +61,13 @@ export function useBackupReminder({ action: (
-
-
- -
+
), }); - return () => { - if (!toastShownRef.current) { - dismiss(); - } - }; + return () => dismiss(); }, [ enabled, message, onAccept, - reminderInDays, - suppressReminder, - toast, - reminderState, + ]); -} +}; \ No newline at end of file From a7a448cbcde8379dbafc1b8d7beb552671503ac1 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 21 Mar 2025 23:34:20 -0400 Subject: [PATCH 3/3] refactor: improved how reminder expiry dates are handled. --- src/core/hooks/useKeyBackupReminder.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index 1669c429..3cc46842 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -24,7 +24,10 @@ const STORAGE_KEY = "key_backup_reminder"; function isReminderExpired(expires?: string): boolean { if (!expires) return true; const expiryDate = new Date(expires); - return isNaN(expiryDate.getTime()) || new Date() >= expiryDate; + if (isNaN(expiryDate.getTime())) return true; // Invalid date passed + + const now = new Date(); + return now.getTime() >= expiryDate.getTime(); } export function useBackupReminder({ @@ -70,7 +73,7 @@ export function useBackupReminder({ setReminderExpiry(reminderInDays); }} > - Remind me in {reminderInDays} days + Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''}