committed by
GitHub
54 changed files with 3781 additions and 3335 deletions
@ -0,0 +1,2 @@ |
|||
dist/build.tar |
|||
dist/output |
|||
@ -1,4 +1,9 @@ |
|||
FROM registry.access.redhat.com/ubi9/nginx-122:1-45 |
|||
FROM nginx:1.27.2-alpine |
|||
|
|||
RUN rm -r /usr/share/nginx/html \ |
|||
&& mkdir /usr/share/nginx/html |
|||
|
|||
WORKDIR /usr/share/nginx/html |
|||
|
|||
ADD dist . |
|||
|
|||
|
|||
@ -5,13 +5,18 @@ |
|||
"description": "Meshtastic web client", |
|||
"license": "GPL-3.0-only", |
|||
"scripts": { |
|||
"build": "rsbuild build", |
|||
"check": "biome check .", |
|||
"check:fix": "pnpm check --write", |
|||
"build": "pnpm check && rsbuild build", |
|||
"build:analyze": "BUNDLE_ANALYZE=true rsbuild build", |
|||
"check": "biome check src/", |
|||
"check:fix": "pnpm check --write src/", |
|||
"format": "biome format --write src/", |
|||
"dev": "rsbuild dev --open", |
|||
"format": "biome format --write", |
|||
"preview": "rsbuild preview", |
|||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)" |
|||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ $(ls ./dist/output/)", |
|||
"postinstall": "npx simple-git-hooks" |
|||
}, |
|||
"simple-git-hooks": { |
|||
"pre-commit": "npm run check:fix && npm run format" |
|||
}, |
|||
"repository": { |
|||
"type": "git", |
|||
@ -23,65 +28,64 @@ |
|||
"homepage": "https://meshtastic.org", |
|||
"dependencies": { |
|||
"@bufbuild/protobuf": "^1.10.0", |
|||
"@emeraldpay/hashicon-react": "^0.5.2", |
|||
"@meshtastic/js": "2.3.7-5", |
|||
"@noble/curves": "^1.5.0", |
|||
"@radix-ui/react-accordion": "^1.2.0", |
|||
"@radix-ui/react-checkbox": "^1.1.0", |
|||
"@radix-ui/react-dialog": "^1.1.1", |
|||
"@radix-ui/react-dropdown-menu": "^2.1.1", |
|||
"@radix-ui/react-label": "^2.1.0", |
|||
"@radix-ui/react-menubar": "^1.1.1", |
|||
"@radix-ui/react-popover": "^1.1.1", |
|||
"@radix-ui/react-scroll-area": "^1.1.0", |
|||
"@radix-ui/react-select": "^2.1.1", |
|||
"@radix-ui/react-separator": "^1.1.0", |
|||
"@radix-ui/react-switch": "^1.1.0", |
|||
"@radix-ui/react-tabs": "^1.1.0", |
|||
"@radix-ui/react-toast": "^1.2.1", |
|||
"@radix-ui/react-tooltip": "^1.1.1", |
|||
"@turf/turf": "^6.5.0", |
|||
"@noble/curves": "^1.8.1", |
|||
"@radix-ui/react-accordion": "^1.2.2", |
|||
"@radix-ui/react-checkbox": "^1.1.3", |
|||
"@radix-ui/react-dialog": "^1.1.5", |
|||
"@radix-ui/react-dropdown-menu": "^2.1.5", |
|||
"@radix-ui/react-label": "^2.1.1", |
|||
"@radix-ui/react-menubar": "^1.1.5", |
|||
"@radix-ui/react-popover": "^1.1.5", |
|||
"@radix-ui/react-scroll-area": "^1.2.2", |
|||
"@radix-ui/react-select": "^2.1.5", |
|||
"@radix-ui/react-separator": "^1.1.1", |
|||
"@radix-ui/react-switch": "^1.1.2", |
|||
"@radix-ui/react-tabs": "^1.1.2", |
|||
"@radix-ui/react-toast": "^1.2.5", |
|||
"@radix-ui/react-tooltip": "^1.1.7", |
|||
"@turf/turf": "^7.2.0", |
|||
"base64-js": "^1.5.1", |
|||
"class-transformer": "^0.5.1", |
|||
"class-validator": "^0.14.1", |
|||
"class-variance-authority": "^0.7.0", |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.1", |
|||
"cmdk": "^1.0.0", |
|||
"cmdk": "^1.0.4", |
|||
"crypto-random-string": "^5.0.0", |
|||
"immer": "^10.1.1", |
|||
"lucide-react": "^0.363.0", |
|||
"mapbox-gl": "^3.6.0", |
|||
"js-cookie": "^3.0.5", |
|||
"lucide-react": "^0.474.0", |
|||
"mapbox-gl": "^3.9.4", |
|||
"maplibre-gl": "4.1.2", |
|||
"react": "^18.3.1", |
|||
"react-dom": "^18.3.1", |
|||
"react-hook-form": "^7.52.0", |
|||
"react-map-gl": "7.1.7", |
|||
"react-qrcode-logo": "^2.10.0", |
|||
"rfc4648": "^1.5.3", |
|||
"tailwind-merge": "^2.3.0", |
|||
"tailwindcss-animate": "^1.0.7", |
|||
"react": "^19.0.0", |
|||
"react-dom": "^19.0.0", |
|||
"react-hook-form": "^7.54.2", |
|||
"react-map-gl": "7.1.9", |
|||
"react-qrcode-logo": "^3.0.0", |
|||
"rfc4648": "^1.5.4", |
|||
"timeago-react": "^3.0.6", |
|||
"vite-plugin-node-polyfills": "^0.22.0", |
|||
"zustand": "4.5.2" |
|||
"vite-plugin-node-polyfills": "^0.23.0", |
|||
"zustand": "5.0.3" |
|||
}, |
|||
"devDependencies": { |
|||
"@biomejs/biome": "^1.8.2", |
|||
"@buf/meshtastic_protobufs.bufbuild_es": "1.10.0-20240906232734-3da561588c55.1", |
|||
"@rsbuild/core": "^1.0.10", |
|||
"@rsbuild/plugin-react": "^1.0.3", |
|||
"@types/chrome": "^0.0.263", |
|||
"@types/node": "^20.14.9", |
|||
"@types/react": "^18.3.3", |
|||
"@types/react-dom": "^18.3.0", |
|||
"@types/w3c-web-serial": "^1.0.6", |
|||
"@biomejs/biome": "^1.9.4", |
|||
"@rsbuild/core": "^1.2.3", |
|||
"@rsbuild/plugin-react": "^1.1.0", |
|||
"@types/chrome": "^0.0.299", |
|||
"@types/js-cookie": "^3.0.6", |
|||
"@types/node": "^22.12.0", |
|||
"@types/react": "^19.0.8", |
|||
"@types/react-dom": "^19.0.3", |
|||
"@types/w3c-web-serial": "^1.0.7", |
|||
"@types/web-bluetooth": "^0.0.20", |
|||
"autoprefixer": "^10.4.19", |
|||
"gzipper": "^7.2.0", |
|||
"postcss": "^8.4.38", |
|||
"rollup-plugin-visualizer": "^5.12.0", |
|||
"tailwindcss": "^3.4.4", |
|||
"tar": "^6.2.1", |
|||
"tslib": "^2.6.3", |
|||
"typescript": "^5.5.2" |
|||
} |
|||
"autoprefixer": "^10.4.20", |
|||
"gzipper": "^8.2.0", |
|||
"postcss": "^8.5.1", |
|||
"simple-git-hooks": "^2.11.1", |
|||
"tailwind-merge": "^2.6.0", |
|||
"tailwindcss": "^3.4.17", |
|||
"tailwindcss-animate": "^1.0.7", |
|||
"tar": "^7.4.3", |
|||
"typescript": "^5.7.3" |
|||
}, |
|||
"packageManager": "[email protected]" |
|||
} |
|||
|
|||
File diff suppressed because it is too large
@ -0,0 +1,134 @@ |
|||
import { useDevice } from "@app/core/stores/deviceStore"; |
|||
import { Button } from "@components/UI/Button"; |
|||
import { |
|||
Dialog, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogFooter, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { fromByteArray } from "base64-js"; |
|||
import { DownloadIcon, PrinterIcon } from "lucide-react"; |
|||
import React from "react"; |
|||
|
|||
export interface PkiBackupDialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
export const PkiBackupDialog = ({ |
|||
open, |
|||
onOpenChange, |
|||
}: PkiBackupDialogProps) => { |
|||
const { config, setDialogOpen } = useDevice(); |
|||
const privateKey = config.security?.privateKey; |
|||
const publicKey = config.security?.publicKey; |
|||
|
|||
const decodeKeyData = React.useCallback( |
|||
(key: Uint8Array<ArrayBufferLike>) => { |
|||
if (!key) return ""; |
|||
return fromByteArray(key ?? new Uint8Array(0)); |
|||
}, |
|||
[], |
|||
); |
|||
|
|||
const closeDialog = React.useCallback(() => { |
|||
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>=== MESHTASTIC KEYS ===</title> |
|||
<style> |
|||
body { font-family: Arial, sans-serif; padding: 20px; } |
|||
h1 { font-size: 18px; } |
|||
p { font-size: 14px; word-break: break-all; } |
|||
</style> |
|||
</head> |
|||
<body> |
|||
<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(); |
|||
} |
|||
}, [decodeKeyData, privateKey, publicKey, closeDialog]); |
|||
|
|||
const createDownloadKeyFile = React.useCallback(() => { |
|||
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_keys.txt"; |
|||
link.style.display = "none"; |
|||
document.body.appendChild(link); |
|||
link.click(); |
|||
document.body.removeChild(link); |
|||
closeDialog(); |
|||
URL.revokeObjectURL(url); |
|||
}, [decodeKeyData, privateKey, publicKey, closeDialog]); |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogHeader> |
|||
<DialogTitle>Backup Keys</DialogTitle> |
|||
<DialogDescription> |
|||
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 keys, you will need to reset your device. |
|||
</span> |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
<DialogFooter className="mt-6"> |
|||
<Button |
|||
variant={"default"} |
|||
onClick={() => createDownloadKeyFile()} |
|||
className="" |
|||
> |
|||
<DownloadIcon size={20} className="mr-2" /> |
|||
Download |
|||
</Button> |
|||
<Button variant={"default"} onClick={() => renderPrintWindow()}> |
|||
<PrinterIcon size={20} className="mr-2" /> |
|||
Print |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -0,0 +1,19 @@ |
|||
import { useBackupReminder } from "@app/core/hooks/useKeyBackupReminder"; |
|||
import { useDevice } from "@app/core/stores/deviceStore"; |
|||
|
|||
export const KeyBackupReminder = (): JSX.Element => { |
|||
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), |
|||
enabled: true, |
|||
cookieOptions: { |
|||
secure: true, |
|||
sameSite: "strict", |
|||
}, |
|||
}); |
|||
return <></>; |
|||
}; |
|||
@ -0,0 +1,171 @@ |
|||
import { Separator } from "@app/components/UI/Seperator"; |
|||
import { H5 } from "@app/components/UI/Typography/H5.tsx"; |
|||
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx"; |
|||
import { Avatar } from "@components/UI/Avatar"; |
|||
import { Mono } from "@components/generic/Mono.tsx"; |
|||
import { TimeAgo } from "@components/generic/Table/tmp/TimeAgo.tsx"; |
|||
import { Protobuf } from "@meshtastic/js"; |
|||
import type { Protobuf as ProtobufType } from "@meshtastic/js"; |
|||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import { |
|||
BatteryChargingIcon, |
|||
BatteryFullIcon, |
|||
BatteryLowIcon, |
|||
BatteryMediumIcon, |
|||
Dot, |
|||
LockIcon, |
|||
LockOpenIcon, |
|||
MountainSnow, |
|||
Star, |
|||
} from "lucide-react"; |
|||
|
|||
export interface NodeDetailProps { |
|||
node: ProtobufType.Mesh.NodeInfo; |
|||
} |
|||
|
|||
export const NodeDetail = ({ node }: NodeDetailProps) => { |
|||
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`; |
|||
const hardwareType = Protobuf.Mesh.HardwareModel[ |
|||
node.user?.hwModel ?? 0 |
|||
].replaceAll("_", " "); |
|||
|
|||
return ( |
|||
<div className="dark:text-black"> |
|||
<div className="flex gap-2"> |
|||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1"> |
|||
<Avatar text={node.user?.shortName} /> |
|||
|
|||
<div> |
|||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? ( |
|||
<LockIcon |
|||
className="text-green-600" |
|||
size={12} |
|||
strokeWidth={3} |
|||
aria-label="Public Key Enabled" |
|||
/> |
|||
) : ( |
|||
<LockOpenIcon |
|||
className="text-yellow-500" |
|||
size={12} |
|||
strokeWidth={3} |
|||
aria-label="No Public Key" |
|||
/> |
|||
)} |
|||
</div> |
|||
|
|||
<Star |
|||
fill={node.isFavorite ? "black" : "none"} |
|||
size={15} |
|||
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"} |
|||
/> |
|||
</div> |
|||
|
|||
<div> |
|||
<H5>{name}</H5> |
|||
|
|||
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>} |
|||
|
|||
{!!node.deviceMetrics?.batteryLevel && ( |
|||
<div |
|||
className="flex items-center gap-1" |
|||
title={`${ |
|||
node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown" |
|||
} volts`}
|
|||
> |
|||
{node.deviceMetrics?.batteryLevel > 100 ? ( |
|||
<BatteryChargingIcon size={22} /> |
|||
) : node.deviceMetrics?.batteryLevel > 80 ? ( |
|||
<BatteryFullIcon size={22} /> |
|||
) : node.deviceMetrics?.batteryLevel > 20 ? ( |
|||
<BatteryMediumIcon size={22} /> |
|||
) : ( |
|||
<BatteryLowIcon size={22} /> |
|||
)} |
|||
<Subtle aria-label="Battery"> |
|||
{node.deviceMetrics?.batteryLevel > 100 |
|||
? "Charging" |
|||
: `${node.deviceMetrics?.batteryLevel}%`} |
|||
</Subtle> |
|||
</div> |
|||
)} |
|||
|
|||
<div className="flex gap-2 items-center"> |
|||
{node.user?.shortName && <div>"{node.user?.shortName}"</div>} |
|||
{node.user?.id && <div>{node.user?.id}</div>} |
|||
</div> |
|||
|
|||
<div |
|||
className="flex gap-1" |
|||
title={new Date(node.lastHeard * 1000).toLocaleString( |
|||
navigator.language, |
|||
)} |
|||
> |
|||
<div> |
|||
{node.lastHeard > 0 && ( |
|||
<div> |
|||
Heard <TimeAgo timestamp={node.lastHeard * 1000} /> |
|||
</div> |
|||
)} |
|||
</div> |
|||
{node.viaMqtt && ( |
|||
<div style={{ color: "#660066" }} className="font-medium"> |
|||
MQTT |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<Separator className="my-1" /> |
|||
|
|||
<div className="flex mt-2 text-sm"> |
|||
<div className="flex items-center flex-grow"> |
|||
<div className="border-2 border-black rounded px-0.5 mr-1"> |
|||
{Number.isNaN(node.hopsAway) ? "?" : node.hopsAway} |
|||
</div> |
|||
<div>{node.hopsAway === 1 ? "Hop" : "Hops"}</div> |
|||
</div> |
|||
{node.position?.altitude && ( |
|||
<div className="flex items-center flex-grow"> |
|||
<MountainSnow |
|||
size={15} |
|||
className="ml-2 mr-1" |
|||
aria-label="Elevation" |
|||
/> |
|||
<div>{node.position?.altitude} ft</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
|
|||
<div className="flex mt-2"> |
|||
{!!node.deviceMetrics?.channelUtilization && ( |
|||
<div className="flex-grow"> |
|||
<div>Channel Util</div> |
|||
<Mono> |
|||
{node.deviceMetrics?.channelUtilization.toPrecision(3)}% |
|||
</Mono> |
|||
</div> |
|||
)} |
|||
{!!node.deviceMetrics?.airUtilTx && ( |
|||
<div className="flex-grow"> |
|||
<div>Airtime Util</div> |
|||
<Mono>{node.deviceMetrics?.airUtilTx.toPrecision(3)}%</Mono> |
|||
</div> |
|||
)} |
|||
</div> |
|||
|
|||
{node.snr !== 0 && ( |
|||
<div className="mt-2"> |
|||
<div>SNR</div> |
|||
<Mono className="flex items-center text-xs"> |
|||
{node.snr}db |
|||
<Dot /> |
|||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}% |
|||
<Dot /> |
|||
{(node.snr + 10) * 5}raw |
|||
</Mono> |
|||
</div> |
|||
)} |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,94 @@ |
|||
import { cn } from "@app/core/utils/cn"; |
|||
import type React from "react"; |
|||
|
|||
type RGBColor = { |
|||
r: number; |
|||
g: number; |
|||
b: number; |
|||
a: number; |
|||
}; |
|||
|
|||
interface AvatarProps { |
|||
text: string; |
|||
size?: "sm" | "lg"; |
|||
className?: string; |
|||
} |
|||
|
|||
// biome-ignore lint/complexity/noStaticOnlyClass: stop being annoying Biome
|
|||
class ColorUtils { |
|||
static hexToRgb(hex: number): RGBColor { |
|||
return { |
|||
r: (hex & 0xff0000) >> 16, |
|||
g: (hex & 0x00ff00) >> 8, |
|||
b: hex & 0x0000ff, |
|||
a: 255, |
|||
}; |
|||
} |
|||
|
|||
static rgbToHex(color: RGBColor): number { |
|||
return ( |
|||
(Math.round(color.a) << 24) | |
|||
(Math.round(color.r) << 16) | |
|||
(Math.round(color.g) << 8) | |
|||
Math.round(color.b) |
|||
); |
|||
} |
|||
|
|||
static isLight(color: RGBColor): boolean { |
|||
const brightness = (color.r * 299 + color.g * 587 + color.b * 114) / 1000; |
|||
return brightness > 127.5; |
|||
} |
|||
} |
|||
|
|||
export const Avatar: React.FC<AvatarProps> = ({ |
|||
text, |
|||
size = "sm", |
|||
className, |
|||
}) => { |
|||
const sizes = { |
|||
sm: "size-11 text-xs", |
|||
lg: "size-16 text-lg", |
|||
}; |
|||
|
|||
// Pick a color based on the text provided to function
|
|||
const getColorFromText = (text: string): RGBColor => { |
|||
let hash = 0; |
|||
for (let i = 0; i < text.length; i++) { |
|||
hash = text.charCodeAt(i) + ((hash << 5) - hash); |
|||
} |
|||
|
|||
return { |
|||
r: (hash & 0xff0000) >> 16, |
|||
g: (hash & 0x00ff00) >> 8, |
|||
b: hash & 0x0000ff, |
|||
a: 255, |
|||
}; |
|||
}; |
|||
|
|||
const bgColor = getColorFromText(text ?? "UNK"); |
|||
const isLight = ColorUtils.isLight(bgColor); |
|||
const textColor = isLight ? "#000000" : "#FFFFFF"; |
|||
const initials = text?.toUpperCase().slice(0, 4) ?? "UNK"; |
|||
|
|||
return ( |
|||
<div |
|||
className={cn( |
|||
` |
|||
rounded-full |
|||
flex |
|||
items-center |
|||
justify-center |
|||
size-11 |
|||
font-semibold`,
|
|||
sizes[size], |
|||
className, |
|||
)} |
|||
style={{ |
|||
backgroundColor: `rgb(${bgColor.r}, ${bgColor.g}, ${bgColor.b})`, |
|||
color: textColor, |
|||
}} |
|||
> |
|||
<p className="p-1">{initials}</p> |
|||
</div> |
|||
); |
|||
}; |
|||
@ -0,0 +1,14 @@ |
|||
import { cn } from "@app/core/utils/cn.ts"; |
|||
|
|||
export interface H5Props { |
|||
className?: string; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
export const H5 = ({ className, children }: H5Props): JSX.Element => ( |
|||
<h5 |
|||
className={cn("scroll-m-20 text-lg font-medium tracking-tight", className)} |
|||
> |
|||
{children} |
|||
</h5> |
|||
); |
|||
@ -0,0 +1,33 @@ |
|||
import { useMemo } from "react"; |
|||
|
|||
export type BrowserFeature = "Web Bluetooth" | "Web Serial" | "Secure Context"; |
|||
|
|||
interface BrowserSupport { |
|||
supported: BrowserFeature[]; |
|||
unsupported: BrowserFeature[]; |
|||
} |
|||
|
|||
export function useBrowserFeatureDetection(): BrowserSupport { |
|||
const support = useMemo(() => { |
|||
const features: [BrowserFeature, boolean][] = [ |
|||
["Web Bluetooth", !!navigator?.bluetooth], |
|||
["Web Serial", !!navigator?.serial], |
|||
[ |
|||
"Secure Context", |
|||
window.location.protocol === "https:" || |
|||
window.location.hostname === "localhost", |
|||
], |
|||
]; |
|||
|
|||
return features.reduce<BrowserSupport>( |
|||
(acc, [feature, isSupported]) => { |
|||
const list = isSupported ? acc.supported : acc.unsupported; |
|||
list.push(feature); |
|||
return acc; |
|||
}, |
|||
{ supported: [], unsupported: [] }, |
|||
); |
|||
}, []); |
|||
|
|||
return support; |
|||
} |
|||
@ -0,0 +1,52 @@ |
|||
import Cookies, { type CookieAttributes } from "js-cookie"; |
|||
import { useCallback, useState } from "react"; |
|||
|
|||
interface CookieHookResult<T> { |
|||
value: T | undefined; |
|||
setCookie: (value: T, options?: CookieAttributes) => void; |
|||
removeCookie: () => void; |
|||
} |
|||
|
|||
function useCookie<T extends object>( |
|||
cookieName: string, |
|||
initialValue?: T, |
|||
): 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 = useCallback( |
|||
(value: T, options?: CookieAttributes) => { |
|||
try { |
|||
Cookies.set(cookieName, JSON.stringify(value), options); |
|||
setCookieValue(value); |
|||
} catch (error) { |
|||
console.error(`Error setting cookie ${cookieName}:`, error); |
|||
} |
|||
}, |
|||
[cookieName], |
|||
); |
|||
|
|||
const removeCookie = useCallback(() => { |
|||
try { |
|||
Cookies.remove(cookieName); |
|||
setCookieValue(undefined); |
|||
} catch (error) { |
|||
console.error(`Error removing cookie ${cookieName}:`, error); |
|||
} |
|||
}, [cookieName]); |
|||
|
|||
return { |
|||
value: cookieValue, |
|||
setCookie, |
|||
removeCookie, |
|||
}; |
|||
} |
|||
|
|||
export default useCookie; |
|||
@ -0,0 +1,119 @@ |
|||
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 { |
|||
reminderInDays?: number; |
|||
message: string; |
|||
onAccept?: () => void | Promise<void>; |
|||
enabled: boolean; |
|||
cookieOptions?: CookieAttributes; |
|||
} |
|||
|
|||
interface ReminderState { |
|||
suppressed: boolean; |
|||
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 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({ |
|||
reminderInDays = 7, |
|||
enabled, |
|||
message, |
|||
onAccept = () => {}, |
|||
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 shouldShowReminder = |
|||
!reminderCookie?.suppressed || |
|||
isReminderExpired(reminderCookie.lastShown); |
|||
if (!shouldShowReminder) return; |
|||
|
|||
toastShownRef.current = true; |
|||
|
|||
const { dismiss } = toast({ |
|||
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> |
|||
), |
|||
}); |
|||
|
|||
return () => { |
|||
if (!toastShownRef.current) { |
|||
dismiss(); |
|||
} |
|||
}; |
|||
}, [ |
|||
enabled, |
|||
message, |
|||
onAccept, |
|||
reminderInDays, |
|||
suppressReminder, |
|||
toast, |
|||
reminderCookie, |
|||
]); |
|||
} |
|||
@ -0,0 +1,13 @@ |
|||
type Callback<Args extends unknown[]> = (...args: Args) => void; |
|||
|
|||
export function debounce<Args extends unknown[]>( |
|||
callback: Callback<Args>, |
|||
wait: number, |
|||
): Callback<Args> { |
|||
let timeoutId: ReturnType<typeof setTimeout>; |
|||
|
|||
return (...args: Args) => { |
|||
clearTimeout(timeoutId); |
|||
timeoutId = setTimeout(() => callback(...args), wait); |
|||
}; |
|||
} |
|||
Loading…
Reference in new issue