Merge pull request #528 from danditomaso/add-dismiss-to-key-reminder
Refactor useBackupReminder hook
This commit is contained in:
@@ -5,15 +5,10 @@ export const KeyBackupReminder = () => {
|
|||||||
const { setDialogOpen } = useDevice();
|
const { setDialogOpen } = useDevice();
|
||||||
|
|
||||||
useBackupReminder({
|
useBackupReminder({
|
||||||
reminderInDays: 7,
|
|
||||||
message:
|
message:
|
||||||
"We recommend backing up your key data regularly. Would you like to back up now?",
|
"We recommend backing up your key data regularly. Would you like to back up now?",
|
||||||
onAccept: () => setDialogOpen("pkiBackup", true),
|
onAccept: () => setDialogOpen("pkiBackup", true),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
cookieOptions: {
|
|
||||||
secure: true,
|
|
||||||
sameSite: "strict",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
// deno-lint-ignore jsx-no-useless-fragment
|
// deno-lint-ignore jsx-no-useless-fragment
|
||||||
return <></>;
|
return <></>;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
} from "./UI/Toast.tsx";
|
} from "@components/UI/Toast.tsx";
|
||||||
import { useToast } from "../core/hooks/useToast.ts";
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { toasts } = useToast();
|
const { toasts } = useToast();
|
||||||
|
|||||||
@@ -1,71 +1,58 @@
|
|||||||
import { Button } from "../../components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import type { CookieAttributes } from "js-cookie";
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import useCookie from "./useCookie.ts";
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
import { useToast } from "./useToast.ts";
|
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
|
||||||
|
|
||||||
interface UseBackupReminderOptions {
|
interface UseBackupReminderOptions {
|
||||||
reminderInDays?: number;
|
reminderInDays?: number;
|
||||||
message: string;
|
message: string;
|
||||||
onAccept?: () => void | Promise<void>;
|
onAccept?: () => void | Promise<void>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
cookieOptions?: CookieAttributes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReminderState {
|
interface ReminderState {
|
||||||
suppressed: boolean;
|
expires: string;
|
||||||
lastShown: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds;
|
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds
|
||||||
const TOAST_DURATION = 30_000; // 30 seconds;:
|
const TOAST_DURATION = 30_000; // 30 seconds
|
||||||
|
const REMINDER_DAYS_ONE_WEEK = 7;
|
||||||
|
const REMINDER_DAYS_ONE_YEAR = 365;
|
||||||
|
const REMINDER_DAYS_FOREVER = 3650;
|
||||||
|
const STORAGE_KEY = "key_backup_reminder";
|
||||||
|
|
||||||
// remind user in 1 year to backup keys again, if they accept the reminder;
|
function isReminderExpired(expires?: string): boolean {
|
||||||
const ON_ACCEPT_REMINDER_DAYS = 365;
|
if (!expires) return true;
|
||||||
|
const expiryDate = new Date(expires);
|
||||||
|
if (isNaN(expiryDate.getTime())) return true; // Invalid date passed
|
||||||
|
|
||||||
function isReminderExpired(lastShown: string): boolean {
|
|
||||||
const lastShownDate = new Date(lastShown);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) /
|
return now.getTime() >= expiryDate.getTime();
|
||||||
(1000 * 60 * 60 * 24);
|
|
||||||
return daysSinceLastShown >= 7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupReminder({
|
export function useBackupReminder({
|
||||||
reminderInDays = 7,
|
|
||||||
enabled,
|
enabled,
|
||||||
message,
|
message,
|
||||||
onAccept = () => {},
|
onAccept = () => { },
|
||||||
cookieOptions,
|
reminderInDays = REMINDER_DAYS_ONE_WEEK,
|
||||||
}: UseBackupReminderOptions) {
|
}: UseBackupReminderOptions) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const toastShownRef = useRef(false);
|
const toastShownRef = useRef(false);
|
||||||
const { value: reminderCookie, setCookie } = useCookie<ReminderState>(
|
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>(
|
||||||
"key_backup_reminder",
|
STORAGE_KEY,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
const suppressReminder = useCallback(
|
const setReminderExpiry = useCallback((days: number) => {
|
||||||
(days: number) => {
|
const expiryDate = new Date();
|
||||||
const expiryDate = new Date();
|
expiryDate.setDate(expiryDate.getDate() + days);
|
||||||
expiryDate.setDate(expiryDate.getDate() + days);
|
setReminderState({ expires: expiryDate.toISOString() });
|
||||||
|
}, [setReminderState]);
|
||||||
setCookie(
|
|
||||||
{
|
|
||||||
suppressed: true,
|
|
||||||
lastShown: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ ...cookieOptions, expires: expiryDate },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[setCookie, cookieOptions],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || toastShownRef.current) return;
|
if (!enabled || toastShownRef.current) return;
|
||||||
|
|
||||||
const shouldShowReminder = !reminderCookie?.suppressed ||
|
if (!isReminderExpired(reminderState?.expires)) return;
|
||||||
isReminderExpired(reminderCookie.lastShown);
|
|
||||||
if (!shouldShowReminder) return;
|
|
||||||
|
|
||||||
toastShownRef.current = true;
|
toastShownRef.current = true;
|
||||||
|
|
||||||
@@ -75,44 +62,52 @@ export function useBackupReminder({
|
|||||||
delay: TOAST_APPEAR_DELAY,
|
delay: TOAST_APPEAR_DELAY,
|
||||||
description: message,
|
description: message,
|
||||||
action: (
|
action: (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="p-1"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
setReminderExpiry(reminderInDays);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="p-1"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
setReminderExpiry(REMINDER_DAYS_FOREVER);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Never remind me
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
className="w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAccept();
|
onAccept();
|
||||||
dismiss();
|
dismiss();
|
||||||
suppressReminder(ON_ACCEPT_REMINDER_DAYS);
|
setReminderExpiry(REMINDER_DAYS_ONE_YEAR);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back up now
|
Back up now
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
dismiss();
|
|
||||||
suppressReminder(reminderInDays);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remind me in {reminderInDays} days
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => dismiss();
|
||||||
if (!toastShownRef.current) {
|
|
||||||
dismiss();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
enabled,
|
enabled,
|
||||||
message,
|
message,
|
||||||
onAccept,
|
onAccept,
|
||||||
reminderInDays,
|
|
||||||
suppressReminder,
|
|
||||||
toast,
|
|
||||||
reminderCookie,
|
|
||||||
]);
|
]);
|
||||||
}
|
};
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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: <Button>Backup Now</Button>
|
||||||
|
})
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) {
|
|||||||
...props,
|
...props,
|
||||||
id,
|
id,
|
||||||
open: true,
|
open: true,
|
||||||
onOpenChange: (open) => {
|
onOpenChange: (open: boolean) => {
|
||||||
if (!open) dismiss();
|
if (!open) dismiss();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user