Browse Source

Refactor based on code review. Improved typing of useCookie hook. Added duration/delay to control length of toast.

pull/360/head
Dan Ditomaso 1 year ago
parent
commit
7884991ac6
  1. 85
      src/components/Dialog/PKIBackupDialog.tsx
  2. 16
      src/components/KeyBackupReminder.tsx
  3. 12
      src/components/Toaster.tsx
  4. 55
      src/core/hooks/useCookie.ts
  5. 165
      src/core/hooks/useKeyBackupReminder.tsx
  6. 27
      src/core/hooks/useToast.ts

85
src/components/Dialog/PKIBackupDialog.tsx

@ -1,3 +1,5 @@
import { useDevice } from "@app/core/stores/deviceStore";
import { Button } from "@components/UI/Button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -6,11 +8,9 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { Button } from "@components/UI/Button"; import { fromByteArray } from "base64-js";
import { DownloadIcon, PrinterIcon } from "lucide-react"; import { DownloadIcon, PrinterIcon } from "lucide-react";
import React from "react"; import React from "react";
import { useDevice } from "@app/core/stores/deviceStore";
import { fromByteArray } from "base64-js";
export interface PkiBackupDialogProps { export interface PkiBackupDialogProps {
open: boolean; open: boolean;
@ -22,26 +22,30 @@ export const PkiBackupDialog = ({
onOpenChange, onOpenChange,
}: PkiBackupDialogProps) => { }: PkiBackupDialogProps) => {
const { config, setDialogOpen } = useDevice(); const { config, setDialogOpen } = useDevice();
const privateKeyData = config.security?.privateKey const privateKey = config.security?.privateKey;
const publicKey = config.security?.publicKey;
// If the private data doesn't exist return null
if (!privateKeyData) {
return null
}
const getPrivateKey = React.useMemo(() => fromByteArray(config.security?.privateKey ?? new Uint8Array(0)), [config.security?.privateKey]); const decodeKeyData = React.useCallback(
(key: Uint8Array<ArrayBufferLike>) => {
if (!key) return "";
return fromByteArray(key ?? new Uint8Array(0));
},
[],
);
const closeDialog = React.useCallback(() => { const closeDialog = React.useCallback(() => {
setDialogOpen("pkiBackup", false) setDialogOpen("pkiBackup", false);
}, [setDialogOpen]) }, [setDialogOpen]);
const renderPrintWindow = React.useCallback(() => { const renderPrintWindow = React.useCallback(() => {
if (!privateKey || !publicKey) return;
const printWindow = window.open("", "_blank"); const printWindow = window.open("", "_blank");
if (printWindow) { if (printWindow) {
printWindow.document.write(` printWindow.document.write(`
<html> <html>
<head> <head>
<title>Your Private Key</title> <title>=== MESHTASTIC KEYS ===</title>
<style> <style>
body { font-family: Arial, sans-serif; padding: 20px; } body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; } h1 { font-size: 18px; }
@ -49,58 +53,77 @@ export const PkiBackupDialog = ({
</style> </style>
</head> </head>
<body> <body>
<h1>Your Private Key</h1> <h1>=== MESHTASTIC KEYS ===</h1>
<p>${getPrivateKey}</p> <br>
<h2>Public Key:</h2>
<p>${decodeKeyData(publicKey)}</p>
<h2>Private Key:</h2>
<p>${decodeKeyData(privateKey)}</p>
<br>
<p>=== END OF KEYS ===</p>
</body> </body>
</html> </html>
`); `);
printWindow.document.close(); printWindow.document.close();
printWindow.print(); printWindow.print();
closeDialog() closeDialog();
} }
}, [getPrivateKey, closeDialog]); }, [decodeKeyData, privateKey, publicKey, closeDialog]);
const createDownloadKeyFile = React.useCallback(() => { 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 url = URL.createObjectURL(blob);
const link = document.createElement("a"); const link = document.createElement("a");
link.href = url; link.href = url;
link.download = "meshtastic_private_key.txt"; link.download = "meshtastic_keys.txt";
link.style.display = "none"; link.style.display = "none";
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
closeDialog() closeDialog();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}, [getPrivateKey, closeDialog]); }, [decodeKeyData, privateKey, publicKey, closeDialog]);
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Backup Key</DialogTitle> <DialogTitle>Backup Keys</DialogTitle>
<DialogDescription> <DialogDescription>
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!
</DialogDescription> </DialogDescription>
<DialogDescription> <DialogDescription>
<span className="font-bold break-before-auto">If you lose your private key, you will need to reset your device.</span> <span className="font-bold break-before-auto">
If you lose your keys, you will need to reset your device.
</span>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="mt-6"> <DialogFooter className="mt-6">
<Button <Button
variant={'default'} variant={"default"}
onClick={() => createDownloadKeyFile()} onClick={() => createDownloadKeyFile()}
className="" className=""
> >
<DownloadIcon size={20} className="mr-2" /> <DownloadIcon size={20} className="mr-2" />
Download Download
</Button> </Button>
<Button <Button variant={"default"} onClick={() => renderPrintWindow()}>
variant={'default'}
onClick={() => renderPrintWindow()}
>
<PrinterIcon size={20} className="mr-2" /> <PrinterIcon size={20} className="mr-2" />
Print Print
</Button> </Button>

16
src/components/KeyBackupReminder.tsx

@ -5,17 +5,15 @@ export const KeyBackupReminder = (): JSX.Element => {
const { setDialogOpen } = useDevice(); const { setDialogOpen } = useDevice();
useBackupReminder({ useBackupReminder({
suppressDays: 7, reminderInDays: 7,
message: "We recommend backing up your key data regularly. Would you like to back up now?", message:
"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,
cookieOptions: { cookieOptions: {
secure: true, secure: true,
sameSite: 'strict' sameSite: "strict",
} },
}); });
return <></>;
return (
<>
</>
);
}; };

