Merge branch 'master' into unread-counts
This commit is contained in:
@@ -1,2 +0,0 @@
|
|||||||
dist/build.tar
|
|
||||||
dist/output
|
|
||||||
@@ -46,7 +46,7 @@ jobs:
|
|||||||
uses: redhat-actions/buildah-build@v2
|
uses: redhat-actions/buildah-build@v2
|
||||||
with:
|
with:
|
||||||
containerfiles: |
|
containerfiles: |
|
||||||
./Containerfile
|
./infra/Containerfile
|
||||||
image: ${{github.event.repository.full_name}}
|
image: ${{github.event.repository.full_name}}
|
||||||
tags: nightly ${{ github.sha }}
|
tags: nightly ${{ github.sha }}
|
||||||
oci: true
|
oci: true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: 'Release'
|
name: Release
|
||||||
|
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
@@ -38,6 +38,10 @@ jobs:
|
|||||||
name: build
|
name: build
|
||||||
path: dist/build.tar
|
path: dist/build.tar
|
||||||
|
|
||||||
|
- name: Attach build.tar to release
|
||||||
|
run: |
|
||||||
|
gh release upload ${{ github.event.release.tag_name }} dist/build.tar
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ jobs:
|
|||||||
uses: redhat-actions/buildah-build@v2
|
uses: redhat-actions/buildah-build@v2
|
||||||
with:
|
with:
|
||||||
containerfiles: |
|
containerfiles: |
|
||||||
./Containerfile
|
./infra/Containerfile
|
||||||
image: ${{github.event.repository.full_name}}
|
image: ${{github.event.repository.full_name}}
|
||||||
tags: latest ${{ github.sha }}
|
tags: latest ${{ github.sha }}
|
||||||
oci: true
|
oci: true
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
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 .
|
|
||||||
|
|
||||||
CMD nginx -g "daemon off;"
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
../dist/build.tar
|
||||||
|
../dist/output
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
RUN rm -r /usr/share/nginx/html \
|
||||||
|
&& mkdir -p /usr/share/nginx/html \
|
||||||
|
&& mkdir -p /etc/nginx/conf.d
|
||||||
|
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
|
ADD dist .
|
||||||
|
|
||||||
|
COPY ./infra/default.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name localhost;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
location = /50x.html {
|
||||||
|
internal;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\.ht {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_disable "msie6";
|
||||||
|
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_buffers 16 8k;
|
||||||
|
gzip_http_version 1.1;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/xml
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/x-javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
application/xml+rss
|
||||||
|
font/ttf
|
||||||
|
font/otf
|
||||||
|
image/svg+xml;
|
||||||
|
}
|
||||||
+27
-24
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "meshtastic-web",
|
"name": "meshtastic-web",
|
||||||
"version": "2.3.3-0",
|
"version": "2.6.0-0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Meshtastic web client",
|
"description": "Meshtastic web client",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
@@ -34,11 +34,12 @@
|
|||||||
},
|
},
|
||||||
"homepage": "https://meshtastic.org",
|
"homepage": "https://meshtastic.org",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^2.2.3",
|
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.2",
|
||||||
"@meshtastic/core": "npm:@jsr/meshtastic__core@2.6.0-0",
|
|
||||||
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
"@meshtastic/js": "npm:@jsr/meshtastic__js@2.6.0-0",
|
||||||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
|
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http",
|
||||||
|
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth",
|
||||||
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial",
|
||||||
|
"@bufbuild/protobuf": "^2.2.5",
|
||||||
"@noble/curves": "^1.8.1",
|
"@noble/curves": "^1.8.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.3",
|
"@radix-ui/react-accordion": "^1.2.3",
|
||||||
"@radix-ui/react-checkbox": "^1.1.4",
|
"@radix-ui/react-checkbox": "^1.1.4",
|
||||||
@@ -59,49 +60,51 @@
|
|||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.4",
|
"cmdk": "^1.1.1",
|
||||||
"crypto-random-string": "^5.0.0",
|
"crypto-random-string": "^5.0.0",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
"immer": "^10.1.1",
|
"immer": "^10.1.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"lucide-react": "^0.477.0",
|
"lucide-react": "^0.486.0",
|
||||||
"maplibre-gl": "5.1.1",
|
"maplibre-gl": "5.3.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-error-boundary": "^5.0.0",
|
"react-error-boundary": "^5.0.0",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.55.0",
|
||||||
"react-map-gl": "8.0.1",
|
"react-map-gl": "8.0.2",
|
||||||
"react-qrcode-logo": "^3.0.0",
|
"react-qrcode-logo": "^3.0.0",
|
||||||
"rfc4648": "^1.5.4",
|
"rfc4648": "^1.5.4",
|
||||||
"vite-plugin-node-polyfills": "^0.23.0",
|
"vite-plugin-node-polyfills": "^0.23.0",
|
||||||
|
"zod": "^3.24.2",
|
||||||
"zustand": "5.0.3"
|
"zustand": "5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.0.9",
|
"@tailwindcss/postcss": "^4.1.0",
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
"@testing-library/react": "^16.2.0",
|
"@testing-library/react": "^16.2.0",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/chrome": "^0.0.307",
|
"@types/chrome": "^0.0.313",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.13.7",
|
"@types/node": "^22.13.17",
|
||||||
"@types/react": "^19.0.10",
|
"@types/react": "^19.0.12",
|
||||||
"@types/react-dom": "^19.0.4",
|
"@types/react-dom": "^19.0.4",
|
||||||
"@types/serviceworker": "^0.0.123",
|
"@types/serviceworker": "^0.0.127",
|
||||||
"@types/w3c-web-serial": "^1.0.8",
|
"@types/w3c-web-serial": "^1.0.8",
|
||||||
"@types/web-bluetooth": "^0.0.21",
|
"@types/web-bluetooth": "^0.0.21",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.21",
|
||||||
"gzipper": "^8.2.0",
|
"gzipper": "^8.2.1",
|
||||||
"happy-dom": "^17.2.2",
|
"happy-dom": "^17.4.4",
|
||||||
"postcss": "^8.5.3",
|
"postcss": "^8.5.3",
|
||||||
"simple-git-hooks": "^2.11.1",
|
"simple-git-hooks": "^2.12.1",
|
||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.1.0",
|
||||||
"tailwindcss": "^4.0.9",
|
"tailwindcss": "^4.1.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"tar": "^7.4.3",
|
"tar": "^7.4.3",
|
||||||
"testing-library": "^0.0.2",
|
"testing-library": "^0.0.2",
|
||||||
"typescript": "^5.8.2",
|
"typescript": "^5.8.2",
|
||||||
"vite": "^6.2.0",
|
"vite": "^6.2.4",
|
||||||
"vitest": "^3.0.7",
|
"vitest": "^3.1.1",
|
||||||
"vite-plugin-pwa": "^0.21.1"
|
"vite-plugin-pwa": "^1.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,5 @@
|
|||||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
import { DeviceWrapper } from "@app/DeviceWrapper.tsx";
|
||||||
import { PageRouter } from "@app/PageRouter.tsx";
|
import { PageRouter } from "@app/PageRouter.tsx";
|
||||||
import { CommandPalette } from "@components/CommandPalette.tsx";
|
|
||||||
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
import { DeviceSelector } from "@components/DeviceSelector.tsx";
|
||||||
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
import { DialogManager } from "@components/Dialog/DialogManager.tsx";
|
||||||
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
import { NewDeviceDialog } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||||
@@ -14,6 +13,7 @@ import type { JSX } from "react";
|
|||||||
import { ErrorBoundary } from "react-error-boundary";
|
import { ErrorBoundary } from "react-error-boundary";
|
||||||
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
import { ErrorPage } from "@components/UI/ErrorPage.tsx";
|
||||||
import { MapProvider } from "react-map-gl/maplibre";
|
import { MapProvider } from "react-map-gl/maplibre";
|
||||||
|
import { CommandPalette } from "@components/CommandPalette/index.tsx";
|
||||||
|
|
||||||
|
|
||||||
export const App = (): JSX.Element => {
|
export const App = (): JSX.Element => {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Avatar } from "./UI/Avatar.tsx";
|
|
||||||
import {
|
import {
|
||||||
CommandDialog,
|
CommandDialog,
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
FactoryIcon,
|
FactoryIcon,
|
||||||
LayersIcon,
|
LayersIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
type LucideIcon,
|
|
||||||
MapIcon,
|
MapIcon,
|
||||||
MessageSquareIcon,
|
MessageSquareIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@@ -29,9 +27,13 @@ import {
|
|||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
XCircleIcon,
|
Pin,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
|
import { Avatar } from "@components/UI/Avatar.tsx";
|
||||||
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import { usePinnedItems } from "@core/hooks/usePinnedItems.ts";
|
||||||
|
|
||||||
export interface Group {
|
export interface Group {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -45,7 +47,6 @@ export interface Command {
|
|||||||
subItems?: SubItem[];
|
subItems?: SubItem[];
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubItem {
|
export interface SubItem {
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -57,11 +58,10 @@ export const CommandPalette = () => {
|
|||||||
commandPaletteOpen,
|
commandPaletteOpen,
|
||||||
setCommandPaletteOpen,
|
setCommandPaletteOpen,
|
||||||
setSelectedDevice,
|
setSelectedDevice,
|
||||||
removeDevice,
|
|
||||||
selectedDevice,
|
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
const { getDevices } = useDeviceStore();
|
const { getDevices } = useDeviceStore();
|
||||||
const { setDialogOpen, setActivePage, connection } = useDevice();
|
const { setDialogOpen, setActivePage, connection } = useDevice();
|
||||||
|
const { pinnedItems, togglePinnedItem } = usePinnedItems({ storageName: 'pinnedCommandMenuGroups' });
|
||||||
|
|
||||||
const groups: Group[] = [
|
const groups: Group[] = [
|
||||||
{
|
{
|
||||||
@@ -113,22 +113,22 @@ export const CommandPalette = () => {
|
|||||||
{
|
{
|
||||||
label: "Switch Node",
|
label: "Switch Node",
|
||||||
icon: ArrowLeftRightIcon,
|
icon: ArrowLeftRightIcon,
|
||||||
subItems: getDevices().map((device) => {
|
subItems: getDevices().map((device) => ({
|
||||||
return {
|
label:
|
||||||
label:
|
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
||||||
device.nodes.get(device.hardware.myNodeNum)?.user?.longName ??
|
device.hardware.myNodeNum.toString(),
|
||||||
device.hardware.myNodeNum.toString(),
|
icon: (
|
||||||
icon: (
|
<Avatar
|
||||||
<Avatar
|
text={
|
||||||
text={device.nodes.get(device.hardware.myNodeNum)?.user
|
device.nodes.get(device.hardware.myNodeNum)?.user?.shortName ??
|
||||||
?.shortName ?? device.hardware.myNodeNum.toString()}
|
device.hardware.myNodeNum.toString()
|
||||||
/>
|
}
|
||||||
),
|
/>
|
||||||
action() {
|
),
|
||||||
setSelectedDevice(device.id);
|
action() {
|
||||||
},
|
setSelectedDevice(device.id);
|
||||||
};
|
},
|
||||||
}),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Connect New Node",
|
label: "Connect New Node",
|
||||||
@@ -163,15 +163,6 @@ export const CommandPalette = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
label: "Disconnect",
|
|
||||||
icon: XCircleIcon,
|
|
||||||
action() {
|
|
||||||
void connection?.disconnect();
|
|
||||||
setSelectedDevice(0);
|
|
||||||
removeDevice(selectedDevice ?? 0);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Schedule Shutdown",
|
label: "Schedule Shutdown",
|
||||||
icon: PowerIcon,
|
icon: PowerIcon,
|
||||||
@@ -186,6 +177,13 @@ export const CommandPalette = () => {
|
|||||||
setDialogOpen("reboot", true);
|
setDialogOpen("reboot", true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Reboot To OTA Mode",
|
||||||
|
icon: RefreshCwIcon,
|
||||||
|
action() {
|
||||||
|
setDialogOpen("rebootOTA", true);
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Reset Nodes",
|
label: "Reset Nodes",
|
||||||
icon: TrashIcon,
|
icon: TrashIcon,
|
||||||
@@ -221,16 +219,22 @@ export const CommandPalette = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "[WIP] Clear Messages",
|
label: "Clear All Stored Message",
|
||||||
icon: EraserIcon,
|
icon: EraserIcon,
|
||||||
action() {
|
action() {
|
||||||
alert("This feature is not implemented");
|
setDialogOpen("clearMessages", true);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const sortedGroups = [...groups].sort((a, b) => {
|
||||||
|
const aPinned = pinnedItems.includes(a.label) ? 1 : 0;
|
||||||
|
const bPinned = pinnedItems.includes(b.label) ? 1 : 0;
|
||||||
|
return bPinned - aPinned;
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeydown = (e: KeyboardEvent) => {
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||||
@@ -244,15 +248,45 @@ export const CommandPalette = () => {
|
|||||||
}, [setCommandPaletteOpen]);
|
}, [setCommandPaletteOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CommandDialog
|
<CommandDialog open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen}>
|
||||||
open={commandPaletteOpen}
|
|
||||||
onOpenChange={setCommandPaletteOpen}
|
|
||||||
>
|
|
||||||
<CommandInput placeholder="Type a command or search..." />
|
<CommandInput placeholder="Type a command or search..." />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No results found.</CommandEmpty>
|
<CommandEmpty>No results found.</CommandEmpty>
|
||||||
{groups.map((group) => (
|
{sortedGroups.map((group) => (
|
||||||
<CommandGroup key={group.label} heading={group.label}>
|
<CommandGroup
|
||||||
|
key={group.label}
|
||||||
|
heading={
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>{group.label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => togglePinnedItem(group.label)}
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-300 scale-100 cursor-pointer m-0.5 p-2 focus:*:data-label:opacity-100"
|
||||||
|
)}
|
||||||
|
aria-description={
|
||||||
|
pinnedItems.includes(group.label)
|
||||||
|
? "Unpin command group"
|
||||||
|
: "Pin command group"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
data-label
|
||||||
|
className="transition-all block absolute w-full mb-auto mt-auto ml-0 mr-0 text-xs left-0 -top-5 opacity-0 rounded-lg"
|
||||||
|
/>
|
||||||
|
<Pin
|
||||||
|
size={16}
|
||||||
|
className={cn(
|
||||||
|
"transition-opacity",
|
||||||
|
pinnedItems.includes(group.label)
|
||||||
|
? "opacity-100 text-red-500"
|
||||||
|
: "opacity-40 hover:opacity-70"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
{group.commands.map((command) => (
|
{group.commands.map((command) => (
|
||||||
<div key={command.label}>
|
<div key={command.label}>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx";
|
||||||
|
|
||||||
|
vi.mock('@core/stores/messageStore.ts', () => ({
|
||||||
|
useMessageStore: vi.fn(() => ({
|
||||||
|
clearAllMessages: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ClearMessagesDialog', () => {
|
||||||
|
const mockOnOpenChange = vi.fn();
|
||||||
|
const mockClearAllMessages = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(useMessageStore).mockReturnValue({ clearAllMessages: mockClearAllMessages });
|
||||||
|
mockOnOpenChange.mockClear();
|
||||||
|
mockClearAllMessages.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the dialog when open is true', () => {
|
||||||
|
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||||
|
expect(screen.getByText('Clear All Messages')).toBeVisible();
|
||||||
|
expect(screen.getByText(/This action will clear all message history./)).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render the dialog when open is false', () => {
|
||||||
|
render(<ClearMessagesDialog open={false} onOpenChange={mockOnOpenChange} />);
|
||||||
|
expect(screen.queryByText('Clear All Messages')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onOpenChange with false when the close button is clicked', () => {
|
||||||
|
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onOpenChange with false when the dismiss button is clicked', () => {
|
||||||
|
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' }));
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls clearAllMessages and onOpenChange with false when the clear messages button is clicked', () => {
|
||||||
|
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' }));
|
||||||
|
expect(mockClearAllMessages).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
|
||||||
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@components/UI/Dialog.tsx";
|
||||||
|
import { AlertTriangleIcon } from "lucide-react";
|
||||||
|
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
|
export interface ClearMessagesDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ClearMessagesDialog = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ClearMessagesDialogProps) => {
|
||||||
|
const { clearAllMessages } = useMessageStore();
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose onClick={handleCloseDialog} />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangleIcon className="h-5 w-5 text-warning" />
|
||||||
|
Clear All Messages
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This action will clear all message history. This cannot be undone.
|
||||||
|
Are you sure you want to continue?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleCloseDialog}
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
clearAllMessages();
|
||||||
|
handleCloseDialog();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear Messages
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,9 @@ import { ShutdownDialog } from "@components/Dialog/ShutdownDialog.tsx";
|
|||||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx";
|
||||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx";
|
||||||
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
import { RefreshKeysDialog } from "@components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx";
|
||||||
|
import { RebootOTADialog } from "@components/Dialog/RebootOTADialog.tsx";
|
||||||
|
import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx";
|
||||||
|
|
||||||
|
|
||||||
export const DialogManager = () => {
|
export const DialogManager = () => {
|
||||||
const { channels, config, dialog, setDialogOpen } = useDevice();
|
const { channels, config, dialog, setDialogOpen } = useDevice();
|
||||||
@@ -77,6 +80,18 @@ export const DialogManager = () => {
|
|||||||
setDialogOpen("refreshKeys", open);
|
setDialogOpen("refreshKeys", open);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<RebootOTADialog
|
||||||
|
open={dialog.rebootOTA}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen("rebootOTA", open);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ClearMessagesDialog
|
||||||
|
open={dialog.clearMessages}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setDialogOpen("clearMessages", open);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
|||||||
import { TrashIcon } from "lucide-react";
|
import { TrashIcon } from "lucide-react";
|
||||||
|
|
||||||
import { Button } from "../UI/Button.tsx";
|
import { Button } from "../UI/Button.tsx";
|
||||||
|
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
export interface NodeOptionsDialogProps {
|
export interface NodeOptionsDialogProps {
|
||||||
node: Protobuf.Mesh.NodeInfo | undefined;
|
node: Protobuf.Mesh.NodeInfo | undefined;
|
||||||
@@ -29,23 +30,23 @@ export const NodeOptionsDialog = ({
|
|||||||
const {
|
const {
|
||||||
setNodeNumToBeRemoved,
|
setNodeNumToBeRemoved,
|
||||||
setNodeNumDetails,
|
setNodeNumDetails,
|
||||||
setChatType,
|
|
||||||
setActiveChat,
|
|
||||||
} = useAppStore();
|
} = useAppStore();
|
||||||
|
const { setChatType, setActiveChat } = useMessageStore();
|
||||||
|
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
const longName = node?.user?.longName ??
|
const longName = node?.user?.longName ??
|
||||||
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
|
(node ? `!${numberToHexUnpadded(node?.num)}` : "Unknown");
|
||||||
const shortName = node?.user?.shortName ??
|
const shortName = node?.user?.shortName ??
|
||||||
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
|
(node ? `${numberToHexUnpadded(node?.num).substring(0, 4)}` : "UNK");
|
||||||
|
|
||||||
function handleDirectMessage() {
|
function handleDirectMessage() {
|
||||||
if (!node) return;
|
setChatType(MessageType.Direct);
|
||||||
setChatType("direct");
|
|
||||||
setActiveChat(node.num);
|
setActiveChat(node.num);
|
||||||
setActivePage("messages");
|
setActivePage("messages");
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRequestPosition() {
|
function handleRequestPosition() {
|
||||||
if (!node) return;
|
|
||||||
toast({
|
toast({
|
||||||
title: "Requesting position, please wait...",
|
title: "Requesting position, please wait...",
|
||||||
});
|
});
|
||||||
@@ -58,7 +59,6 @@ export const NodeOptionsDialog = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleTraceroute() {
|
function handleTraceroute() {
|
||||||
if (!node) return;
|
|
||||||
toast({
|
toast({
|
||||||
title: "Sending Traceroute, please wait...",
|
title: "Sending Traceroute, please wait...",
|
||||||
});
|
});
|
||||||
@@ -92,7 +92,7 @@ export const NodeOptionsDialog = ({
|
|||||||
key="remove"
|
key="remove"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNodeNumToBeRemoved(node.num);
|
setNodeNumToBeRemoved(node?.num);
|
||||||
setDialogOpen("nodeRemoval", true);
|
setDialogOpen("nodeRemoval", true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -103,7 +103,7 @@ export const NodeOptionsDialog = ({
|
|||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNodeNumDetails(node.num);
|
setNodeNumDetails(node?.num);
|
||||||
setDialogOpen("nodeDetails", true);
|
setDialogOpen("nodeDetails", true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { RebootOTADialog } from './RebootOTADialog.tsx';
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
const rebootOtaMock = vi.fn();
|
||||||
|
let mockConnection: { rebootOta: (delay: number) => void } | undefined = {
|
||||||
|
rebootOta: rebootOtaMock,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||||
|
useDevice: () => ({
|
||||||
|
connection: mockConnection,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Button.tsx', async () => {
|
||||||
|
const actual = await vi.importActual('@components/UI/Button.tsx');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
Button: (props: any) => <button {...props} />,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Input.tsx', async () => {
|
||||||
|
const actual = await vi.importActual('@components/UI/Input.tsx');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
Input: (props: any) => <input {...props} />,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Dialog.tsx', () => {
|
||||||
|
return {
|
||||||
|
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||||
|
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
|
||||||
|
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>,
|
||||||
|
DialogClose: () => null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('RebootOTADialog', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
rebootOtaMock.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dialog with default input value', () => {
|
||||||
|
render(<RebootOTADialog open={true} onOpenChange={() => { }} />);
|
||||||
|
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5);
|
||||||
|
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('schedules a reboot with delay and calls rebootOta', async () => {
|
||||||
|
const onOpenChangeMock = vi.fn();
|
||||||
|
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), {
|
||||||
|
target: { value: '3' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||||
|
|
||||||
|
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(rebootOtaMock).toHaveBeenCalledWith(0);
|
||||||
|
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('triggers an instant reboot', async () => {
|
||||||
|
const onOpenChangeMock = vi.fn();
|
||||||
|
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/reboot to ota mode now/i));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(rebootOtaMock).toHaveBeenCalledWith(5);
|
||||||
|
expect(onOpenChangeMock).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call reboot if connection is undefined', async () => {
|
||||||
|
const onOpenChangeMock = vi.fn();
|
||||||
|
|
||||||
|
// simulate no connection
|
||||||
|
mockConnection = undefined;
|
||||||
|
|
||||||
|
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/schedule reboot/i));
|
||||||
|
vi.advanceTimersByTime(5000);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(rebootOtaMock).not.toHaveBeenCalled();
|
||||||
|
expect(onOpenChangeMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// reset connection for other tests
|
||||||
|
mockConnection = { rebootOta: rebootOtaMock };
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { ClockIcon, RefreshCwIcon } from "lucide-react";
|
||||||
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@components/UI/Dialog.tsx";
|
||||||
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
|
||||||
|
export interface RebootOTADialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
||||||
|
|
||||||
|
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => {
|
||||||
|
const { connection } = useDevice();
|
||||||
|
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY);
|
||||||
|
const [isScheduled, setIsScheduled] = useState(false);
|
||||||
|
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString());
|
||||||
|
|
||||||
|
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!e.target.validity.valid) {
|
||||||
|
e.preventDefault();
|
||||||
|
return
|
||||||
|
};
|
||||||
|
|
||||||
|
const val = e.target.value;
|
||||||
|
setInputValue(val);
|
||||||
|
|
||||||
|
const parsed = Number(val);
|
||||||
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
|
setTime(parsed);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRebootWithTimeout = async () => {
|
||||||
|
if (!connection) return;
|
||||||
|
setIsScheduled(true);
|
||||||
|
|
||||||
|
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY;
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("Rebooting...");
|
||||||
|
resolve();
|
||||||
|
}, delay * 1000);
|
||||||
|
}).finally(() => {
|
||||||
|
setIsScheduled(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
setInputValue(DEFAULT_REBOOT_DELAY.toString());
|
||||||
|
});
|
||||||
|
connection.rebootOta(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstantReboot = async () => {
|
||||||
|
if (!connection) return;
|
||||||
|
|
||||||
|
await connection.rebootOta(DEFAULT_REBOOT_DELAY);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogClose />
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Reboot to OTA Mode</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Reboot the connected node after a delay into OTA (Over-the-Air) mode.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex gap-2 p-2 items-center relative">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={86400}
|
||||||
|
className="dark:text-slate-900 appearance-none"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={handleSetTime}
|
||||||
|
placeholder="Enter delay (sec)"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12">
|
||||||
|
<ClockIcon className="mr-2" size={18} />
|
||||||
|
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="destructive" onClick={() => handleInstantReboot()}>
|
||||||
|
<RefreshCwIcon className="mr-2" size={16} />
|
||||||
|
Reboot to OTA Mode Now
|
||||||
|
</Button>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach, Mock } from 'vitest';
|
||||||
import { renderHook } from '@testing-library/react';
|
import { renderHook } from '@testing-library/react';
|
||||||
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog";
|
import { useUnsafeRolesDialog, UNSAFE_ROLES } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||||
import { eventBus } from "@core/utils/eventBus";
|
import { eventBus } from "@core/utils/eventBus.ts";
|
||||||
|
|
||||||
vi.mock('@core/utils/eventBus', () => ({
|
vi.mock('@core/utils/eventBus', () => ({
|
||||||
eventBus: {
|
eventBus: {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { LucideIcon } from "lucide-react";
|
|||||||
import { Eye, EyeOff } from "lucide-react";
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
import type { ChangeEventHandler } from "react";
|
import type { ChangeEventHandler } from "react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller, type FieldValues } from "react-hook-form";
|
import { useController, type FieldValues } from "react-hook-form";
|
||||||
|
|
||||||
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "text" | "number" | "password";
|
type: "text" | "number" | "password";
|
||||||
@@ -17,6 +17,12 @@ export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
|
|||||||
prefix?: string;
|
prefix?: string;
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
step?: number;
|
step?: number;
|
||||||
|
fieldLength?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
currentValueLength?: number;
|
||||||
|
showCharacterCount?: boolean;
|
||||||
|
},
|
||||||
action?: {
|
action?: {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
@@ -29,42 +35,59 @@ export function GenericInput<T extends FieldValues>({
|
|||||||
disabled,
|
disabled,
|
||||||
field,
|
field,
|
||||||
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
}: GenericFormElementProps<T, InputFieldProps<T>>) {
|
||||||
|
const { fieldLength, ...restProperties } = field.properties || {};
|
||||||
|
|
||||||
const [passwordShown, setPasswordShown] = useState(false);
|
const [passwordShown, setPasswordShown] = useState(false);
|
||||||
|
const [currentLength, setCurrentLength] = useState<number>(fieldLength?.currentValueLength || 0);
|
||||||
|
|
||||||
|
const { field: controllerField } = useController({
|
||||||
|
name: field.name,
|
||||||
|
control,
|
||||||
|
});
|
||||||
|
|
||||||
const togglePasswordVisiblity = () => {
|
const togglePasswordVisiblity = () => {
|
||||||
setPasswordShown(!passwordShown);
|
setPasswordShown(!passwordShown);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newValue = e.target.value;
|
||||||
|
|
||||||
|
if (field.properties?.fieldLength?.max && newValue.length > field.properties?.fieldLength?.max) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCurrentLength(newValue.length);
|
||||||
|
|
||||||
|
if (field.inputChange) field.inputChange(e);
|
||||||
|
|
||||||
|
controllerField.onChange(field.type === "number" ? Number.parseFloat(newValue).toString() : newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Controller
|
<div className="relative w-full">
|
||||||
name={field.name}
|
<Input
|
||||||
control={control}
|
type={field.type === "password" && passwordShown ? "text" : field.type}
|
||||||
render={({ field: { value, onChange, ...rest } }) => (
|
action={
|
||||||
<Input
|
field.type === "password"
|
||||||
type={field.type === "password" && passwordShown
|
|
||||||
? "text"
|
|
||||||
: field.type}
|
|
||||||
action={field.type === "password"
|
|
||||||
? {
|
? {
|
||||||
icon: passwordShown ? EyeOff : Eye,
|
icon: passwordShown ? EyeOff : Eye,
|
||||||
onClick: togglePasswordVisiblity,
|
onClick: togglePasswordVisiblity,
|
||||||
}
|
}
|
||||||
: undefined}
|
: undefined
|
||||||
step={field.properties?.step}
|
}
|
||||||
value={field.type === "number" ? Number.parseFloat(value) : value}
|
step={field.properties?.step}
|
||||||
id={field.name}
|
value={field.type === "number" ? String(controllerField.value) : controllerField.value}
|
||||||
onChange={(e) => {
|
id={field.name}
|
||||||
if (field.inputChange) field.inputChange(e);
|
onChange={handleInputChange}
|
||||||
onChange(
|
{...restProperties}
|
||||||
field.type === "number"
|
disabled={disabled}
|
||||||
? Number.parseFloat(e.target.value)
|
/>
|
||||||
: e.target.value,
|
|
||||||
);
|
{fieldLength?.showCharacterCount && fieldLength?.max && (
|
||||||
}}
|
<div className="absolute inset-y-0 right-0 flex items-center pr-3 text-sm text-slate-500 dark:text-slate-400">
|
||||||
{...field.properties}
|
{currentLength ?? fieldLength?.currentValueLength}/{fieldLength?.max}
|
||||||
{...rest}
|
</div>
|
||||||
disabled={disabled}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@components/UI/Select.tsx";
|
} from "@components/UI/Select.tsx";
|
||||||
import { useController, type FieldValues } from "react-hook-form";
|
import { useController, type FieldValues } from "react-hook-form";
|
||||||
import { computeHeadingLevel } from "@core/utils/test.tsx";
|
|
||||||
|
|
||||||
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
export interface SelectFieldProps<T> extends BaseFormBuilderProps<T> {
|
||||||
type: "select";
|
type: "select";
|
||||||
selectChange?: (e: string, name: string) => void;
|
selectChange?: (e: string, name: string) => void;
|
||||||
validate?: (newValue: string) => Promise<boolean>;
|
validate?: (newValue: string) => Promise<boolean>;
|
||||||
|
defaultValue?: string;
|
||||||
properties: BaseFormBuilderProps<T>["properties"] & {
|
properties: BaseFormBuilderProps<T>["properties"] & {
|
||||||
|
defaultValue?: T;
|
||||||
enumValue: {
|
enumValue: {
|
||||||
[s: string]: string | number;
|
[s: string]: string | number;
|
||||||
};
|
};
|
||||||
@@ -70,7 +71,6 @@ export function SelectInput<T extends FieldValues>({
|
|||||||
onChange(Number.parseInt(newValue));
|
onChange(Number.parseInt(newValue));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select
|
<Select
|
||||||
onValueChange={handleValueChange}
|
onValueChange={handleValueChange}
|
||||||
|
|||||||
@@ -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 <></>;
|
||||||
|
|||||||
@@ -34,9 +34,10 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
|
|||||||
settings: {
|
settings: {
|
||||||
...data.settings,
|
...data.settings,
|
||||||
psk: toByteArray(pass),
|
psk: toByteArray(pass),
|
||||||
moduleSettings: {...data.settings.moduleSettings,
|
moduleSettings: create(Protobuf.Channel.ModuleSettingsSchema, {
|
||||||
|
...data.settings.moduleSettings,
|
||||||
positionPrecision: data.settings.moduleSettings.positionPrecision,
|
positionPrecision: data.settings.moduleSettings.positionPrecision,
|
||||||
},
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
connection?.setChannel(channel).then(() => {
|
connection?.setChannel(channel).then(() => {
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { useDevice } from "@core/stores/deviceStore.ts";
|
|||||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
vi.mock('@core/stores/deviceStore', () => ({
|
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||||
useDevice: vi.fn()
|
useDevice: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
|
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts', () => ({
|
||||||
useUnsafeRolesDialog: vi.fn()
|
useUnsafeRolesDialog: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,19 @@ export const Device = () => {
|
|||||||
label: "Disable Triple Click",
|
label: "Disable Triple Click",
|
||||||
description: "Disable triple click",
|
description: "Disable triple click",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
name: 'tzdef',
|
||||||
|
label: 'POSIX Timezone',
|
||||||
|
description: 'The POSIX timezone string for the device',
|
||||||
|
properties: {
|
||||||
|
fieldLength: {
|
||||||
|
max: 64,
|
||||||
|
currentValueLength: config.device?.tzdef?.length,
|
||||||
|
showCharacterCount: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
name: "ledHeartbeatDisabled",
|
name: "ledHeartbeatDisabled",
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
// import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
// import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
||||||
|
// import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
// import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
|
// vi.mock('@core/stores/deviceStore', () => ({
|
||||||
|
// useDevice: vi.fn()
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// vi.mock('@components/Form/DynamicForm', () => ({
|
||||||
|
// DynamicForm: vi.fn(({ onSubmit }) => {
|
||||||
|
// return (
|
||||||
|
// <div data-testid="dynamic-form">
|
||||||
|
// <select
|
||||||
|
// data-testid="role-select"
|
||||||
|
// onChange={(e) => {
|
||||||
|
// const mockData = { role: e.target.value };
|
||||||
|
// onSubmit(mockData);
|
||||||
|
// }}
|
||||||
|
// >
|
||||||
|
// {Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(([key, value]) => (
|
||||||
|
// <option key={key} value={value}>
|
||||||
|
// {key}
|
||||||
|
// </option>
|
||||||
|
// ))}
|
||||||
|
// </select>
|
||||||
|
// <button type="submit"
|
||||||
|
// data-testid="submit-button"
|
||||||
|
// onClick={() => onSubmit({ role: "CLIENT" })}
|
||||||
|
// >
|
||||||
|
// Submit
|
||||||
|
// </button>
|
||||||
|
// </div>
|
||||||
|
// );
|
||||||
|
// })
|
||||||
|
// }));
|
||||||
|
|
||||||
|
// describe('Network component', () => {
|
||||||
|
// const setWorkingConfigMock = vi.fn();
|
||||||
|
// const mockDeviceConfig = {
|
||||||
|
// role: "CLIENT",
|
||||||
|
// buttonGpio: 0,
|
||||||
|
// buzzerGpio: 0,
|
||||||
|
// rebroadcastMode: "ALL",
|
||||||
|
// nodeInfoBroadcastSecs: 300,
|
||||||
|
// doubleTapAsButtonPress: false,
|
||||||
|
// disableTripleClick: false,
|
||||||
|
// ledHeartbeatDisabled: false,
|
||||||
|
// };
|
||||||
|
|
||||||
|
// beforeEach(() => {
|
||||||
|
// vi.resetAllMocks();
|
||||||
|
|
||||||
|
// (useDevice as any).mockReturnValue({
|
||||||
|
// config: {
|
||||||
|
// device: mockDeviceConfig
|
||||||
|
// },
|
||||||
|
// setWorkingConfig: setWorkingConfigMock
|
||||||
|
// });
|
||||||
|
|
||||||
|
// });
|
||||||
|
|
||||||
|
// afterEach(() => {
|
||||||
|
// vi.clearAllMocks();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it('should render the Network form', () => {
|
||||||
|
// render(<Network />);
|
||||||
|
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
||||||
|
// });
|
||||||
|
|
||||||
|
// it('should call setWorkingConfig when form is submitted', async () => {
|
||||||
|
// render(<Network />);
|
||||||
|
|
||||||
|
// fireEvent.click(screen.getByTestId('submit-button'));
|
||||||
|
|
||||||
|
// await waitFor(() => {
|
||||||
|
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||||
|
// expect.objectContaining({
|
||||||
|
// payloadVariant: {
|
||||||
|
// case: "device",
|
||||||
|
// value: expect.objectContaining({ role: "CLIENT" })
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
||||||
|
// it('should create config with proper structure', async () => {
|
||||||
|
// render(<Network />);
|
||||||
|
|
||||||
|
// // Simulate form submission
|
||||||
|
// fireEvent.click(screen.getByTestId('submit-button'));
|
||||||
|
|
||||||
|
// await waitFor(() => {
|
||||||
|
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||||
|
// expect.objectContaining({
|
||||||
|
// payloadVariant: {
|
||||||
|
// case: "network",
|
||||||
|
// value: expect.any(Object)
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
// );
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
// });
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { Network } from '@components/PageComponents/Config/Network/index.tsx';
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
|
||||||
|
vi.mock('@core/stores/deviceStore', () => ({
|
||||||
|
useDevice: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@components/Form/DynamicForm', async () => {
|
||||||
|
const React = await import('react');
|
||||||
|
const { useState } = React;
|
||||||
|
|
||||||
|
return {
|
||||||
|
DynamicForm: ({ onSubmit, defaultValues }: any) => {
|
||||||
|
const [wifiEnabled, setWifiEnabled] = useState(defaultValues.wifiEnabled ?? false);
|
||||||
|
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? '');
|
||||||
|
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? '');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSubmit({
|
||||||
|
...defaultValues,
|
||||||
|
wifiEnabled,
|
||||||
|
wifiSsid: ssid,
|
||||||
|
wifiPsk: psk,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
data-testid="dynamic-form"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
aria-label="WiFi Enabled"
|
||||||
|
checked={wifiEnabled}
|
||||||
|
onChange={(e) => setWifiEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="SSID"
|
||||||
|
value={ssid}
|
||||||
|
onChange={(e) => setSsid(e.target.value)}
|
||||||
|
disabled={!wifiEnabled}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label="PSK"
|
||||||
|
value={psk}
|
||||||
|
onChange={(e) => setPsk(e.target.value)}
|
||||||
|
disabled={!wifiEnabled}
|
||||||
|
/>
|
||||||
|
<button type="submit" data-testid="submit-button">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
;
|
||||||
|
|
||||||
|
describe('Network component', () => {
|
||||||
|
const setWorkingConfigMock = vi.fn();
|
||||||
|
const mockNetworkConfig = {
|
||||||
|
wifiEnabled: false,
|
||||||
|
wifiSsid: '',
|
||||||
|
wifiPsk: '',
|
||||||
|
ntpServer: '',
|
||||||
|
ethEnabled: false,
|
||||||
|
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP,
|
||||||
|
ipv4Config: {
|
||||||
|
ip: 0,
|
||||||
|
gateway: 0,
|
||||||
|
subnet: 0,
|
||||||
|
dns: 0,
|
||||||
|
},
|
||||||
|
enabledProtocols:
|
||||||
|
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST,
|
||||||
|
rsyslogServer: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
|
||||||
|
(useDevice as any).mockReturnValue({
|
||||||
|
config: {
|
||||||
|
network: mockNetworkConfig
|
||||||
|
},
|
||||||
|
setWorkingConfig: setWorkingConfigMock
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render the Network form', () => {
|
||||||
|
render(<Network />);
|
||||||
|
expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should disable SSID and PSK fields when wifi is off', () => {
|
||||||
|
render(<Network />);
|
||||||
|
expect(screen.getByLabelText("SSID")).toBeDisabled();
|
||||||
|
expect(screen.getByLabelText("PSK")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should enable SSID and PSK when wifi is toggled on', async () => {
|
||||||
|
render(<Network />);
|
||||||
|
const toggle = screen.getByLabelText("WiFi Enabled");
|
||||||
|
screen.debug()
|
||||||
|
|
||||||
|
fireEvent.click(toggle); // turns wifiEnabled = true
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("SSID")).not.toBeDisabled();
|
||||||
|
expect(screen.getByLabelText("PSK")).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setWorkingConfig with the right structure on submit', async () => {
|
||||||
|
render(<Network />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("submit-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
payloadVariant: {
|
||||||
|
case: "network",
|
||||||
|
value: expect.objectContaining({
|
||||||
|
wifiEnabled: false,
|
||||||
|
wifiSsid: '',
|
||||||
|
wifiPsk: '',
|
||||||
|
ntpServer: '',
|
||||||
|
ethEnabled: false,
|
||||||
|
rsyslogServer: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should submit valid data after enabling wifi and entering SSID and PSK', async () => {
|
||||||
|
render(<Network />);
|
||||||
|
fireEvent.click(screen.getByLabelText("WiFi Enabled"));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("SSID"), {
|
||||||
|
target: { value: "MySSID" }
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("PSK"), {
|
||||||
|
target: { value: "MySecretPSK" }
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("submit-button"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
payloadVariant: {
|
||||||
|
case: "network",
|
||||||
|
value: expect.objectContaining({
|
||||||
|
wifiEnabled: true,
|
||||||
|
wifiSsid: "MySSID",
|
||||||
|
wifiPsk: "MySecretPSK"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+30
-5
@@ -1,4 +1,4 @@
|
|||||||
import type { NetworkValidation } from "@app/validation/config/network.tsx";
|
import { NetworkValidationSchema, type NetworkValidation } from "@app/validation/config/network.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -7,11 +7,18 @@ import {
|
|||||||
convertIpAddressToInt,
|
convertIpAddressToInt,
|
||||||
} from "@core/utils/ip.ts";
|
} from "@core/utils/ip.ts";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
|
import { validateSchema } from "@app/validation/validate.ts";
|
||||||
|
|
||||||
export const Network = () => {
|
export const Network = () => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
|
|
||||||
const onSubmit = (data: NetworkValidation) => {
|
const onSubmit = (data: NetworkValidation) => {
|
||||||
|
const result = validateSchema(NetworkValidationSchema, data);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("Validation errors:", result.errors);
|
||||||
|
}
|
||||||
|
|
||||||
setWorkingConfig(
|
setWorkingConfig(
|
||||||
create(Protobuf.Config.ConfigSchema, {
|
create(Protobuf.Config.ConfigSchema, {
|
||||||
payloadVariant: {
|
payloadVariant: {
|
||||||
@@ -21,10 +28,10 @@ export const Network = () => {
|
|||||||
ipv4Config: create(
|
ipv4Config: create(
|
||||||
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
|
Protobuf.Config.Config_NetworkConfig_IpV4ConfigSchema,
|
||||||
{
|
{
|
||||||
ip: convertIpAddressToInt(data.ipv4Config.ip) ?? 0,
|
ip: convertIpAddressToInt(data.ipv4Config?.ip ?? ""),
|
||||||
gateway: convertIpAddressToInt(data.ipv4Config.gateway) ?? 0,
|
gateway: convertIpAddressToInt(data.ipv4Config?.gateway ?? ""),
|
||||||
subnet: convertIpAddressToInt(data.ipv4Config.subnet) ?? 0,
|
subnet: convertIpAddressToInt(data.ipv4Config?.subnet ?? ""),
|
||||||
dns: convertIpAddressToInt(data.ipv4Config.dns) ?? 0,
|
dns: convertIpAddressToInt(data.ipv4Config?.dns ?? ""),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -48,6 +55,8 @@ export const Network = () => {
|
|||||||
),
|
),
|
||||||
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
dns: convertIntToIpAddress(config.network?.ipv4Config?.dns ?? 0),
|
||||||
},
|
},
|
||||||
|
enabledProtocols: config.network?.enabledProtocols ?? Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST
|
||||||
|
|
||||||
}}
|
}}
|
||||||
fieldGroups={[
|
fieldGroups={[
|
||||||
{
|
{
|
||||||
@@ -165,6 +174,22 @@ export const Network = () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "UDP Config",
|
||||||
|
description: "UDP over Mesh configuration",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
name: "enabledProtocols",
|
||||||
|
label: "Mesh via UDP",
|
||||||
|
properties: {
|
||||||
|
enumValue:
|
||||||
|
Protobuf.Config.Config_NetworkConfig_ProtocolFlags,
|
||||||
|
formatEnumName: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "NTP Config",
|
label: "NTP Config",
|
||||||
description: "NTP configuration",
|
description: "NTP configuration",
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
type FlagName,
|
type FlagName,
|
||||||
usePositionFlags,
|
usePositionFlags,
|
||||||
} from "../../../core/hooks/usePositionFlags.ts";
|
} from "@core/hooks/usePositionFlags.ts";
|
||||||
import type { PositionValidation } from "@app/validation/config/position.tsx";
|
import type { PositionValidation } from "@app/validation/config/position.ts";
|
||||||
import { create } from "@bufbuild/protobuf";
|
import { create } from "@bufbuild/protobuf";
|
||||||
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
@@ -12,7 +12,7 @@ import { useCallback } from "react";
|
|||||||
export const Position = () => {
|
export const Position = () => {
|
||||||
const { config, setWorkingConfig } = useDevice();
|
const { config, setWorkingConfig } = useDevice();
|
||||||
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
|
||||||
config?.position.positionFlags ?? 0,
|
config?.position?.positionFlags ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const onSubmit = (data: PositionValidation) => {
|
const onSubmit = (data: PositionValidation) => {
|
||||||
@@ -74,7 +74,7 @@ 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...",
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import { subscribeAll } from "@core/subscriptions.ts";
|
|||||||
import { randId } from "@core/utils/randId.ts";
|
import { randId } from "@core/utils/randId.ts";
|
||||||
import { BleConnection, ServiceUuid } from "@meshtastic/js";
|
import { BleConnection, ServiceUuid } from "@meshtastic/js";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
export const BLE = ({ closeDialog }: TabElementProps) => {
|
export const BLE = ({ closeDialog }: TabElementProps) => {
|
||||||
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);
|
||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
|
|
||||||
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
const updateBleDeviceList = useCallback(async (): Promise<void> => {
|
||||||
@@ -30,7 +32,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
|
|||||||
device: bleDevice,
|
device: bleDevice,
|
||||||
});
|
});
|
||||||
device.addConnection(connection);
|
device.addConnection(connection);
|
||||||
subscribeAll(device, connection);
|
subscribeAll(device, connection, messageStore);
|
||||||
|
|
||||||
closeDialog();
|
closeDialog();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
|
import type { TabElementProps } from "@components/Dialog/NewDeviceDialog.tsx";
|
||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
|
import { Link } from "@components/UI/Typography/Link.tsx";
|
||||||
import { Label } from "@components/UI/Label.tsx";
|
import { Label } from "@components/UI/Label.tsx";
|
||||||
import { Switch } from "@components/UI/Switch.tsx";
|
import { Switch } from "@components/UI/Switch.tsx";
|
||||||
import { useAppStore } from "@core/stores/appStore.ts";
|
import { useAppStore } from "@core/stores/appStore.ts";
|
||||||
@@ -11,6 +12,8 @@ import { MeshDevice } from "@meshtastic/core";
|
|||||||
import { TransportHTTP } from "@meshtastic/transport-http";
|
import { TransportHTTP } from "@meshtastic/transport-http";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useForm, useController } from "react-hook-form";
|
import { useForm, useController } from "react-hook-form";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
ip: string;
|
ip: string;
|
||||||
@@ -21,6 +24,7 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
|||||||
const isURLHTTPS = location.protocol === "https:";
|
const isURLHTTPS = location.protocol === "https:";
|
||||||
|
|
||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
|
const messageStore = useMessageStore();
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
|
|
||||||
const { control, handleSubmit, register } = useForm<FormData>({
|
const { control, handleSubmit, register } = useForm<FormData>({
|
||||||
@@ -39,23 +43,33 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
|||||||
} = useController({ name: "tls", control });
|
} = useController({ name: "tls", control });
|
||||||
|
|
||||||
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
const [connectionInProgress, setConnectionInProgress] = useState(false);
|
||||||
|
const [connectionError, setConnectionError] = useState<{ host: string; secure: boolean } | null>(null);
|
||||||
|
|
||||||
const onSubmit = handleSubmit(async (data) => {
|
const onSubmit = handleSubmit(async (data) => {
|
||||||
setConnectionInProgress(true);
|
setConnectionInProgress(true);
|
||||||
const id = randId();
|
setConnectionError(null);
|
||||||
const device = addDevice(id);
|
|
||||||
const transport = await TransportHTTP.create(data.ip, data.tls);
|
try {
|
||||||
const connection = new MeshDevice(transport, id);
|
const id = randId();
|
||||||
connection.configure();
|
const transport = await TransportHTTP.create(data.ip, data.tls);
|
||||||
setSelectedDevice(id);
|
const device = addDevice(id);
|
||||||
device.addConnection(connection);
|
const connection = new MeshDevice(transport, id);
|
||||||
subscribeAll(device, connection);
|
connection.configure();
|
||||||
closeDialog();
|
setSelectedDevice(id);
|
||||||
|
device.addConnection(connection);
|
||||||
|
subscribeAll(device, connection, messageStore);
|
||||||
|
closeDialog();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Connection error:", error);
|
||||||
|
// Capture all connection errors regardless of type
|
||||||
|
setConnectionError({ host: data.ip, secure: data.tls });
|
||||||
|
setConnectionInProgress(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
|
<form className="flex w-full flex-col gap-2 p-4" onSubmit={onSubmit}>
|
||||||
<div className="flex h-48 flex-col gap-2">
|
<div className="flex flex-col gap-2" style={{ minHeight: "12rem" }}>
|
||||||
<div>
|
<div>
|
||||||
<Label>IP Address/Hostname</Label>
|
<Label>IP Address/Hostname</Label>
|
||||||
<Input
|
<Input
|
||||||
@@ -74,8 +88,37 @@ export const HTTP = ({ closeDialog }: TabElementProps) => {
|
|||||||
{...register("tls")}
|
{...register("tls")}
|
||||||
/>
|
/>
|
||||||
<Label>Use HTTPS</Label>
|
<Label>Use HTTPS</Label>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{connectionError && (
|
||||||
|
<div className="mt-2 mb-2 p-3 rounded-md bg-amber-100 border border-amber-300 dark:bg-amber-100 dark:border-amber-300">
|
||||||
|
<div className="flex gap-2 items-start">
|
||||||
|
<AlertTriangle className="shrink-0 mt-0.5 text-amber-600 dark:text-amber-600" size={20} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-amber-800 dark:text-amber-800">
|
||||||
|
Connection Failed
|
||||||
|
</p>
|
||||||
|
<p className="text-xs mt-1 text-amber-700 dark:text-amber-700">
|
||||||
|
Could not connect to the device. {connectionError.secure && "If using HTTPS, you may need to accept a self-signed certificate first. "}
|
||||||
|
Please open{" "}
|
||||||
|
<Link
|
||||||
|
href={`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
|
||||||
|
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||||
|
>
|
||||||
|
{`${connectionError.secure ? "https" : "http"}://${connectionError.host}`}
|
||||||
|
</Link>{" "}
|
||||||
|
in a new tab{connectionError.secure ? ", accept any TLS warnings if prompted, then try again" : ""}.{" "}
|
||||||
|
<Link
|
||||||
|
href="https://meshtastic.org/docs/software/web-client/#http"
|
||||||
|
className="underline font-medium text-amber-800 dark:text-amber-800"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { randId } from "@core/utils/randId.ts";
|
|||||||
import { MeshDevice } from "@meshtastic/core";
|
import { MeshDevice } from "@meshtastic/core";
|
||||||
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
import { TransportWebSerial } from "@meshtastic/transport-web-serial";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
export const Serial = ({ closeDialog }: TabElementProps) => {
|
export const Serial = ({ closeDialog }: TabElementProps) => {
|
||||||
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);
|
||||||
const { addDevice } = useDeviceStore();
|
const { addDevice } = useDeviceStore();
|
||||||
|
const messageStore = useMessageStore()
|
||||||
const { setSelectedDevice } = useAppStore();
|
const { setSelectedDevice } = useAppStore();
|
||||||
|
|
||||||
const updateSerialPortList = useCallback(async () => {
|
const updateSerialPortList = useCallback(async () => {
|
||||||
@@ -36,7 +38,7 @@ export const Serial = ({ closeDialog }: TabElementProps) => {
|
|||||||
const connection = new MeshDevice(transport, id);
|
const connection = new MeshDevice(transport, id);
|
||||||
connection.configure();
|
connection.configure();
|
||||||
device.addConnection(connection);
|
device.addConnection(connection);
|
||||||
subscribeAll(device, connection);
|
subscribeAll(device, connection, messageStore);
|
||||||
|
|
||||||
closeDialog();
|
closeDialog();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,31 +16,53 @@ import {
|
|||||||
Dot,
|
Dot,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
LockOpenIcon,
|
LockOpenIcon,
|
||||||
|
MessageSquareIcon,
|
||||||
MountainSnow,
|
MountainSnow,
|
||||||
Star,
|
Star,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipPortal,
|
||||||
|
TooltipProvider,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@radix-ui/react-tooltip";
|
||||||
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
|
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
export interface NodeDetailProps {
|
export interface NodeDetailProps {
|
||||||
node: ProtobufType.Mesh.NodeInfo;
|
node: ProtobufType.Mesh.NodeInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
export const NodeDetail = ({ node }: NodeDetailProps) => {
|
||||||
|
const { setChatType, setActiveChat } = useMessageStore();
|
||||||
|
const { setActivePage } = useDevice();
|
||||||
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
|
const name = node.user?.longName || `!${numberToHexUnpadded(node.num)}`;
|
||||||
|
const shortName = node.user?.shortName ?? "UNK";
|
||||||
const hwModel = node.user?.hwModel ?? 0;
|
const hwModel = node.user?.hwModel ?? 0;
|
||||||
const hardwareType =
|
const hardwareType =
|
||||||
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
|
Protobuf.Mesh.HardwareModel[hwModel]?.replaceAll("_", " ") ?? `${hwModel}`;
|
||||||
|
|
||||||
|
function handleDirectMessage() {
|
||||||
|
setChatType(MessageType.Direct);
|
||||||
|
setActiveChat(node.num);
|
||||||
|
setActivePage("messages");
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dark:text-slate-900 p-1">
|
<div className="dark:text-slate-900 p-1">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
<div className="flex flex-col items-center gap-2 min-w-6 pt-1">
|
||||||
<Avatar text={node.user?.shortName ?? "UNK"} />
|
<Avatar text={shortName} />
|
||||||
|
|
||||||
<div>
|
<div onFocusCapture={(e) => {
|
||||||
|
// Required to prevent DM tooltip auto-appearing on creation
|
||||||
|
e.stopPropagation();
|
||||||
|
}}>
|
||||||
{node.user?.publicKey && node.user?.publicKey.length > 0
|
{node.user?.publicKey && node.user?.publicKey.length > 0
|
||||||
? (
|
? (
|
||||||
<LockIcon
|
<LockIcon
|
||||||
className="text-green-600"
|
className="text-green-600 mb-1.5"
|
||||||
size={12}
|
size={12}
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
aria-label="Public Key Enabled"
|
aria-label="Public Key Enabled"
|
||||||
@@ -48,19 +70,41 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
)
|
)
|
||||||
: (
|
: (
|
||||||
<LockOpenIcon
|
<LockOpenIcon
|
||||||
className="text-yellow-500"
|
className="text-yellow-500 mb-1.5"
|
||||||
size={12}
|
size={12}
|
||||||
strokeWidth={3}
|
strokeWidth={3}
|
||||||
aria-label="No Public Key"
|
aria-label="No Public Key"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<Star
|
<TooltipProvider>
|
||||||
fill={node.isFavorite ? "black" : "none"}
|
<Tooltip>
|
||||||
size={15}
|
<TooltipTrigger>
|
||||||
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
|
<MessageSquareIcon
|
||||||
/>
|
size={14}
|
||||||
|
onClick={handleDirectMessage}
|
||||||
|
className="cursor-pointer hover:text-blue-500"
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent
|
||||||
|
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
||||||
|
side="top"
|
||||||
|
align="center"
|
||||||
|
sideOffset={5}
|
||||||
|
>
|
||||||
|
Direct Message {shortName}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
|
<Star
|
||||||
|
fill={node.isFavorite ? "black" : "none"}
|
||||||
|
size={15}
|
||||||
|
aria-label={node.isFavorite ? "Favorite" : "Not a Favorite"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -70,7 +114,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
|
|||||||
|
|
||||||
{!!node.deviceMetrics?.batteryLevel && (
|
{!!node.deviceMetrics?.batteryLevel && (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1"
|
className="flex items-center gap-1 mt-0.5"
|
||||||
title={`${node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
|
title={`${node.deviceMetrics?.voltage?.toPrecision(3) ?? "Unknown"
|
||||||
} volts`}
|
} volts`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
|
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
|
||||||
import { Message } from "@components/PageComponents/Messages/Message.tsx";
|
import type { Message as Message } from "@core/stores/messageStore.ts";
|
||||||
import { InboxIcon } from "lucide-react";
|
import { InboxIcon } from "lucide-react";
|
||||||
import { useCallback, useEffect, useRef } from "react";
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
export interface ChannelChatProps {
|
export interface ChannelChatProps {
|
||||||
messages?: MessageWithState[];
|
messages?: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
@@ -17,8 +17,6 @@ const EmptyState = () => (
|
|||||||
export const ChannelChat = ({
|
export const ChannelChat = ({
|
||||||
messages = [],
|
messages = [],
|
||||||
}: ChannelChatProps) => {
|
}: ChannelChatProps) => {
|
||||||
const { nodes } = useDevice();
|
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -57,14 +55,14 @@ export const ChannelChat = ({
|
|||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
|
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col justify-end min-h-full">
|
<div className="flex flex-col gap-1.5 justify-end min-h-full">
|
||||||
{messages?.map((message, index) => (
|
{messages?.map((message, index) => (
|
||||||
<Message
|
<MessageItem
|
||||||
key={message.id}
|
key={message.messageId + index}
|
||||||
message={message}
|
message={message}
|
||||||
sender={nodes.get(message.from)}
|
|
||||||
lastMsgSameUser={
|
lastMsgSameUser={
|
||||||
index > 0 && messages[index - 1].from === message.from
|
index > 0 &&
|
||||||
|
messages[index - 1].from === message.from
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import { memo, useMemo } from "react";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipArrow,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@components/UI/Tooltip.tsx";
|
|
||||||
import {
|
|
||||||
type MessageWithState,
|
|
||||||
useDeviceStore,
|
|
||||||
} from "@core/stores/deviceStore.ts";
|
|
||||||
import { cn } from "@core/utils/cn.ts";
|
|
||||||
import { Avatar } from "@components/UI/Avatar.tsx";
|
|
||||||
import type { Protobuf } from "@meshtastic/core";
|
|
||||||
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
type MessageStateValue = {
|
|
||||||
state: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
displayText: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MessageState = MessageWithState["state"];
|
|
||||||
|
|
||||||
interface MessageProps {
|
|
||||||
lastMsgSameUser: boolean;
|
|
||||||
message: MessageWithState;
|
|
||||||
sender: Protobuf.Mesh.NodeInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusTooltipProps {
|
|
||||||
state: MessageState;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusIconProps {
|
|
||||||
state: MessageState;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MESSAGE_STATES: Record<string, MessageStateValue> = {
|
|
||||||
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" },
|
|
||||||
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" },
|
|
||||||
FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" },
|
|
||||||
};
|
|
||||||
|
|
||||||
const getMessageState = (state: MessageState): MessageStateValue => {
|
|
||||||
switch (state) {
|
|
||||||
case MESSAGE_STATES.ACK.state:
|
|
||||||
return MESSAGE_STATES.ACK;
|
|
||||||
case MESSAGE_STATES.WAITING.state:
|
|
||||||
return MESSAGE_STATES.WAITING;
|
|
||||||
case MESSAGE_STATES.FAILED.state:
|
|
||||||
return MESSAGE_STATES.FAILED;
|
|
||||||
default:
|
|
||||||
return MESSAGE_STATES.FAILED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const StatusTooltip = ({ state, children }: StatusTooltipProps) => (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
|
||||||
<TooltipContent
|
|
||||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
|
||||||
side="top"
|
|
||||||
align="center"
|
|
||||||
sideOffset={5}
|
|
||||||
>
|
|
||||||
{getMessageState(state).displayText ?? "An unknown error occurred"};
|
|
||||||
<TooltipArrow className="fill-slate-800" />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => {
|
|
||||||
const msgState = getMessageState(state);
|
|
||||||
|
|
||||||
const isFailed = msgState.state === 'failed'
|
|
||||||
|
|
||||||
const iconClass = cn(
|
|
||||||
className,
|
|
||||||
"text-slate-500 dark:text-slate-400 size-5 shrink-0"
|
|
||||||
);
|
|
||||||
|
|
||||||
const Icon = msgState.icon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StatusTooltip state={state}>
|
|
||||||
<Icon
|
|
||||||
className={iconClass}
|
|
||||||
{...otherProps}
|
|
||||||
color={isFailed ? "red" : "currentColor"}
|
|
||||||
/>
|
|
||||||
</StatusTooltip>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => (
|
|
||||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
|
||||||
{date.toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
|
||||||
{date.toLocaleTimeString(undefined, {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
|
|
||||||
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => {
|
|
||||||
const { getDevices } = useDeviceStore();
|
|
||||||
|
|
||||||
const isDeviceUser = useMemo(
|
|
||||||
() =>
|
|
||||||
getDevices()
|
|
||||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
|
||||||
.includes(message.from),
|
|
||||||
[getDevices, message.from]
|
|
||||||
);
|
|
||||||
|
|
||||||
const messageUser = sender?.user;
|
|
||||||
|
|
||||||
const getMessageTextStyles = (state: MessageState) => {
|
|
||||||
const msgState = getMessageState(state);
|
|
||||||
const isAcknowledged = msgState.state === 'ack'
|
|
||||||
const isFailed = msgState.state === 'failed'
|
|
||||||
|
|
||||||
return cn(
|
|
||||||
"break-words overflow-hidden",
|
|
||||||
isAcknowledged
|
|
||||||
? "text-slate-900 dark:text-white"
|
|
||||||
: "text-slate-900 dark:text-slate-400",
|
|
||||||
isFailed && "text-red-500 dark:text-red-500",
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]);
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-full px-4 justify-start">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex flex-col flex-wrap items-start py-1",
|
|
||||||
isDeviceUser && "items-end"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{!lastMsgSameUser && (
|
|
||||||
<div className="flex place-items-center gap-2 mb-1">
|
|
||||||
<Avatar text={messageUser?.shortName ?? "UNK"} />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
|
||||||
{messageUser?.longName}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<TimeDisplay date={message.rxTime} />
|
|
||||||
<div className="flex place-items-center gap-2 pb-2">
|
|
||||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>
|
|
||||||
{message.data}
|
|
||||||
</div>
|
|
||||||
<StatusIcon state={message.state} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,152 +1,154 @@
|
|||||||
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx';
|
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
|
||||||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
|
||||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import { vi, describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { MessageInput } from './MessageInput.tsx';
|
||||||
|
import { useDevice } from '@core/stores/deviceStore.ts';
|
||||||
|
import { useMessageStore } from '@core/stores/messageStore.ts';
|
||||||
|
import { debounce } from '@core/utils/debounce.ts';
|
||||||
|
import { Types } from "@meshtastic/core";
|
||||||
|
|
||||||
vi.mock("@core/stores/deviceStore.ts", () => ({
|
vi.mock('@components/UI/Button.tsx', () => ({
|
||||||
|
Button: vi.fn(({ type, className, children, onClick, onSubmit }) => (
|
||||||
|
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@components/UI/Input.tsx', () => ({
|
||||||
|
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => (
|
||||||
|
<input
|
||||||
|
autoFocus={autoFocus}
|
||||||
|
minLength={minLength}
|
||||||
|
name={name}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@core/stores/deviceStore.ts', () => ({
|
||||||
useDevice: vi.fn(),
|
useDevice: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@core/utils/debounce.ts", () => ({
|
vi.mock('@core/stores/messageStore.ts', () => ({
|
||||||
debounce: (fn: () => void) => fn,
|
useMessageStore: vi.fn(),
|
||||||
|
MessageState: {
|
||||||
|
Ack: 'ack',
|
||||||
|
Waiting: 'waiting',
|
||||||
|
Failed: 'failed',
|
||||||
|
},
|
||||||
|
MessageType: {
|
||||||
|
Direct: 'direct',
|
||||||
|
Broadcast: 'broadcast',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@components/UI/Button.tsx", () => ({
|
vi.mock('@core/utils/debounce.ts', () => ({
|
||||||
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>
|
debounce: vi.fn((fn) => fn),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@components/UI/Input.tsx", () => ({
|
vi.mock('lucide-react', () => ({
|
||||||
Input: (props: any) => <input {...props} />
|
SendIcon: vi.fn(() => <svg data-testid="send-icon" />),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("lucide-react", () => ({
|
describe('MessageInput', () => {
|
||||||
SendIcon: () => <div data-testid="send-icon">Send</div>
|
|
||||||
}));
|
|
||||||
|
|
||||||
// TODO: getting an error with this test
|
|
||||||
describe('MessageInput Component', () => {
|
|
||||||
const mockProps = {
|
|
||||||
to: "broadcast" as const,
|
|
||||||
channel: 0 as const,
|
|
||||||
maxBytes: 100,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockSetMessageDraft = vi.fn();
|
|
||||||
const mockSetMessageState = vi.fn();
|
const mockSetMessageState = vi.fn();
|
||||||
const mockSendText = vi.fn().mockResolvedValue(123);
|
const mockSetActiveChat = vi.fn();
|
||||||
|
const mockSetDraft = vi.fn();
|
||||||
|
const mockGetDraft = vi.fn();
|
||||||
|
const mockClearDraft = vi.fn();
|
||||||
|
const mockSendText = vi.fn();
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
(useDevice as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
|
||||||
(useDevice as Mock).mockReturnValue({
|
|
||||||
connection: {
|
connection: {
|
||||||
sendText: mockSendText,
|
sendText: mockSendText,
|
||||||
},
|
},
|
||||||
setMessageState: mockSetMessageState,
|
|
||||||
messageDraft: "",
|
|
||||||
setMessageDraft: mockSetMessageDraft,
|
|
||||||
hardware: {
|
|
||||||
myNodeNum: 1234567890,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||||
|
setMessageState: mockSetMessageState,
|
||||||
|
activeChat: 123,
|
||||||
|
setDraft: mockSetDraft,
|
||||||
|
getDraft: mockGetDraft,
|
||||||
|
clearDraft: mockClearDraft,
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSetMessageState.mockClear();
|
||||||
|
mockSetActiveChat.mockClear();
|
||||||
|
mockSetDraft.mockClear();
|
||||||
|
mockGetDraft.mockClear();
|
||||||
|
mockClearDraft.mockClear();
|
||||||
|
mockSendText.mockClear();
|
||||||
|
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders correctly with initial state', () => {
|
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => {
|
||||||
render(<MessageInput {...mockProps} />);
|
render(<MessageInput {...props} />);
|
||||||
|
};
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
|
it.skip('sends text message and updates state to Ack on submit', async () => {
|
||||||
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
|
renderComponent({ to: 2, channel: 3, maxBytes: 256 });
|
||||||
|
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
fireEvent.change(inputElement, { target: { value: 'Hello' } });
|
||||||
});
|
const formElement = screen.getByRole('form');
|
||||||
|
fireEvent.submit(formElement);
|
||||||
it('updates local draft and byte count when typing', () => {
|
|
||||||
render(<MessageInput {...mockProps} />);
|
|
||||||
|
|
||||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
|
||||||
fireEvent.change(inputField, { target: { value: 'Hello' } })
|
|
||||||
|
|
||||||
expect(screen.getByText('5/100')).toBeInTheDocument();
|
|
||||||
expect(inputField).toHaveValue('Hello');
|
|
||||||
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('does not allow input exceeding max bytes', () => {
|
|
||||||
render(<MessageInput {...mockProps} maxBytes={5} />);
|
|
||||||
|
|
||||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
|
||||||
|
|
||||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
|
||||||
|
|
||||||
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p')
|
|
||||||
|
|
||||||
expect(screen.getByText('100/100')).toBeInTheDocument();
|
|
||||||
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
|
|
||||||
});
|
|
||||||
|
|
||||||
it.skip('sends message and resets form when submitting', async () => {
|
|
||||||
try {
|
|
||||||
render(<MessageInput {...mockProps} />);
|
|
||||||
|
|
||||||
const inputField = screen.getByPlaceholderText('Enter Message');
|
|
||||||
const submitButton = screen.getByText('Send');
|
|
||||||
|
|
||||||
fireEvent.change(inputField, { target: { value: 'Test Message' } });
|
|
||||||
fireEvent.click(submitButton);
|
|
||||||
|
|
||||||
const form = screen.getByRole('form');
|
|
||||||
fireEvent.submit(form);
|
|
||||||
|
|
||||||
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(mockSetMessageState).toHaveBeenCalledWith(
|
|
||||||
'broadcast',
|
|
||||||
0,
|
|
||||||
'broadcast',
|
|
||||||
1234567890,
|
|
||||||
123,
|
|
||||||
'ack'
|
|
||||||
);
|
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3);
|
||||||
|
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||||
|
type: 'direct',
|
||||||
|
key: 123,
|
||||||
|
messageId: undefined,
|
||||||
|
newState: 'ack',
|
||||||
});
|
});
|
||||||
|
expect(mockClearDraft).toHaveBeenCalledWith(2);
|
||||||
expect(inputField).toHaveValue('');
|
expect(inputElement.value).toBe('');
|
||||||
expect(screen.getByText('0/100')).toBeInTheDocument();
|
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||||
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
it('prevents sending empty messages', () => {
|
|
||||||
render(<MessageInput {...mockProps} />);
|
|
||||||
|
|
||||||
const form = screen.getByPlaceholderText('Enter Message')
|
|
||||||
fireEvent.submit(form);
|
|
||||||
|
|
||||||
expect(mockSendText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('initializes with existing message draft', () => {
|
|
||||||
(useDevice as Mock).mockReturnValue({
|
|
||||||
connection: {
|
|
||||||
sendText: mockSendText,
|
|
||||||
},
|
|
||||||
setMessageState: mockSetMessageState,
|
|
||||||
messageDraft: "Existing draft",
|
|
||||||
setMessageDraft: mockSetMessageDraft,
|
|
||||||
isQueueingMessages: false,
|
|
||||||
queueStatus: { free: 10 },
|
|
||||||
hardware: {
|
|
||||||
myNodeNum: 1234567890,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
render(<MessageInput {...mockProps} />);
|
it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => {
|
||||||
|
renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 });
|
||||||
|
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||||
|
fireEvent.change(inputElement, { target: { value: 'Broadcast message' } });
|
||||||
|
const formElement = screen.getByRole('form');
|
||||||
|
fireEvent.submit(formElement);
|
||||||
|
|
||||||
const inputField = screen.getByRole('textbox');
|
await waitFor(() => {
|
||||||
|
expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5);
|
||||||
|
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||||
|
type: 'broadcast',
|
||||||
|
key: 123,
|
||||||
|
messageId: undefined,
|
||||||
|
newState: 'ack',
|
||||||
|
});
|
||||||
|
expect(mockClearDraft).toHaveBeenCalledWith('broadcast');
|
||||||
|
expect(inputElement.value).toBe('');
|
||||||
|
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(inputField).toHaveValue('Existing draft');
|
it('updates state to Failed if sendText throws an error', async () => {
|
||||||
|
mockSendText.mockRejectedValue({ id: 456 });
|
||||||
|
renderComponent({ to: 3, channel: 1, maxBytes: 256 });
|
||||||
|
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement;
|
||||||
|
fireEvent.change(inputElement, { target: { value: 'Error message' } });
|
||||||
|
const formElement = screen.getByRole('form');
|
||||||
|
fireEvent.submit(formElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1);
|
||||||
|
expect(mockSetMessageState).toHaveBeenCalledWith({
|
||||||
|
type: 'direct',
|
||||||
|
key: 123,
|
||||||
|
messageId: 456,
|
||||||
|
newState: 'failed',
|
||||||
|
});
|
||||||
|
expect(mockClearDraft).toHaveBeenCalledWith(3);
|
||||||
|
expect(inputElement.value).toBe('');
|
||||||
|
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { debounce } from "@core/utils/debounce.ts";
|
|
||||||
import { Button } from "@components/UI/Button.tsx";
|
import { Button } from "@components/UI/Button.tsx";
|
||||||
import { Input } from "@components/UI/Input.tsx";
|
import { Input } from "@components/UI/Input.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { Types } from "@meshtastic/core";
|
import type { Types } from "@meshtastic/core";
|
||||||
import { SendIcon } from "lucide-react";
|
import { SendIcon } from "lucide-react";
|
||||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||||
|
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
import { debounce } from "@core/utils/debounce.ts";
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
to: Types.Destination;
|
to: Types.Destination;
|
||||||
@@ -17,57 +18,41 @@ export const MessageInput = ({
|
|||||||
channel,
|
channel,
|
||||||
maxBytes,
|
maxBytes,
|
||||||
}: MessageInputProps) => {
|
}: MessageInputProps) => {
|
||||||
const {
|
const { connection } = useDevice();
|
||||||
connection,
|
const { setMessageState, activeChat, setDraft, getDraft, clearDraft } = useMessageStore();
|
||||||
setMessageState,
|
|
||||||
messageDraft,
|
const [localDraft, setLocalDraft] = useState(getDraft(to));
|
||||||
setMessageDraft,
|
|
||||||
isQueueingMessages,
|
|
||||||
queueStatus,
|
|
||||||
hardware,
|
|
||||||
} = useDevice();
|
|
||||||
const myNodeNum = hardware.myNodeNum;
|
|
||||||
const [localDraft, setLocalDraft] = useState(messageDraft);
|
|
||||||
const [messageBytes, setMessageBytes] = useState(0);
|
const [messageBytes, setMessageBytes] = useState(0);
|
||||||
|
|
||||||
const debouncedSetMessageDraft = useMemo(
|
const debouncedSetMessageDraft = useMemo(
|
||||||
() => debounce(setMessageDraft, 300),
|
() => debounce((value: string) => setDraft(to, value), 300),
|
||||||
[setMessageDraft],
|
[setDraft, to]
|
||||||
);
|
);
|
||||||
|
|
||||||
// sends the message to the selected destination
|
const calculateBytes = (text: string) => new Blob([text]).size;
|
||||||
const sendText = useCallback(
|
|
||||||
async (message: string) => {
|
|
||||||
|
|
||||||
await connection
|
const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct;
|
||||||
?.sendText(message, to, true, channel)
|
|
||||||
.then((id: number) =>
|
const sendText = useCallback(async (message: string) => {
|
||||||
setMessageState(
|
try {
|
||||||
to === "broadcast" ? "broadcast" : "direct",
|
const messageId = await connection?.sendText(message, to, true, channel);
|
||||||
channel,
|
if (messageId !== undefined) {
|
||||||
to as number,
|
setMessageState({ type: chatType, key: activeChat, messageId, newState: MessageState.Ack });
|
||||||
myNodeNum,
|
}
|
||||||
id,
|
// deno-lint-ignore no-explicit-any
|
||||||
"ack",
|
} catch (e: any) {
|
||||||
)
|
setMessageState({
|
||||||
)
|
type: chatType,
|
||||||
.catch((e: Types.PacketError) =>
|
key: activeChat,
|
||||||
setMessageState(
|
messageId: e?.id,
|
||||||
to === "broadcast" ? "broadcast" : "direct",
|
newState: MessageState.Failed,
|
||||||
channel,
|
});
|
||||||
to as number,
|
}
|
||||||
myNodeNum,
|
}, [channel, connection, setMessageState, to, activeChat, chatType]);
|
||||||
e.id,
|
|
||||||
e.error,
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[channel, connection, myNodeNum, setMessageState, to, queueStatus],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
const byteLength = new Blob([newValue]).size;
|
const byteLength = calculateBytes(newValue);
|
||||||
|
|
||||||
if (byteLength <= maxBytes) {
|
if (byteLength <= maxBytes) {
|
||||||
setLocalDraft(newValue);
|
setLocalDraft(newValue);
|
||||||
@@ -76,26 +61,22 @@ export const MessageInput = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!localDraft.trim()) return;
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
sendText(localDraft.trim());
|
||||||
|
setLocalDraft("");
|
||||||
|
clearDraft(to);
|
||||||
|
setMessageBytes(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<form
|
<form className="w-full" action="#" name="messageInput" onSubmit={handleSubmit}>
|
||||||
className="w-full"
|
<div className="flex grow gap-2">
|
||||||
action={(formData: FormData) => {
|
|
||||||
// prevent user from sending blank/empty message
|
|
||||||
if (localDraft === "") return;
|
|
||||||
const message = formData.get("messageInput") as string;
|
|
||||||
startTransition(() => {
|
|
||||||
if (!isQueueingMessages) {
|
|
||||||
sendText(message);
|
|
||||||
setLocalDraft("");
|
|
||||||
setMessageDraft("");
|
|
||||||
setMessageBytes(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex grow gap-2 ">
|
|
||||||
<label className="w-full">
|
<label className="w-full">
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -106,11 +87,15 @@ export const MessageInput = ({
|
|||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
|
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
|
||||||
{messageBytes}/{maxBytes}
|
{messageBytes}/{maxBytes}
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<Button type="submit" className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white"
|
||||||
|
>
|
||||||
<SendIcon size={16} />
|
<SendIcon size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ import { Avatar } from "@components/UI/Avatar.tsx";
|
|||||||
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
import { AlertCircle, CheckCircle2, CircleEllipsis } from "lucide-react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import { ReactNode, useMemo } from "react";
|
import { ReactNode, useMemo } from "react";
|
||||||
import { Message, MessageState } from "@core/services/types.ts";
|
import { Message, MessageState, useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
import { Protobuf } from "@meshtastic/js";
|
||||||
|
|
||||||
interface MessageProps {
|
interface MessageProps {
|
||||||
lastMsgSameUser: boolean;
|
lastMsgSameUser: boolean;
|
||||||
@@ -25,24 +26,20 @@ interface MessageStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
|
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
|
||||||
ack: { state: "ack", displayText: "Message delivered", icon: CheckCircle2 },
|
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2 },
|
||||||
waiting: { state: "waiting", displayText: "Waiting for delivery", icon: CircleEllipsis },
|
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis },
|
||||||
failed: { state: "failed", displayText: "Delivery failed", icon: AlertCircle },
|
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle },
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMessageStatus = (state: MessageState): MessageStatus =>
|
const getMessageStatus = (state: MessageState): MessageStatus =>
|
||||||
MESSAGE_STATUS[state] || { state: "failed", displayText: "Unknown error", icon: AlertCircle };
|
MESSAGE_STATUS[state] ?? { state: MessageState.Failed, displayText: "Unknown state", icon: AlertCircle };
|
||||||
|
|
||||||
|
|
||||||
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
|
const StatusTooltip = ({ status, children }: { status: MessageStatus; children: ReactNode }) => (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||||
<TooltipContent
|
<TooltipContent /* ...props... */ >
|
||||||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
|
||||||
side="top"
|
|
||||||
align="center"
|
|
||||||
sideOffset={5}
|
|
||||||
>
|
|
||||||
{status.displayText}
|
{status.displayText}
|
||||||
<TooltipArrow className="fill-slate-800" />
|
<TooltipArrow className="fill-slate-800" />
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
@@ -51,76 +48,105 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children:
|
|||||||
);
|
);
|
||||||
|
|
||||||
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
|
const StatusIcon = ({ status, className, ...otherProps }: { status: MessageStatus; className?: string }) => {
|
||||||
const isFailed = status.state === "failed";
|
const isFailed = status.state === MessageState.Failed;
|
||||||
const iconClass = cn("text-slate-500 dark:text-slate-400 w-4 h-4 shrink-0", className);
|
const iconClass = cn("w-4 h-4 shrink-0", className);
|
||||||
const Icon = status.icon;
|
const Icon = status.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StatusTooltip status={status}>
|
<StatusTooltip status={status}>
|
||||||
<Icon className={iconClass} {...otherProps} color={isFailed ? "red" : "currentColor"} />
|
<Icon className={iconClass} {...otherProps} color={isFailed ? "currentColor" : undefined} />
|
||||||
</StatusTooltip>
|
</StatusTooltip>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMessageTextStyles = (status: MessageStatus) => {
|
const getMessageTextStyles = (status: MessageState, isDeviceUser: boolean) => {
|
||||||
const isAcknowledged = status.state === "ack";
|
const isFailed = status === MessageState.Failed;
|
||||||
const isFailed = status.state === "failed";
|
|
||||||
|
|
||||||
return cn(
|
return cn(
|
||||||
"break-words overflow-hidden",
|
"break-words overflow-hidden whitespace-pre-wrap flex items-center gap-1.5",
|
||||||
isAcknowledged ? "text-slate-900 dark:text-white" : "text-slate-900 dark:text-slate-400",
|
isFailed && (isDeviceUser ? "text-red-500" : "text-red-600 dark:text-red-500")
|
||||||
isFailed && "text-red-500 dark:text-red-500",
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const TimeDisplay = ({ date, className }: { date: Date; className?: string }) => (
|
|
||||||
<div className={cn("flex items-center gap-2 shrink-0", className)}>
|
const TimeDisplay = ({ date, className }: { date: number; className?: string }) => {
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">{date.toLocaleDateString()}</span>
|
const _date = new Date(date);
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono">
|
const locale = 'en-US'; // TODO: this should be dynamic based on user settings
|
||||||
{date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}
|
return (
|
||||||
</span>
|
<div className={cn("flex items-center gap-1 text-xs font-mono", className)}>
|
||||||
</div>
|
<span>
|
||||||
);
|
{_date?.toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', hour12: true })}
|
||||||
|
</span>
|
||||||
|
{/* TODO: Conditionally show date for older messages? */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
export const MessageItem = ({ lastMsgSameUser, message }: MessageProps) => {
|
||||||
|
const myNodeNum = useMessageStore((state) => state.nodeNum);
|
||||||
|
|
||||||
const { getDevices } = useDeviceStore();
|
const { getDevices } = useDeviceStore();
|
||||||
|
|
||||||
const isDeviceUser = useMemo(
|
const isDeviceUser = message.from === myNodeNum;
|
||||||
() =>
|
|
||||||
getDevices()
|
|
||||||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num)
|
|
||||||
.includes(message.from),
|
|
||||||
[getDevices, message.from],
|
|
||||||
);
|
|
||||||
|
|
||||||
const messageUser = message?.from
|
const messageUser: Protobuf.Mesh.NodeInfo | null = useMemo(() => {
|
||||||
? getDevices().find((device) => device.nodes.has(message.from))?.nodes.get(message.from)
|
if (message?.from === null || message?.from === undefined) return null;
|
||||||
: null;
|
for (const device of getDevices()) {
|
||||||
|
if (device.nodes.has(message.from)) {
|
||||||
|
return device.nodes.get(message.from) ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [getDevices, message.from]);
|
||||||
|
|
||||||
|
const fallbackName = `${message.from}`;
|
||||||
|
const longName = messageUser?.user?.longName;
|
||||||
|
const shortName = messageUser?.user?.shortName ?? fallbackName.slice(0, 2).toUpperCase();
|
||||||
|
const displayName = isDeviceUser ? "You" : (longName || fallbackName);
|
||||||
|
|
||||||
|
const messageContainerClass = cn(
|
||||||
|
"flex flex-col w-full px-4 justify-start",
|
||||||
|
!lastMsgSameUser ? "pt-3" : "pt-0.5"
|
||||||
|
);
|
||||||
|
const alignmentClass = cn(
|
||||||
|
"flex flex-col flex-wrap w-full",
|
||||||
|
isDeviceUser ? "items-end" : "items-start"
|
||||||
|
);
|
||||||
|
const bubbleBaseStyle = "flex flex-col max-w-[75%] rounded-lg px-3 py-1.5 text-sm shadow-md";
|
||||||
|
const sentBubbleStyle = "bg-gradient-to-br from-blue-600 to-blue-700 dark:from-blue-500 dark:to-blue-600 text-white";
|
||||||
|
const receivedBubbleStyle = "bg-slate-200 dark:bg-slate-500 text-slate-900 dark:text-white";
|
||||||
|
const timeStatusColor = isDeviceUser ? "text-blue-100 dark:text-blue-200" : "text-slate-500 dark:text-slate-300";
|
||||||
|
|
||||||
const messageStatus = getMessageStatus(message.state);
|
const messageStatus = getMessageStatus(message.state);
|
||||||
const messageTextClass = getMessageTextStyles(messageStatus);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full px-4 justify-start">
|
<div className={messageContainerClass}>
|
||||||
<div className={cn("flex flex-col flex-wrap items-start py-1", messageTextClass, isDeviceUser && "items-end")}>
|
<div className={alignmentClass}>
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
{!lastMsgSameUser && (
|
{/* Show only if not consecutive message AND not sent by self */}
|
||||||
<div className="flex place-items-center gap-2 mb-1">
|
{!lastMsgSameUser && (
|
||||||
<Avatar text={messageUser?.user?.shortName ?? "UNK"} />
|
<div className="flex items-center gap-1.5 mb-1 px-1">
|
||||||
<div className="flex flex-col">
|
<Avatar text={shortName} />
|
||||||
<span className="font-medium text-slate-900 dark:text-white truncate">
|
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 truncate">
|
||||||
{messageUser?.user?.longName}
|
{displayName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</div>
|
<div className={cn(
|
||||||
<TimeDisplay date={message.date} />
|
bubbleBaseStyle,
|
||||||
<div className="flex place-items-center gap-2 pb-2">
|
isDeviceUser ? sentBubbleStyle : receivedBubbleStyle
|
||||||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}>{message.message}</div>
|
)}>
|
||||||
<StatusIcon status={messageStatus} />
|
<div className={cn("flex items-center gap-1.5 mt-1 self-end", timeStatusColor)}>
|
||||||
|
<TimeDisplay date={message.date} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={cn(getMessageTextStyles(message.state, isDeviceUser))}>
|
||||||
|
{message.message || <span className="italic opacity-70">Empty message</span>}
|
||||||
|
{isDeviceUser && <StatusIcon status={messageStatus} />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+55
-41
@@ -3,6 +3,8 @@ import { SidebarButton } from "@components/UI/Sidebar/sidebarButton.tsx";
|
|||||||
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
|
||||||
import { useDevice } from "@core/stores/deviceStore.ts";
|
import { useDevice } from "@core/stores/deviceStore.ts";
|
||||||
import type { Page } from "@core/stores/deviceStore.ts";
|
import type { Page } from "@core/stores/deviceStore.ts";
|
||||||
|
import { Spinner } from "@components/UI/Spinner.tsx";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BatteryMediumIcon,
|
BatteryMediumIcon,
|
||||||
CpuIcon,
|
CpuIcon,
|
||||||
@@ -58,7 +60,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",
|
||||||
},
|
},
|
||||||
@@ -67,46 +69,55 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
return showSidebar
|
return showSidebar
|
||||||
? (
|
? (
|
||||||
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-400">
|
<div className="min-w-[280px] max-w-min flex-col overflow-y-auto border-r-[0.5px] bg-background-primary border-slate-300 dark:border-slate-400">
|
||||||
<div className="flex justify-between px-8 pt-6">
|
{myNode === undefined ? (
|
||||||
<div>
|
<div className="flex flex-col items-center justify-center px-8 py-6">
|
||||||
<span className="text-lg font-medium">
|
<Spinner />
|
||||||
{myNode?.user?.shortName ?? "UNK"}
|
<Subtle className="mt-2">Loading device info...</Subtle>
|
||||||
</span>
|
|
||||||
<Subtle>{myNode?.user?.longName ?? "UNK"}</Subtle>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<>
|
||||||
className="transition-all hover:text-accent"
|
<div className="flex justify-between px-8 pt-6">
|
||||||
onClick={() => setDialogOpen("deviceName", true)}
|
<div>
|
||||||
>
|
<span className="text-lg font-medium">
|
||||||
<EditIcon size={16} />
|
{myNode.user?.shortName ?? "UNK"}
|
||||||
</button>
|
</span>
|
||||||
<button type="button" onClick={() => setShowSidebar(false)}>
|
<Subtle>{myNode.user?.longName ?? "UNK"}</Subtle>
|
||||||
<SidebarCloseIcon size={24} />
|
</div>
|
||||||
</button>
|
<button
|
||||||
</div>
|
type="button"
|
||||||
<div className="px-8 pb-6">
|
className="transition-all hover:text-accent"
|
||||||
<div className="flex items-center">
|
onClick={() => setDialogOpen("deviceName", true)}
|
||||||
<BatteryMediumIcon size={24} viewBox="0 0 28 24" />
|
>
|
||||||
<Subtle>
|
<EditIcon size={16} />
|
||||||
{myNode?.deviceMetrics?.batteryLevel
|
</button>
|
||||||
? myNode?.deviceMetrics?.batteryLevel > 100
|
<button type="button" onClick={() => setShowSidebar(false)}>
|
||||||
? "Charging"
|
<SidebarCloseIcon size={24} />
|
||||||
: `${myNode?.deviceMetrics?.batteryLevel}%`
|
</button>
|
||||||
: "UNK"}
|
</div>
|
||||||
</Subtle>
|
<div className="px-8 pb-6">
|
||||||
</div>
|
<div className="flex items-center">
|
||||||
<div className="flex items-center">
|
<BatteryMediumIcon size={24} viewBox="0 0 28 24" />
|
||||||
<ZapIcon size={24} viewBox="0 0 36 24" />
|
<Subtle>
|
||||||
<Subtle>
|
{myNode.deviceMetrics?.batteryLevel
|
||||||
{myNode?.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
|
? myNode.deviceMetrics.batteryLevel > 100
|
||||||
</Subtle>
|
? "Charging"
|
||||||
</div>
|
: `${myNode.deviceMetrics.batteryLevel}%`
|
||||||
<div className="flex items-center">
|
: "UNK"}
|
||||||
<CpuIcon size={24} viewBox="0 0 36 24" />
|
</Subtle>
|
||||||
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
|
</div>
|
||||||
</div>
|
<div className="flex items-center">
|
||||||
</div>
|
<ZapIcon size={24} viewBox="0 0 36 24" />
|
||||||
|
<Subtle>
|
||||||
|
{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts
|
||||||
|
</Subtle>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CpuIcon size={24} viewBox="0 0 36 24" />
|
||||||
|
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<SidebarSection label="Navigation">
|
<SidebarSection label="Navigation">
|
||||||
{pages.map((link) => (
|
{pages.map((link) => (
|
||||||
@@ -115,9 +126,12 @@ export const Sidebar = ({ children }: SidebarProps) => {
|
|||||||
label={link.name}
|
label={link.name}
|
||||||
Icon={link.icon}
|
Icon={link.icon}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setActivePage(link.page);
|
if (myNode !== undefined) {
|
||||||
|
setActivePage(link.page);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
active={link.page === activePage}
|
active={link.page === activePage}
|
||||||
|
disabled={myNode === undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</SidebarSection>
|
</SidebarSection>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-hidden aria-selected:bg-slate-100 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 dark:aria-selected:bg-slate-700",
|
"relative flex cursor-default select-none items-center rounded-md py-1.5 px-2 text-sm font-medium outline-hidden aria-selected:bg-slate-100 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 dark:aria-selected:bg-slate-700 dark:text-white ",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface SidebarButtonProps {
|
|||||||
Icon?: LucideIcon;
|
Icon?: LucideIcon;
|
||||||
element?;
|
element?;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SidebarButton = ({
|
export const SidebarButton = ({
|
||||||
@@ -17,12 +18,14 @@ export const SidebarButton = ({
|
|||||||
count,
|
count,
|
||||||
element,
|
element,
|
||||||
onClick,
|
onClick,
|
||||||
|
disabled = false,
|
||||||
}: SidebarButtonProps) => (
|
}: SidebarButtonProps) => (
|
||||||
<Button
|
<Button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
variant={active ? "subtle" : "ghost"}
|
variant={active ? "subtle" : "ghost"}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex gap-2 w-full"
|
className="flex gap-2 w-full"
|
||||||
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{Icon && <Icon size={16} />}
|
{Icon && <Icon size={16} />}
|
||||||
{element && element}
|
{element && element}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Types } from "@meshtastic/js";
|
||||||
|
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
|
class PacketToMessageDTO {
|
||||||
|
channel: Types.ChannelNumber;
|
||||||
|
to: number;
|
||||||
|
from: number;
|
||||||
|
date: number; // (timestamp ms)
|
||||||
|
messageId: number;
|
||||||
|
state: MessageState;
|
||||||
|
message: string;
|
||||||
|
type: MessageType;
|
||||||
|
|
||||||
|
constructor(data: Types.PacketMetadata<string>, nodeNum: number) {
|
||||||
|
this.channel = data.channel;
|
||||||
|
this.to = data.to;
|
||||||
|
this.from = data.from;
|
||||||
|
this.messageId = data.id;
|
||||||
|
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting;
|
||||||
|
this.message = data.data;
|
||||||
|
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast;
|
||||||
|
|
||||||
|
let dateTimestamp = Date.now();
|
||||||
|
if (data.rxTime instanceof Date) {
|
||||||
|
const timeValue = data.rxTime.getTime();
|
||||||
|
|
||||||
|
if (!isNaN(timeValue)) {
|
||||||
|
dateTimestamp = timeValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (data.rxTime != null) {
|
||||||
|
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`);
|
||||||
|
}
|
||||||
|
this.date = dateTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
toMessage(): Message {
|
||||||
|
return {
|
||||||
|
channel: this.channel,
|
||||||
|
to: this.to,
|
||||||
|
from: this.from,
|
||||||
|
date: this.date,
|
||||||
|
messageId: this.messageId,
|
||||||
|
state: this.state,
|
||||||
|
message: this.message,
|
||||||
|
type: this.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PacketToMessageDTO;
|
||||||
@@ -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,65 @@
|
|||||||
|
import { renderHook, act } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { usePinnedItems } from "./usePinnedItems.ts";
|
||||||
|
|
||||||
|
const mockSetPinnedItems = vi.fn();
|
||||||
|
const mockUseLocalStorage = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("@core/hooks/useLocalStorage.ts", () => ({
|
||||||
|
default: (...args: any[]) => mockUseLocalStorage(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("usePinnedItems", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns default pinnedItems and togglePinnedItem", () => {
|
||||||
|
mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePinnedItems({ storageName: "test-storage" })
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.pinnedItems).toEqual([]);
|
||||||
|
expect(typeof result.current.togglePinnedItem).toBe("function");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("adds an item if it's not already pinned", () => {
|
||||||
|
mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePinnedItems({ storageName: "test-storage" })
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.togglePinnedItem("item2");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
|
||||||
|
const updater = mockSetPinnedItems.mock.calls[0][0];
|
||||||
|
const updated = updater(["item1"]);
|
||||||
|
|
||||||
|
expect(updated).toEqual(["item1", "item2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes an item if it's already pinned", () => {
|
||||||
|
mockUseLocalStorage.mockReturnValue([["item1", "item2"], mockSetPinnedItems]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
usePinnedItems({ storageName: "test-storage" })
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.togglePinnedItem("item1");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function));
|
||||||
|
|
||||||
|
const updater = mockSetPinnedItems.mock.calls[0][0];
|
||||||
|
const updated = updater(["item1", "item2"]);
|
||||||
|
|
||||||
|
expect(updated).toEqual(["item2"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import useLocalStorage from "@core/hooks/useLocalStorage.ts";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
|
||||||
|
export function usePinnedItems({ storageName }: { storageName: string }) {
|
||||||
|
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []);
|
||||||
|
|
||||||
|
const togglePinnedItem = useCallback((label: string) => {
|
||||||
|
setPinnedItems((prev) =>
|
||||||
|
prev.includes(label)
|
||||||
|
? prev.filter((g) => g !== label)
|
||||||
|
: [...prev, label]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pinnedItems,
|
||||||
|
togglePinnedItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { Types } from "@meshtastic/core";
|
|
||||||
import { produce } from "immer";
|
import { produce } from "immer";
|
||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
|
||||||
@@ -31,14 +30,11 @@ export interface AppState {
|
|||||||
id: number;
|
id: number;
|
||||||
num: number;
|
num: number;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
rasterSources: RasterSource[];
|
rasterSources: RasterSource[];
|
||||||
commandPaletteOpen: boolean;
|
commandPaletteOpen: boolean;
|
||||||
nodeNumToBeRemoved: number;
|
nodeNumToBeRemoved: number;
|
||||||
connectDialogOpen: boolean;
|
connectDialogOpen: boolean;
|
||||||
nodeNumDetails: number;
|
nodeNumDetails: number;
|
||||||
activeChat: number;
|
|
||||||
chatType: "broadcast" | "direct";
|
|
||||||
errors: ErrorState[];
|
errors: ErrorState[];
|
||||||
unreadCounts: Map<number, number>;
|
unreadCounts: Map<number, number>;
|
||||||
|
|
||||||
@@ -52,8 +48,6 @@ export interface AppState {
|
|||||||
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
setNodeNumToBeRemoved: (nodeNum: number) => void;
|
||||||
setConnectDialogOpen: (open: boolean) => void;
|
setConnectDialogOpen: (open: boolean) => void;
|
||||||
setNodeNumDetails: (nodeNum: number) => void;
|
setNodeNumDetails: (nodeNum: number) => void;
|
||||||
setActiveChat: (chat: number) => void;
|
|
||||||
setChatType: (type: "broadcast" | "direct") => void;
|
|
||||||
|
|
||||||
// Error management
|
// Error management
|
||||||
hasErrors: () => boolean;
|
hasErrors: () => boolean;
|
||||||
@@ -77,8 +71,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
|
|||||||
connectDialogOpen: false,
|
connectDialogOpen: false,
|
||||||
nodeNumToBeRemoved: 0,
|
nodeNumToBeRemoved: 0,
|
||||||
nodeNumDetails: 0,
|
nodeNumDetails: 0,
|
||||||
activeChat: Types.ChannelNumber.Primary,
|
|
||||||
chatType: "broadcast",
|
|
||||||
errors: [],
|
errors: [],
|
||||||
unreadCounts: new Map([[0, 100],[2718471552, 1]]),
|
unreadCounts: new Map([[0, 100],[2718471552, 1]]),
|
||||||
|
|
||||||
@@ -138,14 +130,6 @@ export const useAppStore = create<AppState>()((set, get) => ({
|
|||||||
set(() => ({
|
set(() => ({
|
||||||
nodeNumDetails: nodeNum,
|
nodeNumDetails: nodeNum,
|
||||||
})),
|
})),
|
||||||
setActiveChat: (chat) =>
|
|
||||||
set(() => ({
|
|
||||||
activeChat: chat,
|
|
||||||
})),
|
|
||||||
setChatType: (type) =>
|
|
||||||
set(() => ({
|
|
||||||
chatType: type,
|
|
||||||
})),
|
|
||||||
hasErrors: () => {
|
hasErrors: () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
return state.errors.length > 0;
|
return state.errors.length > 0;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export interface MessageWithState extends Types.PacketMetadata<string> {
|
|||||||
state: MessageState;
|
state: MessageState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageState = "ack" | "waiting" | Protobuf.Mesh.Routing_Error;
|
export type MessageState = "ack" | "waiting" | 'failed';
|
||||||
|
|
||||||
export interface ProcessPacketParams {
|
export interface ProcessPacketParams {
|
||||||
from: number;
|
from: number;
|
||||||
@@ -23,16 +23,14 @@ export type DialogVariant =
|
|||||||
| "QR"
|
| "QR"
|
||||||
| "shutdown"
|
| "shutdown"
|
||||||
| "reboot"
|
| "reboot"
|
||||||
|
| "rebootOTA"
|
||||||
| "deviceName"
|
| "deviceName"
|
||||||
| "nodeRemoval"
|
| "nodeRemoval"
|
||||||
| "pkiBackup"
|
| "pkiBackup"
|
||||||
| "nodeDetails"
|
| "nodeDetails"
|
||||||
| "unsafeRoles"
|
| "unsafeRoles"
|
||||||
| "refreshKeys";
|
| "refreshKeys"
|
||||||
|
| "clearMessages";
|
||||||
type QueueStatus = {
|
|
||||||
res: number, free: number, maxlen: number
|
|
||||||
}
|
|
||||||
|
|
||||||
type NodeError = {
|
type NodeError = {
|
||||||
node: number;
|
node: number;
|
||||||
@@ -50,10 +48,6 @@ export interface Device {
|
|||||||
hardware: Protobuf.Mesh.MyNodeInfo;
|
hardware: Protobuf.Mesh.MyNodeInfo;
|
||||||
nodes: Map<number, Protobuf.Mesh.NodeInfo>;
|
nodes: Map<number, Protobuf.Mesh.NodeInfo>;
|
||||||
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
metadata: Map<number, Protobuf.Mesh.DeviceMetadata>;
|
||||||
messages: {
|
|
||||||
direct: Map<number, MessageWithState[]>;
|
|
||||||
broadcast: Map<Types.ChannelNumber, MessageWithState[]>;
|
|
||||||
};
|
|
||||||
traceroutes: Map<
|
traceroutes: Map<
|
||||||
number,
|
number,
|
||||||
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>[]
|
||||||
@@ -66,19 +60,19 @@ export interface Device {
|
|||||||
// currentMetrics: Protobuf.DeviceMetrics;
|
// currentMetrics: Protobuf.DeviceMetrics;
|
||||||
pendingSettingsChanges: boolean;
|
pendingSettingsChanges: boolean;
|
||||||
messageDraft: string;
|
messageDraft: string;
|
||||||
queueStatus: QueueStatus,
|
|
||||||
isQueueingMessages: boolean,
|
|
||||||
dialog: {
|
dialog: {
|
||||||
import: boolean;
|
import: boolean;
|
||||||
QR: boolean;
|
QR: boolean;
|
||||||
shutdown: boolean;
|
shutdown: boolean;
|
||||||
reboot: boolean;
|
reboot: boolean;
|
||||||
|
rebootOTA: boolean;
|
||||||
deviceName: boolean;
|
deviceName: boolean;
|
||||||
nodeRemoval: boolean;
|
nodeRemoval: boolean;
|
||||||
pkiBackup: boolean;
|
pkiBackup: boolean;
|
||||||
nodeDetails: boolean;
|
nodeDetails: boolean;
|
||||||
unsafeRoles: boolean;
|
unsafeRoles: boolean;
|
||||||
refreshKeys: boolean;
|
refreshKeys: boolean;
|
||||||
|
clearMessages: boolean;
|
||||||
};
|
};
|
||||||
unreadCounts: Map<number, number>;
|
unreadCounts: Map<number, number>;
|
||||||
|
|
||||||
@@ -99,26 +93,16 @@ export interface Device {
|
|||||||
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
addUser: (user: Types.PacketMetadata<Protobuf.Mesh.User>) => void;
|
||||||
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
addPosition: (position: Types.PacketMetadata<Protobuf.Mesh.Position>) => void;
|
||||||
addConnection: (connection: MeshDevice) => void;
|
addConnection: (connection: MeshDevice) => void;
|
||||||
addMessage: (message: MessageWithState) => void;
|
|
||||||
addTraceRoute: (
|
addTraceRoute: (
|
||||||
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
traceroute: Types.PacketMetadata<Protobuf.Mesh.RouteDiscovery>,
|
||||||
) => void;
|
) => void;
|
||||||
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
|
addMetadata: (from: number, metadata: Protobuf.Mesh.DeviceMetadata) => void;
|
||||||
removeNode: (nodeNum: number) => void;
|
removeNode: (nodeNum: number) => void;
|
||||||
setMessageState: (
|
|
||||||
type: "direct" | "broadcast",
|
|
||||||
channelIndex: Types.ChannelNumber,
|
|
||||||
to: number,
|
|
||||||
from: number,
|
|
||||||
messageId: number,
|
|
||||||
state: MessageState,
|
|
||||||
) => void;
|
|
||||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
|
setDialogOpen: (dialog: DialogVariant, open: boolean) => void;
|
||||||
getDialogOpen: (dialog: DialogVariant) => boolean;
|
getDialogOpen: (dialog: DialogVariant) => boolean;
|
||||||
processPacket: (data: ProcessPacketParams) => void;
|
processPacket: (data: ProcessPacketParams) => void;
|
||||||
setMessageDraft: (message: string) => void;
|
setMessageDraft: (message: string) => void;
|
||||||
setUnread: (id: number, count: number) => void;
|
setUnread: (id: number, count: number) => void;
|
||||||
setQueueStatus: (status: QueueStatus) => void;
|
|
||||||
setNodeError: (nodeNum: number, error: string) => void;
|
setNodeError: (nodeNum: number, error: string) => void;
|
||||||
clearNodeError: (nodeNum: number) => void;
|
clearNodeError: (nodeNum: number) => void;
|
||||||
getNodeError: (nodeNum: number) => NodeError | undefined;
|
getNodeError: (nodeNum: number) => NodeError | undefined;
|
||||||
@@ -153,19 +137,11 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
hardware: create(Protobuf.Mesh.MyNodeInfoSchema),
|
||||||
nodes: new Map(),
|
nodes: new Map(),
|
||||||
metadata: new Map(),
|
metadata: new Map(),
|
||||||
messages: {
|
|
||||||
direct: new Map(),
|
|
||||||
broadcast: new Map(),
|
|
||||||
},
|
|
||||||
traceroutes: new Map(),
|
traceroutes: new Map(),
|
||||||
connection: undefined,
|
connection: undefined,
|
||||||
activePage: "messages",
|
activePage: "messages",
|
||||||
activeNode: 0,
|
activeNode: 0,
|
||||||
waypoints: [],
|
waypoints: [],
|
||||||
queueStatus: {
|
|
||||||
res: 0, free: 0, maxlen: 0
|
|
||||||
},
|
|
||||||
isQueueingMessages: false,
|
|
||||||
dialog: {
|
dialog: {
|
||||||
import: false,
|
import: false,
|
||||||
QR: false,
|
QR: false,
|
||||||
@@ -177,6 +153,7 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
nodeDetails: false,
|
nodeDetails: false,
|
||||||
unsafeRoles: false,
|
unsafeRoles: false,
|
||||||
refreshKeys: false,
|
refreshKeys: false,
|
||||||
|
rebootOTA: false,
|
||||||
},
|
},
|
||||||
pendingSettingsChanges: false,
|
pendingSettingsChanges: false,
|
||||||
messageDraft: "",
|
messageDraft: "",
|
||||||
@@ -509,31 +486,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
addMessage: (message) => {
|
|
||||||
set(
|
|
||||||
produce<DeviceState>((draft) => {
|
|
||||||
const device = draft.devices.get(id);
|
|
||||||
if (!device) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const messageGroup = device.messages[message.type];
|
|
||||||
const messageIndex = message.type === "direct"
|
|
||||||
? message.from === device.hardware.myNodeNum
|
|
||||||
? message.to
|
|
||||||
: message.from
|
|
||||||
: message.channel;
|
|
||||||
const messages = messageGroup.get(messageIndex);
|
|
||||||
|
|
||||||
if (messages) {
|
|
||||||
messages.push(message);
|
|
||||||
messageGroup.set(messageIndex, messages);
|
|
||||||
} else {
|
|
||||||
messageGroup.set(messageIndex, [message]);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
addMetadata: (from, metadata) => {
|
addMetadata: (from, metadata) => {
|
||||||
set(
|
set(
|
||||||
produce<DeviceState>((draft) => {
|
produce<DeviceState>((draft) => {
|
||||||
@@ -574,43 +526,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
setMessageState: (
|
|
||||||
type: "direct" | "broadcast",
|
|
||||||
channelIndex: Types.ChannelNumber,
|
|
||||||
to: number,
|
|
||||||
from: number,
|
|
||||||
messageId: number,
|
|
||||||
state: MessageState,
|
|
||||||
) => {
|
|
||||||
set(
|
|
||||||
produce<DeviceState>((draft) => {
|
|
||||||
const device = draft.devices.get(id);
|
|
||||||
if (!device) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const messageGroup = device.messages[type];
|
|
||||||
|
|
||||||
const messageIndex = type === "direct"
|
|
||||||
? from === device.hardware.myNodeNum ? to : from
|
|
||||||
: channelIndex;
|
|
||||||
const messages = messageGroup.get(messageIndex);
|
|
||||||
|
|
||||||
if (!messages) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
messageGroup.set(
|
|
||||||
messageIndex,
|
|
||||||
messages.map((msg) => {
|
|
||||||
if (msg.id === messageId) {
|
|
||||||
msg.state = state;
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
|
setDialogOpen: (dialog: DialogVariant, open: boolean) => {
|
||||||
set(
|
set(
|
||||||
produce<DeviceState>((draft) => {
|
produce<DeviceState>((draft) => {
|
||||||
@@ -680,17 +595,6 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
setQueueStatus: (status: QueueStatus) => {
|
|
||||||
set(
|
|
||||||
produce<DeviceState>((draft) => {
|
|
||||||
const device = draft.devices.get(id);
|
|
||||||
if (device) {
|
|
||||||
device.queueStatus = status;
|
|
||||||
device.queueStatus.free >= 10 ? true : false
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
setNodeError: (nodeNum, error) => {
|
setNodeError: (nodeNum, error) => {
|
||||||
set(
|
set(
|
||||||
produce<DeviceState>((draft) => {
|
produce<DeviceState>((draft) => {
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
useMessageStore,
|
||||||
|
MessageType,
|
||||||
|
MessageState,
|
||||||
|
type Message,
|
||||||
|
} from './messageStore.ts';
|
||||||
|
|
||||||
|
let memoryStorage: Record<string, string> = {};
|
||||||
|
|
||||||
|
vi.mock('./storage/indexDB.ts', () => {
|
||||||
|
console.log("Mocking zustandIndexDBStorage...");
|
||||||
|
return {
|
||||||
|
zustandIndexDBStorage: {
|
||||||
|
getItem: vi.fn(async (name: string): Promise<string | null> => {
|
||||||
|
console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null);
|
||||||
|
return memoryStorage[name] ?? null;
|
||||||
|
}),
|
||||||
|
setItem: vi.fn(async (name: string, value: string): Promise<void> => {
|
||||||
|
console.log(`Mock setItem: ${name}`, value);
|
||||||
|
memoryStorage[name] = value;
|
||||||
|
}),
|
||||||
|
removeItem: vi.fn(async (name: string): Promise<void> => {
|
||||||
|
console.log(`Mock removeItem: ${name}`);
|
||||||
|
delete memoryStorage[name];
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const myNodeNum = 111;
|
||||||
|
const otherNodeNum1 = 222;
|
||||||
|
const otherNodeNum2 = 333;
|
||||||
|
const broadcastChannel = 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const directMessageToOther1: Message = {
|
||||||
|
type: MessageType.Direct,
|
||||||
|
from: myNodeNum,
|
||||||
|
to: otherNodeNum1,
|
||||||
|
channel: 0,
|
||||||
|
date: Date.now(),
|
||||||
|
messageId: 101,
|
||||||
|
state: MessageState.Waiting,
|
||||||
|
message: 'Hello other 1 from me',
|
||||||
|
};
|
||||||
|
|
||||||
|
const directMessageFromOther1: Message = {
|
||||||
|
type: MessageType.Direct,
|
||||||
|
from: otherNodeNum1,
|
||||||
|
to: myNodeNum,
|
||||||
|
channel: 0,
|
||||||
|
date: Date.now() + 1000,
|
||||||
|
messageId: 102,
|
||||||
|
state: MessageState.Waiting,
|
||||||
|
message: 'Hello me from other 1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const directMessageToOther2: Message = {
|
||||||
|
type: MessageType.Direct,
|
||||||
|
from: myNodeNum,
|
||||||
|
to: otherNodeNum2,
|
||||||
|
channel: 0,
|
||||||
|
date: Date.now() + 2000,
|
||||||
|
messageId: 103,
|
||||||
|
state: MessageState.Waiting,
|
||||||
|
message: 'Hello other 2 from me',
|
||||||
|
};
|
||||||
|
|
||||||
|
const broadcastMessage1: Message = {
|
||||||
|
type: MessageType.Broadcast,
|
||||||
|
from: otherNodeNum1,
|
||||||
|
to: 0xffffffff,
|
||||||
|
channel: broadcastChannel,
|
||||||
|
date: Date.now() + 3000,
|
||||||
|
messageId: 201,
|
||||||
|
state: MessageState.Waiting,
|
||||||
|
message: 'Broadcast message 1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const broadcastMessage2: Message = {
|
||||||
|
type: MessageType.Broadcast,
|
||||||
|
from: myNodeNum,
|
||||||
|
to: 0xffffffff,
|
||||||
|
channel: broadcastChannel,
|
||||||
|
date: Date.now() + 4000,
|
||||||
|
messageId: 202,
|
||||||
|
state: MessageState.Waiting,
|
||||||
|
message: 'Broadcast message 2',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useMessageStore', () => {
|
||||||
|
const initialState = useMessageStore.getState();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useMessageStore.setState(initialState, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct initial state', () => {
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
expect(state.messages.direct).toEqual({});
|
||||||
|
expect(state.messages.broadcast).toEqual({});
|
||||||
|
expect(state.draft).toBeInstanceOf(Map);
|
||||||
|
expect(state.draft.size).toBe(0);
|
||||||
|
expect(state.nodeNum).toBe(0);
|
||||||
|
expect(state.activeChat).toBe(0);
|
||||||
|
expect(state.chatType).toBe(MessageType.Broadcast);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set nodeNum', () => {
|
||||||
|
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||||
|
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set activeChat and chatType', () => {
|
||||||
|
useMessageStore.getState().setActiveChat(otherNodeNum1);
|
||||||
|
useMessageStore.getState().setChatType(MessageType.Direct);
|
||||||
|
expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1);
|
||||||
|
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveMessage', () => {
|
||||||
|
it('should save a direct message with correct structure', () => {
|
||||||
|
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
expect(state.messages.direct[myNodeNum]).toBeDefined();
|
||||||
|
expect(state.messages.direct[myNodeNum][otherNodeNum1]).toBeDefined();
|
||||||
|
expect(
|
||||||
|
state.messages.direct[myNodeNum][otherNodeNum1][directMessageToOther1.messageId],
|
||||||
|
).toEqual(directMessageToOther1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save a broadcast message with correct structure', () => {
|
||||||
|
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
expect(state.messages.broadcast[broadcastChannel]).toBeDefined();
|
||||||
|
expect(
|
||||||
|
state.messages.broadcast[broadcastChannel][broadcastMessage1.messageId],
|
||||||
|
).toEqual(broadcastMessage1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should save multiple messages correctly', () => {
|
||||||
|
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||||
|
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||||
|
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||||
|
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
|
||||||
|
// Direct msg 1 (me -> other1)
|
||||||
|
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1);
|
||||||
|
// Direct msg 2 (other1 -> me)
|
||||||
|
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1);
|
||||||
|
// Broadcast msg 1
|
||||||
|
expect(state.messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]).toEqual(broadcastMessage1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMessages', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||||
|
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||||
|
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||||
|
useMessageStore.getState().saveMessage(directMessageToOther2);
|
||||||
|
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||||
|
useMessageStore.getState().saveMessage(broadcastMessage2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return broadcast messages for a channel, sorted by date', () => {
|
||||||
|
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, {
|
||||||
|
myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice
|
||||||
|
channel: broadcastChannel
|
||||||
|
});
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(messages[0]).toEqual(broadcastMessage1);
|
||||||
|
expect(messages[1]).toEqual(broadcastMessage2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for broadcast if channel has no messages', () => {
|
||||||
|
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, {
|
||||||
|
myNodeNum: myNodeNum,
|
||||||
|
channel: 99
|
||||||
|
});
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return combined direct messages for a specific chat (pair), sorted by date', () => {
|
||||||
|
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||||
|
myNodeNum: myNodeNum,
|
||||||
|
otherNodeNum: otherNodeNum1
|
||||||
|
});
|
||||||
|
expect(messages).toHaveLength(2);
|
||||||
|
expect(messages[0]).toEqual(directMessageToOther1);
|
||||||
|
expect(messages[1]).toEqual(directMessageFromOther1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return only relevant direct messages for a different chat pair', () => {
|
||||||
|
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||||
|
myNodeNum: myNodeNum,
|
||||||
|
otherNodeNum: otherNodeNum2
|
||||||
|
});
|
||||||
|
expect(messages).toHaveLength(1);
|
||||||
|
expect(messages[0]).toEqual(directMessageToOther2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array for direct chat if no messages exist', () => {
|
||||||
|
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||||
|
myNodeNum: myNodeNum,
|
||||||
|
otherNodeNum: 999
|
||||||
|
});
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty array if myNodeNum is not provided for direct messages', () => {
|
||||||
|
const messages = useMessageStore.getState().getMessages(MessageType.Direct, {
|
||||||
|
otherNodeNum: otherNodeNum1
|
||||||
|
});
|
||||||
|
expect(messages).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('setMessageState', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||||
|
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||||
|
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||||
|
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update state for a direct message sent BY ME', () => {
|
||||||
|
useMessageStore.getState().setMessageState({
|
||||||
|
type: MessageType.Direct,
|
||||||
|
key: otherNodeNum1,
|
||||||
|
messageId: directMessageToOther1.messageId,
|
||||||
|
newState: MessageState.Ack,
|
||||||
|
});
|
||||||
|
const message = useMessageStore.getState().messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId];
|
||||||
|
expect(message?.state).toBe(MessageState.Ack);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update state for a direct message received FROM OTHER', () => {
|
||||||
|
useMessageStore.getState().setMessageState({
|
||||||
|
type: MessageType.Direct,
|
||||||
|
key: otherNodeNum1,
|
||||||
|
messageId: directMessageFromOther1.messageId,
|
||||||
|
newState: MessageState.Failed,
|
||||||
|
});
|
||||||
|
const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId];
|
||||||
|
expect(message?.state).toBe(MessageState.Failed);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update state for a broadcast message', () => {
|
||||||
|
useMessageStore.getState().setMessageState({
|
||||||
|
type: MessageType.Broadcast,
|
||||||
|
key: broadcastChannel,
|
||||||
|
messageId: broadcastMessage1.messageId,
|
||||||
|
newState: MessageState.Ack,
|
||||||
|
});
|
||||||
|
const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId];
|
||||||
|
expect(message?.state).toBe(MessageState.Ack);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should warn if message is not found', () => {
|
||||||
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
|
||||||
|
useMessageStore.getState().setMessageState({
|
||||||
|
type: MessageType.Direct,
|
||||||
|
key: otherNodeNum1,
|
||||||
|
messageId: 999,
|
||||||
|
newState: MessageState.Ack,
|
||||||
|
});
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message not found for state update'));
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('clearMessageByMessageId', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useMessageStore.getState().setNodeNum(myNodeNum);
|
||||||
|
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||||
|
useMessageStore.getState().saveMessage(directMessageFromOther1);
|
||||||
|
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||||
|
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: 1011, date: Date.now() + 50 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a specific direct message (sent by me)', () => {
|
||||||
|
const messageIdToDelete = directMessageToOther1.messageId;
|
||||||
|
useMessageStore.getState().clearMessageByMessageId({
|
||||||
|
type: MessageType.Direct,
|
||||||
|
sender: myNodeNum,
|
||||||
|
recipient: otherNodeNum1,
|
||||||
|
messageId: messageIdToDelete
|
||||||
|
});
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[messageIdToDelete]).toBeUndefined();
|
||||||
|
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
|
||||||
|
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a specific direct message (sent by other)', () => {
|
||||||
|
const messageIdToDelete = directMessageFromOther1.messageId;
|
||||||
|
useMessageStore.getState().clearMessageByMessageId({
|
||||||
|
type: MessageType.Direct,
|
||||||
|
sender: otherNodeNum1,
|
||||||
|
recipient: myNodeNum,
|
||||||
|
messageId: messageIdToDelete
|
||||||
|
});
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[messageIdToDelete]).toBeUndefined();
|
||||||
|
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toBeDefined();
|
||||||
|
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should delete a specific broadcast message', () => {
|
||||||
|
const messageIdToDelete = broadcastMessage1.messageId;
|
||||||
|
useMessageStore.getState().clearMessageByMessageId({
|
||||||
|
type: MessageType.Broadcast,
|
||||||
|
channel: broadcastChannel,
|
||||||
|
messageId: messageIdToDelete
|
||||||
|
});
|
||||||
|
const state = useMessageStore.getState();
|
||||||
|
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clean up empty recipient/sender/channel objects', () => {
|
||||||
|
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, sender: otherNodeNum1, recipient: myNodeNum, messageId: directMessageFromOther1.messageId });
|
||||||
|
expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed
|
||||||
|
expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed
|
||||||
|
|
||||||
|
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channel: broadcastChannel, messageId: broadcastMessage1.messageId });
|
||||||
|
expect(useMessageStore.getState().messages.broadcast[broadcastChannel]).toBeUndefined(); // Channel level removed
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Drafts', () => {
|
||||||
|
const draftKey = otherNodeNum1;
|
||||||
|
const draftMessage = 'This is a draft';
|
||||||
|
|
||||||
|
it('should set and get a draft', () => {
|
||||||
|
useMessageStore.getState().setDraft(draftKey, draftMessage);
|
||||||
|
expect(useMessageStore.getState().draft.get(draftKey)).toBe(draftMessage);
|
||||||
|
expect(useMessageStore.getState().getDraft(draftKey)).toBe(draftMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty string for non-existent draft', () => {
|
||||||
|
expect(useMessageStore.getState().getDraft(999)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should clear a draft', () => {
|
||||||
|
useMessageStore.getState().setDraft(draftKey, draftMessage);
|
||||||
|
expect(useMessageStore.getState().draft.has(draftKey)).toBe(true);
|
||||||
|
useMessageStore.getState().clearDraft(draftKey);
|
||||||
|
expect(useMessageStore.getState().draft.has(draftKey)).toBe(false);
|
||||||
|
expect(useMessageStore.getState().getDraft(draftKey)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('clearAllMessages', () => {
|
||||||
|
it('should clear all direct and broadcast messages', () => {
|
||||||
|
useMessageStore.getState().saveMessage(directMessageToOther1);
|
||||||
|
useMessageStore.getState().saveMessage(broadcastMessage1);
|
||||||
|
expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0);
|
||||||
|
expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
useMessageStore.getState().clearAllMessages();
|
||||||
|
|
||||||
|
expect(useMessageStore.getState().messages.direct).toEqual({});
|
||||||
|
expect(useMessageStore.getState().messages.broadcast).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
|
import { produce } from 'immer';
|
||||||
|
import { Types } from '@meshtastic/core';
|
||||||
|
import { zustandIndexDBStorage } from "./storage/indexDB.ts";
|
||||||
|
|
||||||
|
export enum MessageState {
|
||||||
|
Ack = "ack",
|
||||||
|
Waiting = "waiting",
|
||||||
|
Failed = "failed",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MessageType {
|
||||||
|
Direct = "direct",
|
||||||
|
Broadcast = "broadcast",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageBase {
|
||||||
|
channel: Types.ChannelNumber;
|
||||||
|
to: number;
|
||||||
|
from: number;
|
||||||
|
date: number;
|
||||||
|
messageId: number;
|
||||||
|
state: MessageState;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GenericMessage<T extends MessageType> extends MessageBase {
|
||||||
|
type: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>;
|
||||||
|
|
||||||
|
export interface MessageStore {
|
||||||
|
messages: {
|
||||||
|
direct: Record<number, Record<number, Record<number, Message>>>;
|
||||||
|
broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
|
||||||
|
};
|
||||||
|
draft: Map<Types.Destination, string>;
|
||||||
|
nodeNum: number; // This device's node number
|
||||||
|
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
|
||||||
|
chatType: MessageType;
|
||||||
|
|
||||||
|
setNodeNum: (nodeNum: number) => void;
|
||||||
|
getNodeNum: () => number;
|
||||||
|
setActiveChat: (chat: number) => void;
|
||||||
|
setChatType: (type: MessageType) => void;
|
||||||
|
saveMessage: (message: Message) => void;
|
||||||
|
setMessageState: (params: {
|
||||||
|
type: MessageType;
|
||||||
|
// For Direct: Represents the *other* node number involved in the chat.
|
||||||
|
// For Broadcast: Represents the channel number.
|
||||||
|
key: number;
|
||||||
|
messageId: number;
|
||||||
|
newState?: MessageState;
|
||||||
|
}) => void;
|
||||||
|
getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[];
|
||||||
|
getDraft: (key: Types.Destination) => string;
|
||||||
|
setDraft: (key: Types.Destination, message: string) => void;
|
||||||
|
clearAllMessages: () => void;
|
||||||
|
clearMessageByMessageId: (params: {
|
||||||
|
type: MessageType;
|
||||||
|
sender?: number;
|
||||||
|
recipient?: number;
|
||||||
|
channel?: number;
|
||||||
|
messageId: number
|
||||||
|
}) => void;
|
||||||
|
clearDraft: (key: Types.Destination) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CURRENT_STORE_VERSION = 0;
|
||||||
|
|
||||||
|
export const useMessageStore = create<MessageStore>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
messages: {
|
||||||
|
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
|
||||||
|
broadcast: {},
|
||||||
|
},
|
||||||
|
draft: new Map<number, string>(),
|
||||||
|
activeChat: 0,
|
||||||
|
chatType: MessageType.Broadcast,
|
||||||
|
nodeNum: 0,
|
||||||
|
setNodeNum: (nodeNum) => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
state.nodeNum = nodeNum;
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
getNodeNum: () => get().nodeNum,
|
||||||
|
setActiveChat: (chat) => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
state.activeChat = chat;
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setChatType: (type) => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
state.chatType = type;
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
saveMessage: (message) => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
if (message.type === MessageType.Direct) {
|
||||||
|
const sender = Number(message.from);
|
||||||
|
const recipient = Number(message.to);
|
||||||
|
|
||||||
|
if (!state.messages.direct[sender]) {
|
||||||
|
state.messages.direct[sender] = {};
|
||||||
|
}
|
||||||
|
if (!state.messages.direct[sender][recipient]) {
|
||||||
|
state.messages.direct[sender][recipient] = {};
|
||||||
|
}
|
||||||
|
state.messages.direct[sender][recipient][message.messageId] = message;
|
||||||
|
|
||||||
|
} else if (message.type === MessageType.Broadcast) {
|
||||||
|
const channel = Number(message.channel);
|
||||||
|
if (!state.messages.broadcast[channel]) {
|
||||||
|
state.messages.broadcast[channel] = {};
|
||||||
|
}
|
||||||
|
state.messages.broadcast[channel][message.messageId] = message;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
setMessageState: ({
|
||||||
|
type,
|
||||||
|
key,
|
||||||
|
messageId,
|
||||||
|
newState = MessageState.Ack,
|
||||||
|
}) => {
|
||||||
|
set(
|
||||||
|
produce((state: MessageStore) => {
|
||||||
|
let message: Message | undefined;
|
||||||
|
|
||||||
|
if (type === MessageType.Broadcast) {
|
||||||
|
const channel = key;
|
||||||
|
message = state.messages.broadcast?.[channel]?.[messageId];
|
||||||
|
} else if (type === MessageType.Direct) {
|
||||||
|
const otherNodeNum = key;
|
||||||
|
const myNodeNum = state.nodeNum;
|
||||||
|
|
||||||
|
message = state.messages.direct?.[myNodeNum]?.[otherNodeNum]?.[messageId];
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
message = state.messages.direct?.[otherNodeNum]?.[myNodeNum]?.[messageId];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message) {
|
||||||
|
message.state = newState;
|
||||||
|
} else {
|
||||||
|
console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getMessages: (type, options) => {
|
||||||
|
const state = get();
|
||||||
|
|
||||||
|
if (type === MessageType.Broadcast && options.channel !== undefined) {
|
||||||
|
const messageMap = state.messages.broadcast[options.channel] ?? {};
|
||||||
|
return Object.values(messageMap).sort((a, b) => a.date - b.date);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) {
|
||||||
|
const myNodeNum = options.myNodeNum;
|
||||||
|
const otherNodeNum = options.otherNodeNum;
|
||||||
|
|
||||||
|
// Messages sent BY ME TO OTHER
|
||||||
|
const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {};
|
||||||
|
const sentByMe = Object.values(sentByMeMap);
|
||||||
|
|
||||||
|
// Messages sent BY OTHER TO ME
|
||||||
|
const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {};
|
||||||
|
const sentByOther = Object.values(sentByOtherMap);
|
||||||
|
|
||||||
|
// Merge and sort chronologically
|
||||||
|
return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
clearMessageByMessageId: ({ type, sender, recipient, channel, messageId }) => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
if (type === MessageType.Broadcast && channel !== undefined) {
|
||||||
|
const messageMap = state.messages.broadcast[channel];
|
||||||
|
if (messageMap?.[messageId]) {
|
||||||
|
delete messageMap[messageId];
|
||||||
|
if (Object.keys(messageMap).length === 0) {
|
||||||
|
delete state.messages.broadcast[channel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (type === MessageType.Direct && sender !== undefined && recipient !== undefined) {
|
||||||
|
const messageMap = state.messages.direct?.[sender]?.[recipient];
|
||||||
|
if (messageMap?.[messageId]) {
|
||||||
|
delete messageMap[messageId];
|
||||||
|
if (Object.keys(messageMap).length === 0) {
|
||||||
|
delete state.messages.direct[sender][recipient];
|
||||||
|
if (Object.keys(state.messages.direct[sender]).length === 0) {
|
||||||
|
delete state.messages.direct[sender];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.warn("clearMessageByMessageId called without sufficient identifiers for type", type);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
getDraft: (key) => {
|
||||||
|
return get().draft.get(key) ?? '';
|
||||||
|
},
|
||||||
|
setDraft: (key, message) => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
state.draft.set(key, message);
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
clearDraft: (key) => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
state.draft.delete(key);
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
clearAllMessages: () => {
|
||||||
|
set(produce((state: MessageStore) => {
|
||||||
|
state.messages.direct = {};
|
||||||
|
state.messages.broadcast = {};
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: 'meshtastic-message-store',
|
||||||
|
storage: createJSONStorage(() => zustandIndexDBStorage),
|
||||||
|
version: CURRENT_STORE_VERSION,
|
||||||
|
partialize: (state) => ({
|
||||||
|
messages: state.messages,
|
||||||
|
nodeNum: state.nodeNum,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
));
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { StateStorage } from "zustand/middleware";
|
||||||
|
import { get, set, del } from "idb-keyval";
|
||||||
|
|
||||||
|
export const zustandIndexDBStorage: StateStorage = {
|
||||||
|
getItem: async (name: string): Promise<string | null> => {
|
||||||
|
return (await get(name)) || null;
|
||||||
|
},
|
||||||
|
setItem: async (name: string, value: string): Promise<void> => {
|
||||||
|
await set(name, value);
|
||||||
|
},
|
||||||
|
removeItem: async (name: string): Promise<void> => {
|
||||||
|
await del(name);
|
||||||
|
},
|
||||||
|
};
|
||||||
+13
-14
@@ -1,10 +1,14 @@
|
|||||||
import type { Device } from "@core/stores/deviceStore.ts";
|
import type { Device } from "@core/stores/deviceStore.ts";
|
||||||
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
import { MeshDevice, Protobuf } from "@meshtastic/core";
|
||||||
|
import type { MessageStore, MessageType } from "@core/stores/messageStore.ts";
|
||||||
|
import { MessageType } from "@core/stores/messageStore.ts";
|
||||||
|
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
|
||||||
|
|
||||||
|
|
||||||
export const subscribeAll = (
|
export const subscribeAll = (
|
||||||
device: Device,
|
device: Device,
|
||||||
connection: MeshDevice,
|
connection: MeshDevice,
|
||||||
|
messageStore: MessageStore
|
||||||
) => {
|
) => {
|
||||||
let myNodeNum = 0;
|
let myNodeNum = 0;
|
||||||
|
|
||||||
@@ -52,6 +56,7 @@ export const subscribeAll = (
|
|||||||
|
|
||||||
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
connection.events.onMyNodeInfo.subscribe((nodeInfo) => {
|
||||||
device.setHardware(nodeInfo);
|
device.setHardware(nodeInfo);
|
||||||
|
messageStore.setNodeNum(nodeInfo.myNodeNum);
|
||||||
myNodeNum = nodeInfo.myNodeNum;
|
myNodeNum = nodeInfo.myNodeNum;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,18 +87,15 @@ export const subscribeAll = (
|
|||||||
|
|
||||||
|
|
||||||
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
connection.events.onMessagePacket.subscribe((messagePacket) => {
|
||||||
device.addMessage({
|
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
|
||||||
...messagePacket,
|
const message = dto.toMessage();
|
||||||
state: messagePacket.from !== myNodeNum ? "ack" : "waiting",
|
messsageStore.saveMessage(message);
|
||||||
});
|
|
||||||
if (messagePacket.type == "direct")
|
message.type == MessageType.Direct
|
||||||
{
|
?
|
||||||
device.setUnread(messagePacket.from);
|
device.setUnread(messagePacket.from);
|
||||||
}
|
:
|
||||||
else
|
device.setUnread(messagePacket.channel);
|
||||||
{
|
|
||||||
device.setUnread(messagePacket.channel);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
|
connection.events.onTraceRoutePacket.subscribe((traceRoutePacket) => {
|
||||||
@@ -114,9 +116,6 @@ export const subscribeAll = (
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
connection.events.onQueueStatus.subscribe((queueStatus) => {
|
|
||||||
device.setQueueStatus(queueStatus);
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
connection.events.onRoutingPacket.subscribe((routingPacket) => {
|
||||||
if (routingPacket.data.variant.case === "errorReason") {
|
if (routingPacket.data.variant.case === "errorReason") {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
export function convertIntToIpAddress(int: number): string {
|
export function convertIntToIpAddress(int: number): string {
|
||||||
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${
|
return `${int & 0xff}.${(int >> 8) & 0xff}.${(int >> 16) & 0xff}.${(int >> 24) & 0xff
|
||||||
(int >> 24) & 0xff
|
}`;
|
||||||
}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function convertIpAddressToInt(ip: string): number | null {
|
export function convertIpAddressToInt(ip: string): number | undefined {
|
||||||
|
if (!ip) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
ip
|
ip
|
||||||
.split(".")
|
.split(".")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Bluetooth } from "@components/PageComponents/Config/Bluetooth.tsx";
|
|||||||
import { Device } from "../../components/PageComponents/Config/Device/index.tsx";
|
import { Device } from "../../components/PageComponents/Config/Device/index.tsx";
|
||||||
import { Display } from "@components/PageComponents/Config/Display.tsx";
|
import { Display } from "@components/PageComponents/Config/Display.tsx";
|
||||||
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
|
import { LoRa } from "@components/PageComponents/Config/LoRa.tsx";
|
||||||
import { Network } from "@components/PageComponents/Config/Network.tsx";
|
import { Network } from "../../components/PageComponents/Config/Network/index.tsx";
|
||||||
import { Position } from "@components/PageComponents/Config/Position.tsx";
|
import { Position } from "@components/PageComponents/Config/Position.tsx";
|
||||||
import { Power } from "@components/PageComponents/Config/Power.tsx";
|
import { Power } from "@components/PageComponents/Config/Power.tsx";
|
||||||
import { Security } from "../../components/PageComponents/Config/Security/Security.tsx";
|
import { Security } from "../../components/PageComponents/Config/Security/Security.tsx";
|
||||||
@@ -31,7 +31,6 @@ export const DeviceConfig = () => {
|
|||||||
{
|
{
|
||||||
label: "Network",
|
label: "Network",
|
||||||
element: Network,
|
element: Network,
|
||||||
// disabled: !metadata.get(0)?.hasWifi,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Display",
|
label: "Display",
|
||||||
|
|||||||
+35
-32
@@ -1,4 +1,3 @@
|
|||||||
import { useAppStore } from "../core/stores/appStore.ts";
|
|
||||||
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
import { ChannelChat } from "@components/PageComponents/Messages/ChannelChat.tsx";
|
||||||
import { PageLayout } from "@components/PageLayout.tsx";
|
import { PageLayout } from "@components/PageLayout.tsx";
|
||||||
import { Sidebar } from "@components/Sidebar.tsx";
|
import { Sidebar } from "@components/Sidebar.tsx";
|
||||||
@@ -14,11 +13,15 @@ import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
|
||||||
import { cn } from "@core/utils/cn.ts";
|
import { cn } from "@core/utils/cn.ts";
|
||||||
|
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts";
|
||||||
|
|
||||||
export const MessagesPage = () => {
|
export const MessagesPage = () => {
|
||||||
const { channels, nodes, hardware, messages, hasNodeError, unreadCounts, setUnread } = useDevice();
|
const { channels, nodes, hardware, hasNodeError, unreadCounts, setUnread } = useDevice();
|
||||||
const { activeChat, chatType, setActiveChat, setChatType } = useAppStore();
|
const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||||
|
|
||||||
const filteredNodes = Array.from(nodes.values()).filter((node) => {
|
const filteredNodes = Array.from(nodes.values()).filter((node) => {
|
||||||
if (node.num === hardware.myNodeNum) return false;
|
if (node.num === hardware.myNodeNum) return false;
|
||||||
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
|
const nodeName = node.user?.longName ?? `!${numberToHexUnpadded(node.num)}`;
|
||||||
@@ -34,14 +37,15 @@ export const MessagesPage = () => {
|
|||||||
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
|
||||||
);
|
);
|
||||||
const currentChannel = channels.get(activeChat);
|
const currentChannel = channels.get(activeChat);
|
||||||
const { toast } = useToast();
|
|
||||||
const node = nodes.get(activeChat);
|
|
||||||
const nodeHex = node?.num ? numberToHexUnpadded(node.num) : "Unknown";
|
|
||||||
|
|
||||||
const messageDestination = chatType === "direct" ? activeChat : "broadcast";
|
const otherNode = nodes.get(activeChat);
|
||||||
const messageChannel = chatType === "direct"
|
|
||||||
? Types.ChannelNumber.Primary
|
const nodeHex = otherNode?.num ? numberToHexUnpadded(otherNode.num) : "Unknown";
|
||||||
: activeChat;
|
|
||||||
|
const isDirect = chatType === MessageType.Direct;
|
||||||
|
const isBroadcast = chatType === MessageType.Broadcast;
|
||||||
|
|
||||||
|
const currentChat = { type: chatType, id: activeChat };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -56,9 +60,8 @@ export const MessagesPage = () => {
|
|||||||
: channel.index === 0
|
: channel.index === 0
|
||||||
? "Primary"
|
? "Primary"
|
||||||
: `Ch ${channel.index}`}
|
: `Ch ${channel.index}`}
|
||||||
active={activeChat === channel.index && chatType === "broadcast"}
|
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatType("broadcast");
|
setChatType(MessageType.Broadcast);
|
||||||
setActiveChat(channel.index);
|
setActiveChat(channel.index);
|
||||||
setUnread(channel.index, 0);
|
setUnread(channel.index, 0);
|
||||||
}}
|
}}
|
||||||
@@ -77,23 +80,22 @@ export const MessagesPage = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{filteredNodes.map((node) => (
|
{filteredNodes.map((otherNode) => (
|
||||||
<SidebarButton
|
<SidebarButton
|
||||||
key={node.num}
|
|
||||||
count={unreadCounts.get(node.num)}
|
count={unreadCounts.get(node.num)}
|
||||||
label={node.user?.longName ??
|
label={node.user?.longName ??
|
||||||
`!${numberToHexUnpadded(node.num)}`}
|
`!${numberToHexUnpadded(node.num)}`}
|
||||||
active={activeChat === node.num && chatType === "direct"}
|
active={activeChat === otherNode.num && chatType === MessageType.Direct}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setChatType("direct");
|
setChatType(MessageType.Direct);
|
||||||
setActiveChat(node.num);
|
setActiveChat(otherNode.num);
|
||||||
setUnread(node.num, 0);
|
setUnread(otherNode.num, 0);
|
||||||
}}
|
}}
|
||||||
element={
|
element={
|
||||||
<Avatar
|
<Avatar
|
||||||
text={node.user?.shortName ?? node.num.toString()}
|
text={otherNode?.user?.shortName ?? otherNode.num.toString()}
|
||||||
className={cn(hasNodeError(node.num) && "text-red-500")}
|
className={cn(hasNodeError(otherNode?.num) && "text-red-500")}
|
||||||
showError={hasNodeError(node.num)}
|
showError={hasNodeError(otherNode?.num)}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -105,13 +107,13 @@ export const MessagesPage = () => {
|
|||||||
<div className="flex flex-col w-full h-full container mx-auto">
|
<div className="flex flex-col w-full h-full container mx-auto">
|
||||||
<PageLayout
|
<PageLayout
|
||||||
className="flex flex-col h-full"
|
className="flex flex-col h-full"
|
||||||
label={`Messages: ${chatType === "broadcast" && currentChannel
|
label={`Messages: ${MessageType.Broadcast && currentChannel
|
||||||
? getChannelName(currentChannel)
|
? getChannelName(currentChannel)
|
||||||
: chatType === "direct" && nodes.get(activeChat)
|
: chatType === MessageType.Direct && nodes.get(activeChat)
|
||||||
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
|
? (nodes.get(activeChat)?.user?.longName ?? nodeHex)
|
||||||
: "Loading..."
|
: "Loading..."
|
||||||
}`}
|
}`}
|
||||||
actions={chatType === "direct"
|
actions={chatType === MessageType.Direct
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
icon: nodes.get(activeChat)?.user?.publicKey.length
|
icon: nodes.get(activeChat)?.user?.publicKey.length
|
||||||
@@ -134,23 +136,24 @@ export const MessagesPage = () => {
|
|||||||
: []}
|
: []}
|
||||||
>
|
>
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{chatType === "broadcast" && currentChannel && (
|
{isBroadcast && currentChannel && (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ChannelChat
|
<ChannelChat
|
||||||
key={currentChannel.index}
|
messages={getMessages(MessageType.Broadcast, {
|
||||||
messages={messages.broadcast.get(currentChannel.index)}
|
myNodeNum: getNodeNum(),
|
||||||
|
channel: currentChannel?.index
|
||||||
|
})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{chatType === "direct" && node && (
|
{isDirect && otherNode && (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<ChannelChat
|
<ChannelChat
|
||||||
key={node.num}
|
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
|
||||||
messages={messages.direct.get(node.num)}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -159,8 +162,8 @@ export const MessagesPage = () => {
|
|||||||
|
|
||||||
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
|
||||||
<MessageInput
|
<MessageInput
|
||||||
to={messageDestination}
|
to={currentChat.type === MessageType.Direct ? activeChat : MessageType.Broadcast}
|
||||||
channel={messageChannel}
|
channel={currentChat.type === MessageType.Direct ? Types.ChannelNumber.Primary : currentChat.id}
|
||||||
maxBytes={200}
|
maxBytes={200}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { expect, afterEach } from 'vitest';
|
import { afterEach } from 'vitest';
|
||||||
import { cleanup } from '@testing-library/react';
|
import { cleanup } from '@testing-library/react';
|
||||||
import * as matchers from '@testing-library/jest-dom/matchers';
|
import { enableMapSet } from "immer";
|
||||||
import "@testing-library/jest-dom";
|
import "@testing-library/jest-dom";
|
||||||
|
|
||||||
// Enable auto mocks for our UI components
|
enableMapSet();
|
||||||
//vi.mock('@components/UI/Dialog.tsx');
|
|
||||||
//vi.mock('@components/UI/Typography/Link.tsx');
|
|
||||||
|
|
||||||
globalThis.ResizeObserver = class {
|
globalThis.ResizeObserver = class {
|
||||||
observe() { }
|
observe() { }
|
||||||
|
|||||||
@@ -1,61 +1,27 @@
|
|||||||
import type { Message } from "@bufbuild/protobuf";
|
import { z } from "zod";
|
||||||
import { Protobuf } from "@meshtastic/core";
|
import { Protobuf } from "@meshtastic/core";
|
||||||
import {
|
|
||||||
IsBoolean,
|
|
||||||
IsEnum,
|
|
||||||
IsIP,
|
|
||||||
IsOptional,
|
|
||||||
IsString,
|
|
||||||
Length,
|
|
||||||
} from "class-validator";
|
|
||||||
|
|
||||||
export class NetworkValidation
|
const AddressModeEnum = z.nativeEnum(Protobuf.Config.Config_NetworkConfig_AddressMode);
|
||||||
implements
|
const ProtocolFlagsEnum = z.nativeEnum(Protobuf.Config.Config_NetworkConfig_ProtocolFlags);
|
||||||
Omit<Protobuf.Config.Config_NetworkConfig, keyof Message | "ipv4Config"> {
|
|
||||||
@IsBoolean()
|
|
||||||
wifiEnabled: boolean;
|
|
||||||
|
|
||||||
@Length(1, 33)
|
export const NetworkValidationIpV4ConfigSchema = z.object({
|
||||||
@IsOptional({})
|
ip: z.string().ip(),
|
||||||
wifiSsid: string;
|
gateway: z.string().ip(),
|
||||||
|
subnet: z.string().ip(),
|
||||||
|
dns: z.string().ip(),
|
||||||
|
});
|
||||||
|
|
||||||
@Length(8, 64)
|
export const NetworkValidationSchema = z.object({
|
||||||
@IsOptional()
|
wifiEnabled: z.boolean(),
|
||||||
wifiPsk: string;
|
wifiSsid: z.string().min(0).max(33).optional(),
|
||||||
|
wifiPsk: z.string().min(0).max(64).optional(),
|
||||||
|
ntpServer: z.string().min(2).max(30),
|
||||||
|
ethEnabled: z.boolean(),
|
||||||
|
addressMode: AddressModeEnum,
|
||||||
|
ipv4Config: NetworkValidationIpV4ConfigSchema.optional(),
|
||||||
|
enabledProtocols: ProtocolFlagsEnum,
|
||||||
|
rsyslogServer: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
@Length(2, 30)
|
export type NetworkValidation = z.infer<typeof NetworkValidationSchema>;
|
||||||
ntpServer: string;
|
|
||||||
|
|
||||||
@IsBoolean()
|
|
||||||
ethEnabled: boolean;
|
|
||||||
|
|
||||||
@IsEnum(Protobuf.Config.Config_NetworkConfig_AddressMode)
|
|
||||||
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode;
|
|
||||||
|
|
||||||
ipv4Config: NetworkValidationIpV4Config;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
rsyslogServer: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NetworkValidationIpV4Config implements
|
|
||||||
Omit<
|
|
||||||
Protobuf.Config.Config_NetworkConfig_IpV4Config,
|
|
||||||
keyof Message | "ip" | "gateway" | "subnet" | "dns"
|
|
||||||
> {
|
|
||||||
@IsIP()
|
|
||||||
@IsOptional()
|
|
||||||
ip: string;
|
|
||||||
|
|
||||||
@IsIP()
|
|
||||||
@IsOptional()
|
|
||||||
gateway: string;
|
|
||||||
|
|
||||||
@IsIP()
|
|
||||||
@IsOptional()
|
|
||||||
subnet: string;
|
|
||||||
|
|
||||||
@IsIP()
|
|
||||||
@IsOptional()
|
|
||||||
dns: string;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { ZodError, ZodSchema } from "zod";
|
||||||
|
|
||||||
|
export function validateSchema<T>(
|
||||||
|
schema: ZodSchema<T>,
|
||||||
|
data: unknown
|
||||||
|
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } {
|
||||||
|
const result = schema.safeParse(data);
|
||||||
|
if (result.success) {
|
||||||
|
return { success: true, data: result.data };
|
||||||
|
} else {
|
||||||
|
return { success: false, errors: result.error.issues };
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-1
@@ -2,6 +2,8 @@ import path from "node:path";
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
import { enableMapSet } from "immer";
|
||||||
|
enableMapSet();
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
@@ -9,9 +11,9 @@ export default defineConfig({
|
|||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@app': path.resolve(process.cwd(), './src'),
|
'@app': path.resolve(process.cwd(), './src'),
|
||||||
|
'@core': path.resolve(process.cwd(), './src/core'),
|
||||||
'@pages': path.resolve(process.cwd(), './src/pages'),
|
'@pages': path.resolve(process.cwd(), './src/pages'),
|
||||||
'@components': path.resolve(process.cwd(), './src/components'),
|
'@components': path.resolve(process.cwd(), './src/components'),
|
||||||
'@core': path.resolve(process.cwd(), './src/core'),
|
|
||||||
'@layouts': path.resolve(process.cwd(), './src/layouts'),
|
'@layouts': path.resolve(process.cwd(), './src/layouts'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user