From db2cb8cb422e7373f59ea86c3475ab5ba7dfe697 Mon Sep 17 00:00:00 2001 From: Hunter275 Date: Thu, 13 Mar 2025 01:29:03 -0400 Subject: [PATCH 1/8] update style and wording of browser support for connection types --- src/components/Dialog/NewDeviceDialog.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/Dialog/NewDeviceDialog.tsx b/src/components/Dialog/NewDeviceDialog.tsx index 192c51a6..a19261e3 100644 --- a/src/components/Dialog/NewDeviceDialog.tsx +++ b/src/components/Dialog/NewDeviceDialog.tsx @@ -52,7 +52,7 @@ const links: { [key: string]: string } = { const listFormatter = new Intl.ListFormat("en", { style: "long", - type: "conjunction", + type: "disjunction", }); const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => { @@ -78,16 +78,16 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => { }; return ( - +
- +
-

+

{browserFeatures.length > 0 && ( <> - This application requires{" "} + This connection type requires{" "} {formatFeatureList(browserFeatures)}. Please use a - Chromium-based browser like Chrome or Edge. + supported browser, like Chrome or Edge. )} {needsSecureContext && ( From 28cc7b9800f0620598ce7eb22a07eb2786474365 Mon Sep 17 00:00:00 2001 From: bkimmel Date: Wed, 19 Mar 2025 17:50:20 -0400 Subject: [PATCH 2/8] reorder columns in Nodes page --- src/pages/Nodes.tsx | 90 +++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 43 deletions(-) diff --git a/src/pages/Nodes.tsx b/src/pages/Nodes.tsx index c0a9e6f5..a5c33c7b 100644 --- a/src/pages/Nodes.tsx +++ b/src/pages/Nodes.tsx @@ -19,12 +19,17 @@ export interface DeleteNoteDialogProps { onOpenChange: (open: boolean) => void; } -function shortNameFromNode(node: ReturnType["nodes"][number]): string { - const shortNameOfNode = node.user?.shortName ?? (node.user?.macaddr - ? `${base16 - .stringify(node.user?.macaddr.subarray(4, 6) ?? []) - .toLowerCase()}` - : `${numberToHexUnpadded(node.num).slice(-4)}`); +function shortNameFromNode( + node: ReturnType["nodes"][number], +): string { + const shortNameOfNode = node.user?.shortName ?? + (node.user?.macaddr + ? `${ + base16 + .stringify(node.user?.macaddr.subarray(4, 6) ?? []) + .toLowerCase() + }` + : `${numberToHexUnpadded(node.num).slice(-4)}`); return String(shortNameOfNode); } @@ -72,7 +77,6 @@ const NodesPage = (): JSX.Element => { }; }, [connection]); - const handleLocation = useCallback( (location: Types.PacketMetadata) => { if (location.to.valueOf() !== hardware.myNodeNum) return; @@ -99,12 +103,12 @@ const NodesPage = (): JSX.Element => { headings={[ { title: "", type: "blank", sortable: false }, { title: "Long Name", type: "normal", sortable: true }, - { title: "Model", type: "normal", sortable: true }, - { title: "MAC Address", type: "normal", sortable: true }, + { title: "Connection", type: "normal", sortable: true }, { title: "Last Heard", type: "normal", sortable: true }, - { title: "SNR", type: "normal", sortable: true }, { title: "Encryption", type: "normal", sortable: false }, - { title: "Connection", type: "normal", sortable: true }, + { title: "SNR", type: "normal", sortable: true }, + { title: "Model", type: "normal", sortable: true }, + { title: "MAC Address", type: "normal", sortable: true }, ]} rows={filteredNodes.map((node) => [

@@ -113,55 +117,55 @@ const NodesPage = (): JSX.Element => {

setSelectedNode(node)} - onKeyUp={(evt)=>{ evt.key === "Enter" && setSelectedNode(node) }} + onKeyUp={(evt) => { + evt.key === "Enter" && setSelectedNode(node); + }} className="cursor-pointer underline" tabIndex={0} role="button" > {node.user?.longName ?? (node.user?.macaddr - ? `Meshtastic ${base16 - .stringify(node.user?.macaddr.subarray(4, 6) ?? []) - .toLowerCase()}` + ? `Meshtastic ${ + base16 + .stringify(node.user?.macaddr.subarray(4, 6) ?? []) + .toLowerCase() + }` : `!${numberToHexUnpadded(node.num)}`)}

, - - - {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} - , - - {base16 - .stringify(node.user?.macaddr ?? []) - .match(/.{1,2}/g) - ?.join(":") ?? "UNK"} + + {node.lastHeard !== 0 + ? node.viaMqtt === false && node.hopsAway === 0 + ? "Direct" + : `${node.hopsAway?.toString()} ${ + node.hopsAway > 1 ? "hops" : "hop" + } away` + : "-"} + {node.viaMqtt === true ? ", via MQTT" : ""} , - {node.lastHeard === 0 ? ( -

Never

- ) : ( - - )} + {node.lastHeard === 0 + ?

Never

+ : } +
, + + {node.user?.publicKey && node.user?.publicKey.length > 0 + ? + : } , {node.snr}db/ {Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/ {(node.snr + 10) * 5}raw , - - {node.user?.publicKey && node.user?.publicKey.length > 0 ? ( - - ) : ( - - )} + + {Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]} , - - {node.lastHeard !== 0 - ? node.viaMqtt === false && node.hopsAway === 0 - ? "Direct" - : `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop" - } away` - : "-"} - {node.viaMqtt === true ? ", via MQTT" : ""} + + {base16 + .stringify(node.user?.macaddr ?? []) + .match(/.{1,2}/g) + ?.join(":") ?? "UNK"} , ])} /> From 6341d564d323c32567d4c1175307a7656e356dba Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Thu, 20 Mar 2025 12:06:21 -0400 Subject: [PATCH 3/8] feat: update readme with domain changes --- README.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README.md b/README.md index a6e50189..7bd44dc5 100644 --- a/README.md +++ b/README.md @@ -138,3 +138,29 @@ reasons: environments. - **Web Standard APIs**: Uses browser-compatible APIs, making code more portable between server and client environments. + +### Contributing + +We welcome contributions! Here’s how the deployment flow works for pull +requests: + +- **Preview Deployments:**\ + Every pull request automatically generates a preview deployment on Vercel. + This allows you and reviewers to easily preview changes before merging. + +- **Staging Environment (`client-test`):**\ + Once your PR is merged, your changes will be available on our staging site: + [client-test.meshtastic.org](https://client-test.meshtastic.org/).\ + This environment supports rapid feature iteration and testing without + impacting the production site. + +- **Production Releases:**\ + At regular intervals, stable and fully tested releases are promoted to our + production site: [client.meshtastic.org](https://client.meshtastic.org/).\ + This is the primary interface used by the public to connect with their + Meshtastic nodes. + +Please review our +[Contribution Guidelines](https://github.com/meshtastic/web/blob/master/CONTRIBUTING.md) +before submitting a pull request. We appreciate your help in making the project +better! From 22dbfbcc09837ac8a5dea7ce99a196bef3714c86 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Thu, 20 Mar 2025 14:40:15 -0400 Subject: [PATCH 4/8] feat: added never remind me to key reminder. --- src/components/KeyBackupReminder.tsx | 4 - src/components/Toaster.tsx | 4 +- src/core/hooks/useKeyBackupReminder.tsx | 118 +++++++++++++----------- src/core/hooks/useLocalStorage.test.ts | 52 +++++++++++ src/core/hooks/useToast.test.tsx | 81 ++++++++++++++++ src/core/hooks/useToast.ts | 2 +- 6 files changed, 200 insertions(+), 61 deletions(-) create mode 100644 src/core/hooks/useLocalStorage.test.ts create mode 100644 src/core/hooks/useToast.test.tsx diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx index 7ae60ead..777c28cc 100644 --- a/src/components/KeyBackupReminder.tsx +++ b/src/components/KeyBackupReminder.tsx @@ -10,10 +10,6 @@ export const KeyBackupReminder = () => { "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", - }, }); // deno-lint-ignore jsx-no-useless-fragment return <>; diff --git a/src/components/Toaster.tsx b/src/components/Toaster.tsx index b42044bb..b2e76e43 100644 --- a/src/components/Toaster.tsx +++ b/src/components/Toaster.tsx @@ -5,8 +5,8 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "./UI/Toast.tsx"; -import { useToast } from "../core/hooks/useToast.ts"; +} from "@components/UI/Toast.tsx"; +import { useToast } from "@core/hooks/useToast.ts"; export function Toaster() { const { toasts } = useToast(); diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index 1f6b17bd..f1009095 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -1,15 +1,13 @@ -import { Button } from "../../components/UI/Button.tsx"; -import type { CookieAttributes } from "js-cookie"; +import { Button } from "@components/UI/Button.tsx"; import { useCallback, useEffect, useRef } from "react"; -import useCookie from "./useCookie.ts"; -import { useToast } from "./useToast.ts"; +import { useToast } from "@core/hooks/useToast.ts"; +import useLocalStorage from "@core/hooks/useLocalStorage.ts"; interface UseBackupReminderOptions { reminderInDays?: number; message: string; onAccept?: () => void | Promise; enabled: boolean; - cookieOptions?: CookieAttributes; } interface ReminderState { @@ -17,17 +15,15 @@ interface ReminderState { 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 TOAST_APPEAR_DELAY = 10_000; // 10 seconds +const TOAST_DURATION = 30_000; // 30 seconds const ON_ACCEPT_REMINDER_DAYS = 365; +const STORAGE_KEY = "key_backup_reminder"; function isReminderExpired(lastShown: string): boolean { const lastShownDate = new Date(lastShown); const now = new Date(); - const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) / - (1000 * 60 * 60 * 24); + const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) / (1000 * 60 * 60 * 24); return daysSinceLastShown >= 7; } @@ -35,36 +31,32 @@ export function useBackupReminder({ reminderInDays = 7, enabled, message, - onAccept = () => {}, - cookieOptions, + onAccept = () => { }, }: UseBackupReminderOptions) { const { toast } = useToast(); const toastShownRef = useRef(false); - const { value: reminderCookie, setCookie } = useCookie( - "key_backup_reminder", + const [reminderState, setReminderState] = useLocalStorage( + STORAGE_KEY, + null ); - const suppressReminder = useCallback( - (days: number) => { - const expiryDate = new Date(); - expiryDate.setDate(expiryDate.getDate() + days); + // Suppress reminder for 10 years if not specified + const suppressReminder = useCallback((days: number = 3563) => { + const expiryDate = new Date(); + expiryDate.setDate(expiryDate.getDate() + days); - setCookie( - { - suppressed: true, - lastShown: new Date().toISOString(), - }, - { ...cookieOptions, expires: expiryDate }, - ); - }, - [setCookie, cookieOptions], - ); + setReminderState({ + suppressed: true, + lastShown: new Date().toISOString(), + }); + }, [setReminderState]); useEffect(() => { if (!enabled || toastShownRef.current) return; - const shouldShowReminder = !reminderCookie?.suppressed || - isReminderExpired(reminderCookie.lastShown); + const shouldShowReminder = + !reminderState?.suppressed || isReminderExpired(reminderState.lastShown); + if (!shouldShowReminder) return; toastShownRef.current = true; @@ -75,28 +67,46 @@ export function useBackupReminder({ delay: TOAST_APPEAR_DELAY, description: message, action: ( -
- - +
+
+ + + +
+
+ +
), }); @@ -113,6 +123,6 @@ export function useBackupReminder({ reminderInDays, suppressReminder, toast, - reminderCookie, + reminderState, ]); } diff --git a/src/core/hooks/useLocalStorage.test.ts b/src/core/hooks/useLocalStorage.test.ts new file mode 100644 index 00000000..8d621a57 --- /dev/null +++ b/src/core/hooks/useLocalStorage.test.ts @@ -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') + }) +}) diff --git a/src/core/hooks/useToast.test.tsx b/src/core/hooks/useToast.test.tsx new file mode 100644 index 00000000..9125da75 --- /dev/null +++ b/src/core/hooks/useToast.test.tsx @@ -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: + }) + 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() + }) + +}) diff --git a/src/core/hooks/useToast.ts b/src/core/hooks/useToast.ts index 3269eee9..d728537f 100644 --- a/src/core/hooks/useToast.ts +++ b/src/core/hooks/useToast.ts @@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) { ...props, id, open: true, - onOpenChange: (open) => { + onOpenChange: (open: boolean) => { if (!open) dismiss(); }, }, From 890674eea39dd2d3f45d6dbe46f5a736f65b913b Mon Sep 17 00:00:00 2001 From: Hunter Thornsberry Date: Fri, 21 Mar 2025 14:26:19 -0400 Subject: [PATCH 5/8] subtract one from node count --- src/components/Sidebar.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 69707202..e257cff8 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -58,7 +58,7 @@ export const Sidebar = ({ children }: SidebarProps) => { page: "channels", }, { - name: `Nodes (${nodes.size})`, + name: `Nodes (${nodes.size - 1})`, icon: UsersIcon, page: "nodes", }, From 1780c6fb2a7dee58e973723eaee39c497ffe7c18 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 21 Mar 2025 22:39:40 -0400 Subject: [PATCH 6/8] refactor: updated how expiry dates are handled. --- src/components/KeyBackupReminder.tsx | 1 - src/core/hooks/useKeyBackupReminder.tsx | 76 ++++++++++--------------- 2 files changed, 29 insertions(+), 48 deletions(-) diff --git a/src/components/KeyBackupReminder.tsx b/src/components/KeyBackupReminder.tsx index 777c28cc..80894cba 100644 --- a/src/components/KeyBackupReminder.tsx +++ b/src/components/KeyBackupReminder.tsx @@ -5,7 +5,6 @@ export const KeyBackupReminder = () => { 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), diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index f1009095..1669c429 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -11,27 +11,27 @@ interface UseBackupReminderOptions { } interface ReminderState { - suppressed: boolean; - lastShown: string; + expires: string; } const TOAST_APPEAR_DELAY = 10_000; // 10 seconds const TOAST_DURATION = 30_000; // 30 seconds -const ON_ACCEPT_REMINDER_DAYS = 365; +const REMINDER_DAYS_ONE_WEEK = 7; +const REMINDER_DAYS_ONE_YEAR = 365; +const REMINDER_DAYS_FOREVER = 3650; const STORAGE_KEY = "key_backup_reminder"; -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; +function isReminderExpired(expires?: string): boolean { + if (!expires) return true; + const expiryDate = new Date(expires); + return isNaN(expiryDate.getTime()) || new Date() >= expiryDate; } export function useBackupReminder({ - reminderInDays = 7, enabled, message, onAccept = () => { }, + reminderInDays = REMINDER_DAYS_ONE_WEEK, }: UseBackupReminderOptions) { const { toast } = useToast(); const toastShownRef = useRef(false); @@ -40,24 +40,16 @@ export function useBackupReminder({ null ); - // Suppress reminder for 10 years if not specified - const suppressReminder = useCallback((days: number = 3563) => { + const setReminderExpiry = useCallback((days: number) => { const expiryDate = new Date(); expiryDate.setDate(expiryDate.getDate() + days); - - setReminderState({ - suppressed: true, - lastShown: new Date().toISOString(), - }); + setReminderState({ expires: expiryDate.toISOString() }); }, [setReminderState]); useEffect(() => { if (!enabled || toastShownRef.current) return; - const shouldShowReminder = - !reminderState?.suppressed || isReminderExpired(reminderState.lastShown); - - if (!shouldShowReminder) return; + if (!isReminderExpired(reminderState?.expires)) return; toastShownRef.current = true; @@ -69,14 +61,13 @@ export function useBackupReminder({ action: (
-
-
- -
+
), }); - return () => { - if (!toastShownRef.current) { - dismiss(); - } - }; + return () => dismiss(); }, [ enabled, message, onAccept, - reminderInDays, - suppressReminder, - toast, - reminderState, + ]); -} +}; \ No newline at end of file From a7a448cbcde8379dbafc1b8d7beb552671503ac1 Mon Sep 17 00:00:00 2001 From: Dan Ditomaso Date: Fri, 21 Mar 2025 23:34:20 -0400 Subject: [PATCH 7/8] refactor: improved how reminder expiry dates are handled. --- src/core/hooks/useKeyBackupReminder.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/core/hooks/useKeyBackupReminder.tsx b/src/core/hooks/useKeyBackupReminder.tsx index 1669c429..3cc46842 100644 --- a/src/core/hooks/useKeyBackupReminder.tsx +++ b/src/core/hooks/useKeyBackupReminder.tsx @@ -24,7 +24,10 @@ const STORAGE_KEY = "key_backup_reminder"; function isReminderExpired(expires?: string): boolean { if (!expires) return true; const expiryDate = new Date(expires); - return isNaN(expiryDate.getTime()) || new Date() >= expiryDate; + if (isNaN(expiryDate.getTime())) return true; // Invalid date passed + + const now = new Date(); + return now.getTime() >= expiryDate.getTime(); } export function useBackupReminder({ @@ -70,7 +73,7 @@ export function useBackupReminder({ setReminderExpiry(reminderInDays); }} > - Remind me in {reminderInDays} days + Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''}