12
src/components/Toaster.tsx

@ -13,8 +13,14 @@ export function Toaster() {
return ( return (
<ToastProvider> <ToastProvider>
{toasts.map(({ id, title, description, action, ...props }) => ( {toasts.map(({ id, title, description, action, duration, ...props }) => (
<Toast key={id} {...props} className="flex flex-col gap-4"> <Toast
key={id}
{...props}
duration={duration}
className="flex flex-col gap-4"
>
<div className="grid gap-1"> <div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>} {title && <ToastTitle>{title}</ToastTitle>}
{description && <ToastDescription>{description}</ToastDescription>} {description && <ToastDescription>{description}</ToastDescription>}
@ -26,4 +32,4 @@ export function Toaster() {
<ToastViewport /> <ToastViewport />
</ToastProvider> </ToastProvider>
); );
} }

55
src/core/hooks/useCookie.ts

@ -1,35 +1,52 @@
import React from "react";
import Cookies, { type CookieAttributes } from "js-cookie"; import Cookies, { type CookieAttributes } from "js-cookie";
import { useCallback, useState } from "react";
type Cookie<T> = [ interface CookieHookResult<T> {
T | undefined, value: T | undefined;
(value: T, options?: CookieAttributes) => void, setCookie: (value: T, options?: CookieAttributes) => void;
() => void, removeCookie: () => void;
]; }
const useCookie = <T>( function useCookie<T extends object>(
cookieName: string, cookieName: string,
initialValue?: T, initialValue?: T,
): Cookie<T> => { ): CookieHookResult<T> {
const [cookieValue, setCookieValue] = React.useState<T | undefined>(() => { const [cookieValue, setCookieValue] = useState<T | undefined>(() => {
const cookie = Cookies.get(cookieName); try {
return cookie ? (JSON.parse(cookie) as T) : initialValue; 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) => { (value: T, options?: CookieAttributes) => {
Cookies.set(cookieName, JSON.stringify(value), options); try {
setCookieValue(value); Cookies.set(cookieName, JSON.stringify(value), options);
setCookieValue(value);
} catch (error) {
console.error(`Error setting cookie ${cookieName}:`, error);
}
}, },
[cookieName], [cookieName],
); );
const removeCookie = React.useCallback(() => { const removeCookie = useCallback(() => {
Cookies.remove(cookieName); try {
setCookieValue(undefined); Cookies.remove(cookieName);
setCookieValue(undefined);
} catch (error) {
console.error(`Error removing cookie ${cookieName}:`, error);
}
}, [cookieName]); }, [cookieName]);
return [cookieValue, setCookie, removeCookie]; return {
}; value: cookieValue,
setCookie,
removeCookie,
};
}
export default useCookie; export default useCookie;

165
src/core/hooks/useKeyBackupReminder.tsx

@ -1,14 +1,14 @@
import { useEffect, useCallback } from 'react'; import { Button } from "@app/components/UI/Button";
import { useToast } from './useToast'; import type { CookieAttributes } from "js-cookie";
import useCookie from './useCookie'; import { useCallback, useEffect, useRef } from "react";
import type { CookieAttributes } from 'js-cookie'; import useCookie from "./useCookie";
import { Button } from '@app/components/UI/Button'; import { useToast } from "./useToast";
interface UseBackupReminderOptions { interface UseBackupReminderOptions {
suppressDays?: number; reminderInDays?: number;
message?: string; message: string;
onAccept?: () => void | Promise<void>; onAccept?: () => void | Promise<void>;
cookieName?: string; enabled: boolean;
cookieOptions?: CookieAttributes; cookieOptions?: CookieAttributes;
} }
@ -17,73 +17,104 @@ interface ReminderState {
lastShown: string; 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({ export function useBackupReminder({
suppressDays = 365, reminderInDays = 7,
message = "It's time to back up your key data. Would you like to do this now?", enabled,
message,
onAccept = () => { }, onAccept = () => { },
cookieName = "backup_reminder_state", cookieOptions,
cookieOptions = {}, }: UseBackupReminderOptions) {
}: UseBackupReminderOptions = {}) {
const { toast } = useToast(); const { toast } = useToast();
const toastShownRef = useRef(false);
const { value: reminderCookie, setCookie } =
useCookie<ReminderState>("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<ReminderState>(cookieName); const shouldShowReminder =
!reminderCookie?.suppressed ||
isReminderExpired(reminderCookie.lastShown);
if (!shouldShowReminder) return;
const suppressReminder = useCallback(() => { toastShownRef.current = true;
const expiryDate = new Date();
expiryDate.setDate(expiryDate.getDate() + suppressDays);
setReminderState( const { dismiss } = toast(
{ {
suppressed: true, title: "Backup Reminder",
lastShown: new Date().toISOString(), duration: TOAST_DURATION,
delay: TOAST_APPEAR_DELAY,
description: message,
action: (
<div className="flex gap-2">
<Button
type="button"
variant="default"
onClick={() => {
onAccept();
dismiss();
suppressReminder(ON_ACCEPT_REMINDER_DAYS);
}}
>
Back up now
</Button>
<Button
type="button"
variant="outline"
onClick={() => {
dismiss();
suppressReminder(reminderInDays);
}}
>
Remind me in {reminderInDays} days
</Button>
</div>
),
}, },
{
...cookieOptions,
expires: expiryDate,
}
); );
}, [setReminderState, suppressDays, cookieOptions]); return () => {
if (!toastShownRef.current) {
useEffect(() => { dismiss();
if (!reminderState) { }
setTimeout(() => { };
const { dismiss: dimissToast } = toast({ }, [
title: "Backup Reminder", enabled,
description: message, message,
action: ( onAccept,
<div className="flex gap-2"> reminderInDays,
<Button suppressReminder,
type="button" toast,
variant={"default"} reminderCookie,
onClick={async () => { ]);
await onAccept(); }
dimissToast()
suppressReminder();
}}
>
Back up now
</Button>
<Button
type="button"
variant={"outline"}
onClick={() => {
dimissToast();
suppressReminder();
}}
>
Remind me later
</Button>
</div>
),
});
}, TOAST_DELAY);
}
}, [reminderState]);
return {
resetReminder: resetReminderState
};
}

27
src/core/hooks/useToast.ts

@ -10,6 +10,7 @@ type ToasterToast = ToastProps & {
title?: ReactNode; title?: ReactNode;
description?: ReactNode; description?: ReactNode;
action?: ToastActionElement; action?: ToastActionElement;
delay?: number;
}; };
const actionTypes = { const actionTypes = {
@ -137,7 +138,7 @@ function dispatch(action: Action) {
type Toast = Omit<ToasterToast, "id">; type Toast = Omit<ToasterToast, "id">;
function toast({ ...props }: Toast) { function toast({ delay = 0, ...props }: Toast) {
const id = genId(); const id = genId();
const update = (props: ToasterToast) => const update = (props: ToasterToast) =>
@ -147,17 +148,19 @@ function toast({ ...props }: Toast) {
}); });
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
dispatch({ setTimeout(() => {
type: "ADD_TOAST", dispatch({
toast: { type: "ADD_TOAST",
...props, toast: {
id, ...props,
open: true, id,
onOpenChange: (open) => { open: true,
if (!open) dismiss(); onOpenChange: (open) => {
if (!open) dismiss();
},
}, },
}, });
}); }, delay);
return { return {
id: id, id: id,
@ -190,4 +193,4 @@ function useToast() {
}; };
} }
export { toast, useToast }; export { toast, useToast };
Loading…
Cancel
Save