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 {
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<ArrayBufferLike>) => {
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(`
<html>
<head>
<title>Your Private Key</title>
<title>=== MESHTASTIC KEYS ===</title>
<style>
body { font-family: Arial, sans-serif; padding: 20px; }
h1 { font-size: 18px; }
@ -49,58 +53,77 @@ export const PkiBackupDialog = ({
</style>
</head>
<body>
<h1>Your Private Key</h1>
<p>${getPrivateKey}</p>
<h1>=== MESHTASTIC KEYS ===</h1>
<br>
<h2>Public Key:</h2>
<p>${decodeKeyData(publicKey)}</p>
<h2>Private Key:</h2>
<p>${decodeKeyData(privateKey)}</p>
<br>
<p>=== END OF KEYS ===</p>
</body>
</html>
`);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Backup Key</DialogTitle>
<DialogTitle>Backup Keys</DialogTitle>
<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>
<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>
</DialogHeader>
<DialogFooter className="mt-6">
<Button
variant={'default'}
variant={"default"}
onClick={() => createDownloadKeyFile()}
className=""
>
<DownloadIcon size={20} className="mr-2" />
Download
</Button>
<Button
variant={'default'}
onClick={() => renderPrintWindow()}
>
<Button variant={"default"} onClick={() => renderPrintWindow()}>
<PrinterIcon size={20} className="mr-2" />
Print
</Button>

16
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 <></>;
};

12
src/components/Toaster.tsx

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

55
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> = [
T | undefined,
(value: T, options?: CookieAttributes) => void,
() => void,
];
interface CookieHookResult<T> {
value: T | undefined;
setCookie: (value: T, options?: CookieAttributes) => void;
removeCookie: () => void;
}
const useCookie = <T>(
function useCookie<T extends object>(
cookieName: string,
initialValue?: T,
): Cookie<T> => {
const [cookieValue, setCookieValue] = React.useState<T | undefined>(() => {
const cookie = Cookies.get(cookieName);
return cookie ? (JSON.parse(cookie) as T) : initialValue;
): CookieHookResult<T> {
const [cookieValue, setCookieValue] = useState<T | undefined>(() => {
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;

165
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<void>;
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<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(() => {
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: (
<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]);
useEffect(() => {
if (!reminderState) {
setTimeout(() => {
const { dismiss: dimissToast } = toast({
title: "Backup Reminder",
description: message,
action: (
<div className="flex gap-2">
<Button
type="button"
variant={"default"}
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
};
}
return () => {
if (!toastShownRef.current) {
dismiss();
}
};
}, [
enabled,
message,
onAccept,
reminderInDays,
suppressReminder,
toast,
reminderCookie,
]);
}

27
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<ToasterToast, "id">;
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 };
Loading…
Cancel
Save