From 7884991ac6e1d36252dd2575c3abc69977113925 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Tue, 14 Jan 2025 21:51:06 -0500 Subject: [PATCH] Refactor based on code review. Improved typing of useCookie hook. Added duration/delay to control length of toast. --- src/components/Dialog/PKIBackupDialog.tsx | 85 +++++++---- src/components/KeyBackupReminder.tsx | 16 +-- src/components/Toaster.tsx | 12 +- src/core/hooks/useCookie.ts | 55 +++++--- src/core/hooks/useKeyBackupReminder.tsx | 165 +++++++++++++--------- src/core/hooks/useToast.ts | 27 ++-- 6 files changed, 219 insertions(+), 141 deletions(-) diff --git a/src/components/Dialog/PKIBackupDialog.tsx b/src/components/Dialog/PKIBackupDialog.tsx index 4b482bf7..9b6c18d8 100644 --- a/src/components/Dialog/PKIBackupDialog.tsx +++ b/src/components/Dialog/PKIBackupDialog.tsx @@ -1,3 +1,5 @@ +import { useDevice } from "@app/core/stores/deviceStore"; +import { Button } from "@components/UI/Button"; import { Dialog, DialogContent, @@ -6,11 +8,9 @@ import { DialogHeader, DialogTitle, } from "@components/UI/Dialog.tsx"; -import { Button } from "@components/UI/Button"; +import { fromByteArray } from "base64-js"; import { DownloadIcon, PrinterIcon } from "lucide-react"; import React from "react"; -import { useDevice } from "@app/core/stores/deviceStore"; -import { fromByteArray } from "base64-js"; export interface PkiBackupDialogProps { open: boolean; @@ -22,26 +22,30 @@ export const PkiBackupDialog = ({ onOpenChange, }: PkiBackupDialogProps) => { const { config, setDialogOpen } = useDevice(); - const privateKeyData = config.security?.privateKey - - // If the private data doesn't exist return null - if (!privateKeyData) { - return null - } + const privateKey = config.security?.privateKey; + const publicKey = config.security?.publicKey; - const getPrivateKey = React.useMemo(() => fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), [config.security?.privateKey]); + const decodeKeyData = React.useCallback( + (key: Uint8Array) => { + if (!key) return ""; + return fromByteArray(key ?? new Uint8Array(0)); + }, + [], + ); const closeDialog = React.useCallback(() => { - setDialogOpen("pkiBackup", false) - }, [setDialogOpen]) + setDialogOpen("pkiBackup", false); + }, [setDialogOpen]); const renderPrintWindow = React.useCallback(() => { + if (!privateKey || !publicKey) return; + const printWindow = window.open("", "_blank"); if (printWindow) { printWindow.document.write(` - Your Private Key + === MESHTASTIC KEYS === -

Your Private Key

-

${getPrivateKey}

+

=== MESHTASTIC KEYS ===

+
+

Public Key:

+

${decodeKeyData(publicKey)}

+

Private Key:

+

${decodeKeyData(privateKey)}

+
+

=== END OF KEYS ===

`); printWindow.document.close(); printWindow.print(); - closeDialog() - + closeDialog(); } - }, [getPrivateKey, closeDialog]); + }, [decodeKeyData, privateKey, publicKey, closeDialog]); const createDownloadKeyFile = React.useCallback(() => { - const blob = new Blob([getPrivateKey], { type: "text/plain" }); + if (!privateKey || !publicKey) return; + + const decodedPrivateKey = decodeKeyData(privateKey); + const decodedPublicKey = decodeKeyData(publicKey); + + const formattedContent = [ + "=== MESHTASTIC KEYS ===\n\n", + "Private Key:\n", + decodedPrivateKey, + "\n\nPublic Key:\n", + decodedPublicKey, + "\n\n=== END OF KEYS ===", + ].join(""); + + const blob = new Blob([formattedContent], { type: "text/plain" }); const url = URL.createObjectURL(blob); + const link = document.createElement("a"); link.href = url; - link.download = "meshtastic_private_key.txt"; + link.download = "meshtastic_keys.txt"; link.style.display = "none"; document.body.appendChild(link); link.click(); document.body.removeChild(link); - closeDialog() + closeDialog(); URL.revokeObjectURL(url); - }, [getPrivateKey, closeDialog]); - + }, [decodeKeyData, privateKey, publicKey, closeDialog]); return ( - Backup Key + Backup Keys - Its important to backup your private key and store your backup securely! + Its important to backup your public and private keys and store your + backup securely! - If you lose your private key, you will need to reset your device. + + If you lose your keys, you will need to reset your device. + - diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx index 45819bd7..7463d85f 100644 --- a/src/components/KeyBackupReminder.tsx +++ b/src/components/KeyBackupReminder.tsx @@ -5,17 +5,15 @@ export const KeyBackupReminder = (): JSX.Element => { const { setDialogOpen } = useDevice(); useBackupReminder({ - suppressDays: 7, - message: "We recommend backing up your key data regularly. Would you like to back up now?", + reminderInDays: 7, + message: + "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' - } + sameSite: "strict", + }, }); - - return ( - <> - - ); + return <>; }; diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index cf322758..f5531bba 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -13,8 +13,14 @@ export function Toaster() { return ( - {toasts.map(({ id, title, description, action, ...props }) => ( - + {toasts.map(({ id, title, description, action, duration, ...props }) => ( +
{title && {title}} {description && {description}} @@ -26,4 +32,4 @@ export function Toaster() { ); -} +} \ No newline at end of file diff --git a/src/core/hooks/useCookie.ts b/src/core/hooks/useCookie.ts index 6a1eb311..df3d9d82 100644 --- a/src/core/hooks/useCookie.ts +++ b/src/core/hooks/useCookie.ts @@ -1,35 +1,52 @@ -import React from "react"; import Cookies, { type CookieAttributes } from "js-cookie"; +import { useCallback, useState } from "react"; -type Cookie = [ - T | undefined, - (value: T, options?: CookieAttributes) => void, - () => void, -]; +interface CookieHookResult { + value: T | undefined; + setCookie: (value: T, options?: CookieAttributes) => void; + removeCookie: () => void; +} -const useCookie = ( +function useCookie( cookieName: string, initialValue?: T, -): Cookie => { - const [cookieValue, setCookieValue] = React.useState(() => { - const cookie = Cookies.get(cookieName); - return cookie ? (JSON.parse(cookie) as T) : initialValue; +): CookieHookResult { + const [cookieValue, setCookieValue] = useState(() => { + try { + const cookie = Cookies.get(cookieName); + return cookie ? (JSON.parse(cookie) as T) : initialValue; + } catch (error) { + console.error(`Error parsing cookie ${cookieName}:`, error); + return initialValue; + } }); - const setCookie = React.useCallback( + const setCookie = useCallback( (value: T, options?: CookieAttributes) => { - Cookies.set(cookieName, JSON.stringify(value), options); - setCookieValue(value); + try { + Cookies.set(cookieName, JSON.stringify(value), options); + setCookieValue(value); + } catch (error) { + console.error(`Error setting cookie ${cookieName}:`, error); + } }, [cookieName], ); - const removeCookie = React.useCallback(() => { - Cookies.remove(cookieName); - setCookieValue(undefined); + const removeCookie = useCallback(() => { + try { + Cookies.remove(cookieName); + setCookieValue(undefined); + } catch (error) { + console.error(`Error removing cookie ${cookieName}:`, error); + } }, [cookieName]); - return [cookieValue, setCookie, removeCookie]; -}; + return { + value: cookieValue, + setCookie, + removeCookie, + }; +} export default useCookie; diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index 9af1ec70..ee65d161 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -1,14 +1,14 @@ -import { useEffect, useCallback } from 'react'; -import { useToast } from './useToast'; -import useCookie from './useCookie'; -import type { CookieAttributes } from 'js-cookie'; -import { Button } from '@app/components/UI/Button'; +import { Button } from "@app/components/UI/Button"; +import type { CookieAttributes } from "js-cookie"; +import { useCallback, useEffect, useRef } from "react"; +import useCookie from "./useCookie"; +import { useToast } from "./useToast"; interface UseBackupReminderOptions { - suppressDays?: number; - message?: string; + reminderInDays?: number; + message: string; onAccept?: () => void | Promise; - cookieName?: string; + enabled: boolean; cookieOptions?: CookieAttributes; } @@ -17,73 +17,104 @@ interface ReminderState { lastShown: string; } -const TOAST_DELAY = 10000; +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 ON_ACCEPT_REMINDER_DAYS = 365 + +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; +} export function useBackupReminder({ - suppressDays = 365, - message = "It's time to back up your key data. Would you like to do this now?", + reminderInDays = 7, + enabled, + message, onAccept = () => { }, - cookieName = "backup_reminder_state", - cookieOptions = {}, -}: UseBackupReminderOptions = {}) { + cookieOptions, +}: UseBackupReminderOptions) { const { toast } = useToast(); + const toastShownRef = useRef(false); + const { value: reminderCookie, setCookie } = + useCookie("key_backup_reminder"); + + const suppressReminder = useCallback( + (days: number) => { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + days); + + setCookie( + { + suppressed: true, + lastShown: new Date().toISOString(), + }, + { ...cookieOptions, expires: expiryDate }, + ); + }, + [setCookie, cookieOptions], + ); + + useEffect(() => { + if (!enabled || toastShownRef.current) return; - const [reminderState, setReminderState, resetReminderState] = useCookie(cookieName); + const shouldShowReminder = + !reminderCookie?.suppressed || + isReminderExpired(reminderCookie.lastShown); + if (!shouldShowReminder) return; - const suppressReminder = useCallback(() => { - const expiryDate = new Date(); - expiryDate.setDate(expiryDate.getDate() + suppressDays); + toastShownRef.current = true; - setReminderState( + const { dismiss } = toast( { - suppressed: true, - lastShown: new Date().toISOString(), + title: "Backup Reminder", + duration: TOAST_DURATION, + delay: TOAST_APPEAR_DELAY, + description: message, + action: ( +
+ + +
+ ), }, - { - ...cookieOptions, - expires: expiryDate, - } ); - }, [setReminderState, suppressDays, cookieOptions]); - - useEffect(() => { - if (!reminderState) { - setTimeout(() => { - const { dismiss: dimissToast } = toast({ - title: "Backup Reminder", - description: message, - action: ( -
- - -
- ), - }); - }, TOAST_DELAY); - } - }, [reminderState]); - - return { - resetReminder: resetReminderState - }; -} \ No newline at end of file + return () => { + if (!toastShownRef.current) { + dismiss(); + } + }; + }, [ + enabled, + message, + onAccept, + reminderInDays, + suppressReminder, + toast, + reminderCookie, + ]); +} diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts index f64cf45a..c913d223 100644 --- a/src/core/hooks/useToast.ts +++ b/src/core/hooks/useToast.ts @@ -10,6 +10,7 @@ type ToasterToast = ToastProps & { title?: ReactNode; description?: ReactNode; action?: ToastActionElement; + delay?: number; }; const actionTypes = { @@ -137,7 +138,7 @@ function dispatch(action: Action) { type Toast = Omit; -function toast({ ...props }: Toast) { +function toast({ delay = 0, ...props }: Toast) { const id = genId(); const update = (props: ToasterToast) => @@ -147,17 +148,19 @@ function toast({ ...props }: Toast) { }); const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); - dispatch({ - type: "ADD_TOAST", - toast: { - ...props, - id, - open: true, - onOpenChange: (open) => { - if (!open) dismiss(); + setTimeout(() => { + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, }, - }, - }); + }); + }, delay); return { id: id, @@ -190,4 +193,4 @@ function useToast() { }; } -export { toast, useToast }; +export { toast, useToast }; \ No newline at end of file