committed by
GitHub
60 changed files with 2917 additions and 1873 deletions
@ -1,2 +0,0 @@ |
|||||
dist/build.tar |
|
||||
dist/output |
|
||||
@ -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;" |
|
||||
File diff suppressed because it is too large
@ -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; |
||||
|
} |
||||
@ -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/[email protected]", |
||||
"@meshtastic/core": "npm:@jsr/[email protected]", |
|
||||
"@meshtastic/js": "npm:@jsr/[email protected]", |
"@meshtastic/js": "npm:@jsr/[email protected]", |
||||
"@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" |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
@ -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> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
@ -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" |
||||
|
}) |
||||
|
} |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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'; |
||||
vi.mock("@core/stores/deviceStore.ts", () => ({ |
import { useDevice } from '@core/stores/deviceStore.ts'; |
||||
useDevice: vi.fn(), |
import { useMessageStore } from '@core/stores/messageStore.ts'; |
||||
|
import { debounce } from '@core/utils/debounce.ts'; |
||||
|
import { Types } from "@meshtastic/core"; |
||||
|
|
||||
|
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("@core/utils/debounce.ts", () => ({ |
vi.mock('@components/UI/Input.tsx', () => ({ |
||||
debounce: (fn: () => void) => fn, |
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => ( |
||||
|
<input |
||||
|
autoFocus={autoFocus} |
||||
|
minLength={minLength} |
||||
|
name={name} |
||||
|
placeholder={placeholder} |
||||
|
value={value} |
||||
|
onChange={onChange} |
||||
|
/> |
||||
|
)), |
||||
})); |
})); |
||||
|
|
||||
vi.mock("@components/UI/Button.tsx", () => ({ |
vi.mock('@core/stores/deviceStore.ts', () => ({ |
||||
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button> |
useDevice: vi.fn(), |
||||
})); |
})); |
||||
|
|
||||
vi.mock("@components/UI/Input.tsx", () => ({ |
vi.mock('@core/stores/messageStore.ts', () => ({ |
||||
Input: (props: any) => <input {...props} /> |
useMessageStore: vi.fn(), |
||||
|
MessageState: { |
||||
|
Ack: 'ack', |
||||
|
Waiting: 'waiting', |
||||
|
Failed: 'failed', |
||||
|
}, |
||||
|
MessageType: { |
||||
|
Direct: 'direct', |
||||
|
Broadcast: 'broadcast', |
||||
|
}, |
||||
})); |
})); |
||||
|
|
||||
vi.mock("lucide-react", () => ({ |
vi.mock('@core/utils/debounce.ts', () => ({ |
||||
SendIcon: () => <div data-testid="send-icon">Send</div> |
debounce: vi.fn((fn) => fn), |
||||
})); |
})); |
||||
|
|
||||
// TODO: getting an error with this test
|
vi.mock('lucide-react', () => ({ |
||||
describe('MessageInput Component', () => { |
SendIcon: vi.fn(() => <svg data-testid="send-icon" />), |
||||
const mockProps = { |
})); |
||||
to: "broadcast" as const, |
|
||||
channel: 0 as const, |
|
||||
maxBytes: 100, |
|
||||
}; |
|
||||
|
|
||||
const mockSetMessageDraft = vi.fn(); |
describe('MessageInput', () => { |
||||
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, |
|
||||
}, |
|
||||
}); |
}); |
||||
}); |
|
||||
|
|
||||
it('renders correctly with initial state', () => { |
|
||||
render(<MessageInput {...mockProps} />); |
|
||||
|
|
||||
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument(); |
|
||||
expect(screen.getByTestId('send-icon')).toBeInTheDocument(); |
|
||||
|
|
||||
expect(screen.getByText('0/100')).toBeInTheDocument(); |
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ |
||||
}); |
setMessageState: mockSetMessageState, |
||||
|
activeChat: 123, |
||||
it('updates local draft and byte count when typing', () => { |
setDraft: mockSetDraft, |
||||
render(<MessageInput {...mockProps} />); |
getDraft: mockGetDraft, |
||||
|
clearDraft: mockClearDraft, |
||||
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(); |
mockSetMessageState.mockClear(); |
||||
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'); |
mockSetActiveChat.mockClear(); |
||||
|
mockSetDraft.mockClear(); |
||||
|
mockGetDraft.mockClear(); |
||||
|
mockClearDraft.mockClear(); |
||||
|
mockSendText.mockClear(); |
||||
|
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn); |
||||
}); |
}); |
||||
|
|
||||
it.skip('sends message and resets form when submitting', async () => { |
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => { |
||||
try { |
render(<MessageInput {...props} />); |
||||
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' |
|
||||
); |
|
||||
|
|
||||
|
it.skip('sends text message and updates state to Ack on submit', async () => { |
||||
|
renderComponent({ to: 2, channel: 3, maxBytes: 256 }); |
||||
|
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; |
||||
|
fireEvent.change(inputElement, { target: { value: 'Hello' } }); |
||||
|
const formElement = screen.getByRole('form'); |
||||
|
fireEvent.submit(formElement); |
||||
|
|
||||
|
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') |
it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => { |
||||
fireEvent.submit(form); |
renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 }); |
||||
|
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; |
||||
expect(mockSendText).not.toHaveBeenCalled(); |
fireEvent.change(inputElement, { target: { value: 'Broadcast message' } }); |
||||
|
const formElement = screen.getByRole('form'); |
||||
|
fireEvent.submit(formElement); |
||||
|
|
||||
|
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'); |
||||
|
}); |
||||
}); |
}); |
||||
|
|
||||
it('initializes with existing message draft', () => { |
it('updates state to Failed if sendText throws an error', async () => { |
||||
(useDevice as Mock).mockReturnValue({ |
mockSendText.mockRejectedValue({ id: 456 }); |
||||
connection: { |
renderComponent({ to: 3, channel: 1, maxBytes: 256 }); |
||||
sendText: mockSendText, |
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; |
||||
}, |
fireEvent.change(inputElement, { target: { value: 'Error message' } }); |
||||
setMessageState: mockSetMessageState, |
const formElement = screen.getByRole('form'); |
||||
messageDraft: "Existing draft", |
fireEvent.submit(formElement); |
||||
setMessageDraft: mockSetMessageDraft, |
|
||||
isQueueingMessages: false, |
await waitFor(() => { |
||||
queueStatus: { free: 10 }, |
expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1); |
||||
hardware: { |
expect(mockSetMessageState).toHaveBeenCalledWith({ |
||||
myNodeNum: 1234567890, |
type: 'direct', |
||||
}, |
key: 123, |
||||
|
messageId: 456, |
||||
|
newState: 'failed', |
||||
|
}); |
||||
|
expect(mockClearDraft).toHaveBeenCalledWith(3); |
||||
|
expect(inputElement.value).toBe(''); |
||||
|
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); |
||||
}); |
}); |
||||
|
|
||||
render(<MessageInput {...mockProps} />); |
|
||||
|
|
||||
const inputField = screen.getByRole('textbox'); |
|
||||
|
|
||||
expect(inputField).toHaveValue('Existing draft'); |
|
||||
}); |
}); |
||||
}); |
}); |
||||
@ -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; |
||||
@ -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() |
||||
|
}) |
||||
|
|
||||
|
}) |
||||
@ -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); |
||||
|
}, |
||||
|
}; |
||||
@ -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 }; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue