Merge branch 'master' into add-message-persistance
This commit is contained in:
@@ -138,3 +138,29 @@ reasons:
|
|||||||
environments.
|
environments.
|
||||||
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
|
- **Web Standard APIs**: Uses browser-compatible APIs, making code more portable
|
||||||
between server and client environments.
|
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!
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const links: { [key: string]: string } = {
|
|||||||
|
|
||||||
const listFormatter = new Intl.ListFormat("en", {
|
const listFormatter = new Intl.ListFormat("en", {
|
||||||
style: "long",
|
style: "long",
|
||||||
type: "conjunction",
|
type: "disjunction",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
||||||
@@ -79,16 +79,16 @@ const ErrorMessage = ({ missingFeatures }: FeatureErrorProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Subtle className="flex flex-col items-start gap-2 text-slate-900 bg-red-200/80 p-4 rounded-md">
|
<Subtle className="flex flex-col items-start gap-2 bg-red-500 p-4 rounded-md">
|
||||||
<div className="flex items-center gap-2 w-full">
|
<div className="flex items-center gap-2 w-full">
|
||||||
<AlertCircle size={40} className="mr-2 shrink-0" />
|
<AlertCircle size={40} className="mr-2 shrink-0 text-white" />
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<p className="text-sm">
|
<p className="text-sm text-white">
|
||||||
{browserFeatures.length > 0 && (
|
{browserFeatures.length > 0 && (
|
||||||
<>
|
<>
|
||||||
This application requires{" "}
|
This connection type requires{" "}
|
||||||
{formatFeatureList(browserFeatures)}. Please use a
|
{formatFeatureList(browserFeatures)}. Please use a
|
||||||
Chromium-based browser like Chrome or Edge.
|
supported browser, like Chrome or Edge.
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{needsSecureContext && (
|
{needsSecureContext && (
|
||||||
|
|||||||
@@ -5,15 +5,10 @@ export const KeyBackupReminder = () => {
|
|||||||
const { setDialogOpen } = useDevice();
|
const { setDialogOpen } = useDevice();
|
||||||
|
|
||||||
useBackupReminder({
|
useBackupReminder({
|
||||||
reminderInDays: 7,
|
|
||||||
message:
|
message:
|
||||||
"We recommend backing up your key data regularly. Would you like to back up now?",
|
"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,
|
enabled: true,
|
||||||
cookieOptions: {
|
|
||||||
secure: true,
|
|
||||||
sameSite: "strict",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
// deno-lint-ignore jsx-no-useless-fragment
|
// deno-lint-ignore jsx-no-useless-fragment
|
||||||
return <></>;
|
return <></>;
|
||||||
|
|||||||
@@ -74,8 +74,8 @@ export const Position = () => {
|
|||||||
name: "positionFlags",
|
name: "positionFlags",
|
||||||
value: activeFlags,
|
value: activeFlags,
|
||||||
isChecked: (name: string) =>
|
isChecked: (name: string) =>
|
||||||
activeFlags?.includes(name as FlagName),
|
activeFlags?.includes(name as FlagName) ?? false,
|
||||||
onValueChange: onPositonFlagChange,
|
\ onValueChange: onPositonFlagChange,
|
||||||
label: "Position Flags",
|
label: "Position Flags",
|
||||||
placeholder: "Select position flags...",
|
placeholder: "Select position flags...",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
page: "channels",
|
page: "channels",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: `Nodes (${nodes.size})`,
|
name: `Nodes (${nodes.size - 1})`,
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
page: "nodes",
|
page: "nodes",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import {
|
|||||||
ToastProvider,
|
ToastProvider,
|
||||||
ToastTitle,
|
ToastTitle,
|
||||||
ToastViewport,
|
ToastViewport,
|
||||||
} from "./UI/Toast.tsx";
|
} from "@components/UI/Toast.tsx";
|
||||||
import { useToast } from "../core/hooks/useToast.ts";
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
|
|
||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const { toasts } = useToast();
|
const { toasts } = useToast();
|
||||||
|
|||||||
@@ -1,71 +1,58 @@
|
|||||||
import { Button } from "../../components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import type { CookieAttributes } from "js-cookie";
|
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
import useCookie from "./useCookie.ts";
|
import { useToast } from "@core/hooks/useToast.ts";
|
||||||
import { useToast } from "./useToast.ts";
|
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
|
||||||
|
|
||||||
interface UseBackupReminderOptions {
|
interface UseBackupReminderOptions {
|
||||||
reminderInDays?: number;
|
reminderInDays?: number;
|
||||||
message: string;
|
message: string;
|
||||||
onAccept?: () => void | Promise<void>;
|
onAccept?: () => void | Promise<void>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
cookieOptions?: CookieAttributes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReminderState {
|
interface ReminderState {
|
||||||
suppressed: boolean;
|
expires: string;
|
||||||
lastShown: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds;
|
const TOAST_APPEAR_DELAY = 10_000; // 10 seconds
|
||||||
const TOAST_DURATION = 30_000; // 30 seconds;:
|
const TOAST_DURATION = 30_000; // 30 seconds
|
||||||
|
const REMINDER_DAYS_ONE_WEEK = 7;
|
||||||
|
const REMINDER_DAYS_ONE_YEAR = 365;
|
||||||
|
const REMINDER_DAYS_FOREVER = 3650;
|
||||||
|
const STORAGE_KEY = "key_backup_reminder";
|
||||||
|
|
||||||
// remind user in 1 year to backup keys again, if they accept the reminder;
|
function isReminderExpired(expires?: string): boolean {
|
||||||
const ON_ACCEPT_REMINDER_DAYS = 365;
|
if (!expires) return true;
|
||||||
|
const expiryDate = new Date(expires);
|
||||||
|
if (isNaN(expiryDate.getTime())) return true; // Invalid date passed
|
||||||
|
|
||||||
function isReminderExpired(lastShown: string): boolean {
|
|
||||||
const lastShownDate = new Date(lastShown);
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const daysSinceLastShown = (now.getTime() - lastShownDate.getTime()) /
|
return now.getTime() >= expiryDate.getTime();
|
||||||
(1000 * 60 * 60 * 24);
|
|
||||||
return daysSinceLastShown >= 7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBackupReminder({
|
export function useBackupReminder({
|
||||||
reminderInDays = 7,
|
|
||||||
enabled,
|
enabled,
|
||||||
message,
|
message,
|
||||||
onAccept = () => {},
|
onAccept = () => { },
|
||||||
cookieOptions,
|
reminderInDays = REMINDER_DAYS_ONE_WEEK,
|
||||||
}: UseBackupReminderOptions) {
|
}: UseBackupReminderOptions) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const toastShownRef = useRef(false);
|
const toastShownRef = useRef(false);
|
||||||
const { value: reminderCookie, setCookie } = useCookie<ReminderState>(
|
const [reminderState, setReminderState] = useLocalStorage<ReminderState | null>(
|
||||||
"key_backup_reminder",
|
STORAGE_KEY,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
const suppressReminder = useCallback(
|
const setReminderExpiry = useCallback((days: number) => {
|
||||||
(days: number) => {
|
const expiryDate = new Date();
|
||||||
const expiryDate = new Date();
|
expiryDate.setDate(expiryDate.getDate() + days);
|
||||||
expiryDate.setDate(expiryDate.getDate() + days);
|
setReminderState({ expires: expiryDate.toISOString() });
|
||||||
|
}, [setReminderState]);
|
||||||
setCookie(
|
|
||||||
{
|
|
||||||
suppressed: true,
|
|
||||||
lastShown: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
{ ...cookieOptions, expires: expiryDate },
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[setCookie, cookieOptions],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || toastShownRef.current) return;
|
if (!enabled || toastShownRef.current) return;
|
||||||
|
|
||||||
const shouldShowReminder = !reminderCookie?.suppressed ||
|
if (!isReminderExpired(reminderState?.expires)) return;
|
||||||
isReminderExpired(reminderCookie.lastShown);
|
|
||||||
if (!shouldShowReminder) return;
|
|
||||||
|
|
||||||
toastShownRef.current = true;
|
toastShownRef.current = true;
|
||||||
|
|
||||||
@@ -75,44 +62,52 @@ export function useBackupReminder({
|
|||||||
delay: TOAST_APPEAR_DELAY,
|
delay: TOAST_APPEAR_DELAY,
|
||||||
description: message,
|
description: message,
|
||||||
action: (
|
action: (
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="p-1"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
setReminderExpiry(reminderInDays);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remind me in {reminderInDays} day{reminderInDays > 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="p-1"
|
||||||
|
onClick={() => {
|
||||||
|
dismiss();
|
||||||
|
setReminderExpiry(REMINDER_DAYS_FOREVER);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Never remind me
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="default"
|
variant="default"
|
||||||
|
className="w-full"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAccept();
|
onAccept();
|
||||||
dismiss();
|
dismiss();
|
||||||
suppressReminder(ON_ACCEPT_REMINDER_DAYS);
|
setReminderExpiry(REMINDER_DAYS_ONE_YEAR);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Back up now
|
Back up now
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => {
|
|
||||||
dismiss();
|
|
||||||
suppressReminder(reminderInDays);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remind me in {reminderInDays} days
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => dismiss();
|
||||||
if (!toastShownRef.current) {
|
|
||||||
dismiss();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
enabled,
|
enabled,
|
||||||
message,
|
message,
|
||||||
onAccept,
|
onAccept,
|
||||||
reminderInDays,
|
|
||||||
suppressReminder,
|
|
||||||
toast,
|
|
||||||
reminderCookie,
|
|
||||||
]);
|
]);
|
||||||
}
|
};
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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: <Button>Backup Now</Button>
|
||||||
|
})
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -155,7 +155,7 @@ function toast({ delay = 0, ...props }: Toast) {
|
|||||||
...props,
|
...props,
|
||||||
id,
|
id,
|
||||||
open: true,
|
open: true,
|
||||||
onOpenChange: (open) => {
|
onOpenChange: (open: boolean) => {
|
||||||
if (!open) dismiss();
|
if (!open) dismiss();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+48
-44
@@ -19,12 +19,17 @@ export interface DeleteNoteDialogProps {
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function shortNameFromNode(node: ReturnType<useDevice>["nodes"][number]): string {
|
function shortNameFromNode(
|
||||||
const shortNameOfNode = node.user?.shortName ?? (node.user?.macaddr
|
node: ReturnType<useDevice>["nodes"][number],
|
||||||
? `${base16
|
): string {
|
||||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
const shortNameOfNode = node.user?.shortName ??
|
||||||
.toLowerCase()}`
|
(node.user?.macaddr
|
||||||
: `${numberToHexUnpadded(node.num).slice(-4)}`);
|
? `${
|
||||||
|
base16
|
||||||
|
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||||
|
.toLowerCase()
|
||||||
|
}`
|
||||||
|
: `${numberToHexUnpadded(node.num).slice(-4)}`);
|
||||||
return String(shortNameOfNode);
|
return String(shortNameOfNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +75,6 @@ const NodesPage = (): JSX.Element => {
|
|||||||
};
|
};
|
||||||
}, [connection]);
|
}, [connection]);
|
||||||
|
|
||||||
|
|
||||||
const handleLocation = useCallback(
|
const handleLocation = useCallback(
|
||||||
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
|
(location: Types.PacketMetadata<Protobuf.Mesh.Position>) => {
|
||||||
if (location.to.valueOf() !== hardware.myNodeNum) return;
|
if (location.to.valueOf() !== hardware.myNodeNum) return;
|
||||||
@@ -97,12 +101,12 @@ const NodesPage = (): JSX.Element => {
|
|||||||
headings={[
|
headings={[
|
||||||
{ title: "", type: "blank", sortable: false },
|
{ title: "", type: "blank", sortable: false },
|
||||||
{ title: "Long Name", type: "normal", sortable: true },
|
{ title: "Long Name", type: "normal", sortable: true },
|
||||||
|
{ title: "Connection", type: "normal", sortable: true },
|
||||||
|
{ title: "Last Heard", type: "normal", sortable: true },
|
||||||
|
{ title: "Encryption", type: "normal", sortable: false },
|
||||||
|
{ title: "SNR", type: "normal", sortable: true },
|
||||||
{ title: "Model", type: "normal", sortable: true },
|
{ title: "Model", type: "normal", sortable: true },
|
||||||
{ title: "MAC Address", type: "normal", sortable: true },
|
{ title: "MAC Address", 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 },
|
|
||||||
]}
|
]}
|
||||||
rows={filteredNodes.map((node) => [
|
rows={filteredNodes.map((node) => [
|
||||||
<div key={node.num}>
|
<div key={node.num}>
|
||||||
@@ -111,19 +115,47 @@ const NodesPage = (): JSX.Element => {
|
|||||||
<h1
|
<h1
|
||||||
key="longName"
|
key="longName"
|
||||||
onMouseDown={() => setSelectedNode(node)}
|
onMouseDown={() => setSelectedNode(node)}
|
||||||
onKeyUp={(evt)=>{ evt.key === "Enter" && setSelectedNode(node) }}
|
onKeyUp={(evt) => {
|
||||||
|
evt.key === "Enter" && setSelectedNode(node);
|
||||||
|
}}
|
||||||
className="cursor-pointer underline"
|
className="cursor-pointer underline"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
role="button"
|
role="button"
|
||||||
>
|
>
|
||||||
{node.user?.longName ??
|
{node.user?.longName ??
|
||||||
(node.user?.macaddr
|
(node.user?.macaddr
|
||||||
? `Meshtastic ${base16
|
? `Meshtastic ${
|
||||||
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
base16
|
||||||
.toLowerCase()}`
|
.stringify(node.user?.macaddr.subarray(4, 6) ?? [])
|
||||||
|
.toLowerCase()
|
||||||
|
}`
|
||||||
: `!${numberToHexUnpadded(node.num)}`)}
|
: `!${numberToHexUnpadded(node.num)}`)}
|
||||||
</h1>,
|
</h1>,
|
||||||
|
<Mono key="hops">
|
||||||
|
{node.lastHeard !== 0
|
||||||
|
? node.viaMqtt === false && node.hopsAway === 0
|
||||||
|
? "Direct"
|
||||||
|
: `${node.hopsAway?.toString()} ${
|
||||||
|
node.hopsAway > 1 ? "hops" : "hop"
|
||||||
|
} away`
|
||||||
|
: "-"}
|
||||||
|
{node.viaMqtt === true ? ", via MQTT" : ""}
|
||||||
|
</Mono>,
|
||||||
|
<Mono key="lastHeard">
|
||||||
|
{node.lastHeard === 0
|
||||||
|
? <p>Never</p>
|
||||||
|
: <TimeAgo timestamp={node.lastHeard * 1000} />}
|
||||||
|
</Mono>,
|
||||||
|
<Mono key="pki">
|
||||||
|
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||||
|
? <LockIcon className="text-green-600 mx-auto" />
|
||||||
|
: <LockOpenIcon className="text-yellow-300 mx-auto" />}
|
||||||
|
</Mono>,
|
||||||
|
<Mono key="snr">
|
||||||
|
{node.snr}db/
|
||||||
|
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
||||||
|
{(node.snr + 10) * 5}raw
|
||||||
|
</Mono>,
|
||||||
<Mono key="model">
|
<Mono key="model">
|
||||||
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
{Protobuf.Mesh.HardwareModel[node.user?.hwModel ?? 0]}
|
||||||
</Mono>,
|
</Mono>,
|
||||||
@@ -133,34 +165,6 @@ const NodesPage = (): JSX.Element => {
|
|||||||
.match(/.{1,2}/g)
|
.match(/.{1,2}/g)
|
||||||
?.join(":") ?? "UNK"}
|
?.join(":") ?? "UNK"}
|
||||||
</Mono>,
|
</Mono>,
|
||||||
<Mono key="lastHeard">
|
|
||||||
{node.lastHeard === 0 ? (
|
|
||||||
<p>Never</p>
|
|
||||||
) : (
|
|
||||||
<TimeAgo timestamp={node.lastHeard * 1000} />
|
|
||||||
)}
|
|
||||||
</Mono>,
|
|
||||||
<Mono key="snr">
|
|
||||||
{node.snr}db/
|
|
||||||
{Math.min(Math.max((node.snr + 10) * 5, 0), 100)}%/
|
|
||||||
{(node.snr + 10) * 5}raw
|
|
||||||
</Mono>,
|
|
||||||
<Mono key="pki">
|
|
||||||
{node.user?.publicKey && node.user?.publicKey.length > 0 ? (
|
|
||||||
<LockIcon className="text-green-600 mx-auto" />
|
|
||||||
) : (
|
|
||||||
<LockOpenIcon className="text-yellow-300 mx-auto" />
|
|
||||||
)}
|
|
||||||
</Mono>,
|
|
||||||
<Mono key="hops">
|
|
||||||
{node.lastHeard !== 0
|
|
||||||
? node.viaMqtt === false && node.hopsAway === 0
|
|
||||||
? "Direct"
|
|
||||||
: `${node.hopsAway?.toString()} ${node.hopsAway > 1 ? "hops" : "hop"
|
|
||||||
} away`
|
|
||||||
: "-"}
|
|
||||||
{node.viaMqtt === true ? ", via MQTT" : ""}
|
|
||||||
</Mono>,
|
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
<NodeOptionsDialog
|
<NodeOptionsDialog
|
||||||
|
|||||||
Reference in New Issue
Block a user