Browse Source

message store fixes, ui fixes

pull/586/head
Dan Ditomaso 1 year ago
parent
commit
34557f728d
  1. 2
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx
  2. 2
      src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx
  3. 2
      src/components/Dialog/NodeOptionsDialog.tsx
  4. 2
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx
  5. 2
      src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx
  6. 2
      src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts
  7. 2
      src/components/Form/FormWrapper.tsx
  8. 2
      src/components/PageComponents/Connect/BLE.tsx
  9. 2
      src/components/PageComponents/Connect/HTTP.tsx
  10. 2
      src/components/PageComponents/Connect/Serial.tsx
  11. 2
      src/components/PageComponents/Map/NodeDetail.tsx
  12. 28
      src/components/PageComponents/Messages/ChannelChat.tsx
  13. 2
      src/components/PageComponents/Messages/MessageInput.test.tsx
  14. 42
      src/components/PageComponents/Messages/MessageInput.tsx
  15. 23
      src/components/PageComponents/Messages/MessageItem.tsx
  16. 25
      src/components/PageLayout.tsx
  17. 83
      src/components/Sidebar.tsx
  18. 9
      src/components/UI/Avatar.tsx
  19. 81
      src/components/UI/Sidebar/SidebarButton.tsx
  20. 2
      src/components/UI/Sidebar/SidebarSection.tsx
  21. 2
      src/components/UI/Sidebar/sidebarButton.tsx
  22. 2
      src/components/UI/Toast.tsx
  23. 2
      src/components/generic/Table/index.tsx
  24. 2
      src/core/dto/PacketToMessageDTO.ts
  25. 2
      src/core/stores/deviceStore.ts
  26. 234
      src/core/stores/messageStore.ts
  27. 212
      src/core/stores/messageStore/index.ts
  28. 2
      src/core/stores/messageStore/messageStore.test.ts
  29. 69
      src/core/stores/messageStore/types.ts
  30. 69
      src/core/stores/storage/indexDB.ts
  31. 19
      src/core/subscriptions.ts
  32. 4
      src/pages/Config/index.tsx
  33. 62
      src/pages/Messages.tsx

2
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx

@ -1,6 +1,6 @@
import { render, screen, fireEvent } from '@testing-library/react'; import { render, screen, fireEvent } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useMessageStore } from "@core/stores/messageStore.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx";
vi.mock('@core/stores/messageStore.ts', () => ({ vi.mock('@core/stores/messageStore.ts', () => ({

2
src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx

@ -10,7 +10,7 @@ import {
DialogTitle, DialogTitle,
} from "@components/UI/Dialog.tsx"; } from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react"; import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface DeleteMessagesDialogProps { export interface DeleteMessagesDialogProps {
open: boolean; open: boolean;

2
src/components/Dialog/NodeOptionsDialog.tsx

@ -13,7 +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"; import { MessageType, useMessageStore } from "../../core/stores/messageStore/index.ts";
export interface NodeOptionsDialogProps { export interface NodeOptionsDialogProps {
node: Protobuf.Mesh.NodeInfo | undefined; node: Protobuf.Mesh.NodeInfo | undefined;

2
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx

@ -1,7 +1,7 @@
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts"; import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx";
import { useMessageStore } from "@core/stores/messageStore.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { expect, test, vi, beforeEach, afterEach } from 'vitest'; import { expect, test, vi, beforeEach, afterEach } from 'vitest';
import { Protobuf } from "@meshtastic/core"; import { Protobuf } from "@meshtastic/core";

2
src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx

@ -9,7 +9,7 @@ import { Button } from "@components/UI/Button.tsx";
import { LockKeyholeOpenIcon } from "lucide-react"; import { LockKeyholeOpenIcon } from "lucide-react";
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface RefreshKeysDialogProps { export interface RefreshKeysDialogProps {
open: boolean; open: boolean;

2
src/components/Dialog/RefreshKeysDialog/useRefreshKeysDialog.ts

@ -1,6 +1,6 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { useMessageStore } from "@core/stores/messageStore.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export function useRefreshKeysDialog() { export function useRefreshKeysDialog() {
const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice(); const { removeNode, setDialogOpen, clearNodeError, getNodeError } = useDevice();

2
src/components/Form/FormWrapper.tsx

@ -24,7 +24,7 @@ export const FieldWrapper = ({
<div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4"> <div className="grid grid-cols-1 lg:grid-cols-[0.6fr_2fr_.1fr] sm:items-baseline gap-4">
<Label htmlFor={fieldName}>{label}</Label> <Label htmlFor={fieldName}>{label}</Label>
<div className="max-w-3xl"> <div className="max-w-3xl">
<p className="text-sm text-slate-400">{description}</p> <p className="text-sm text-slate-500 dark:text-slate-400">{description}</p>
<p hidden={valid ?? true} className="text-sm text-red-500"> <p hidden={valid ?? true} className="text-sm text-red-500">
{validationText} {validationText}
</p> </p>

2
src/components/PageComponents/Connect/BLE.tsx

@ -7,7 +7,7 @@ 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"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { export const BLE = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]); const [bleDevices, setBleDevices] = useState<BluetoothDevice[]>([]);

2
src/components/PageComponents/Connect/HTTP.tsx

@ -13,7 +13,7 @@ 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 { AlertTriangle } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore.ts"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
interface FormData { interface FormData {
ip: string; ip: string;

2
src/components/PageComponents/Connect/Serial.tsx

@ -8,7 +8,7 @@ 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"; import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => { export const Serial = ({ setConnectionInProgress, closeDialog }: TabElementProps) => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]); const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);

2
src/components/PageComponents/Map/NodeDetail.tsx

@ -23,7 +23,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@radix-ui/react-tooltip"; } from "@radix-ui/react-tooltip";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { MessageType, useMessageStore } from "@core/stores/messageStore.ts"; import { MessageType, useMessageStore } from "../../../core/stores/messageStore/index.ts";
import BatteryStatus from "@components/BatteryStatus.tsx"; import BatteryStatus from "@components/BatteryStatus.tsx";
export interface NodeDetailProps { export interface NodeDetailProps {

28
src/components/PageComponents/Messages/ChannelChat.tsx

@ -1,10 +1,10 @@
import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx"; import { MessageItem } from "@components/PageComponents/Messages/MessageItem.tsx";
import type { Message as MessageType } 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";
import { Message } from "@core/stores/messageStore/types.ts";
export interface ChannelChatProps { export interface ChannelChatProps {
messages?: MessageType[]; messages?: Message[];
} }
const EmptyState = () => ( const EmptyState = () => (
@ -28,7 +28,6 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
useEffect(() => { useEffect(() => {
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) return; if (!scrollContainer) return;
const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; const isScrolledToBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
if (isScrolledToBottom || !userScrolledUpRef.current) { if (isScrolledToBottom || !userScrolledUpRef.current) {
@ -43,9 +42,7 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10; const isAtBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight <= 10;
userScrolledUpRef.current = !isAtBottom; userScrolledUpRef.current = !isAtBottom;
}; };
scrollContainer?.addEventListener('scroll', handleScroll, { passive: true });
scrollContainer?.addEventListener('scroll', handleScroll);
return () => { return () => {
scrollContainer?.removeEventListener('scroll', handleScroll); scrollContainer?.removeEventListener('scroll', handleScroll);
}; };
@ -63,16 +60,17 @@ export const ChannelChat = ({ messages = [] }: ChannelChatProps) => {
return ( return (
<ul <ul
ref={scrollContainerRef} ref={scrollContainerRef}
className="flex-grow overflow-y-auto px-3 py-2" className="flex flex-col flex-grow overflow-y-auto px-3 py-2"
> >
{messages?.map((message) => { <div className="flex-grow" />
return (
<MessageItem {messages?.map((message) => (
key={message.messageId ?? `${message.from}-${message.date}`} <MessageItem
message={message} key={message.messageId ?? `${message.from}-${message.date}`}
/> message={message}
); />
})} ))}
<div ref={messagesEndRef} className="h-px" /> <div ref={messagesEndRef} className="h-px" />
</ul> </ul>
); );

2
src/components/PageComponents/Messages/MessageInput.test.tsx

@ -2,7 +2,7 @@ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach } from 'vitest'; import { vi, describe, it, expect, beforeEach } from 'vitest';
import { MessageInput } from './MessageInput.tsx'; import { MessageInput } from './MessageInput.tsx';
import { useDevice } from '@core/stores/deviceStore.ts'; import { useDevice } from '@core/stores/deviceStore.ts';
import { useMessageStore } from '@core/stores/messageStore.ts'; import { useMessageStore } from '../../../core/stores/messageStore/index.ts';
import { debounce } from '@core/utils/debounce.ts'; import { debounce } from '@core/utils/debounce.ts';
import { Types } from "@meshtastic/core"; import { Types } from "@meshtastic/core";

42
src/components/PageComponents/Messages/MessageInput.tsx

@ -1,57 +1,31 @@
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 type { Types } from "@meshtastic/core"; import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react"; import { SendIcon } from "lucide-react";
import { import { startTransition, useState } from "react";
startTransition, useCallback, // @ts-types="react" import { useMessageStore } from "@core/stores/messageStore/index.ts";
useDeferredValue, useState
} from "react";
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
export interface MessageInputProps { export interface MessageInputProps {
onSend: (message: string) => void;
to: Types.Destination; to: Types.Destination;
channel: Types.ChannelNumber;
maxBytes: number; maxBytes: number;
} }
export const MessageInput = ({ export const MessageInput = ({
onSend,
to, to,
channel,
maxBytes, maxBytes,
}: MessageInputProps) => { }: MessageInputProps) => {
const { connection } = useDevice(); const { setDraft, getDraft, clearDraft } = useMessageStore();
const { setMessageState, activeChat, setDraft, getDraft, clearDraft } = useMessageStore();
const [localDraft, setLocalDraft] = useState(getDraft(to)); const [localDraft, setLocalDraft] = useState(getDraft(to));
const [messageBytes, setMessageBytes] = useState(0); const [messageBytes, setMessageBytes] = useState(0);
const calculateBytes = (text: string) => new Blob([text]).size; const calculateBytes = (text: string) => new Blob([text]).size;
const deferredBytes = useDeferredValue(calculateBytes(localDraft));
const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct;
const sendText = useCallback(async (message: string) => {
try {
const messageId = await connection?.sendText(message, to, true, channel);
if (messageId !== undefined) {
setMessageState({ type: chatType, key: activeChat, messageId, newState: MessageState.Ack });
}
// deno-lint-ignore no-explicit-any
} catch (e: any) {
setMessageState({
type: chatType,
key: activeChat,
messageId: e?.id,
newState: MessageState.Failed,
});
}
}, [channel, connection, setMessageState, to, activeChat, chatType]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value; const newValue = e.target.value;
const byteLength = deferredBytes const byteLength = calculateBytes(newValue);
if (byteLength <= maxBytes) { if (byteLength <= maxBytes) {
setLocalDraft(newValue); setLocalDraft(newValue);
@ -63,12 +37,12 @@ export const MessageInput = ({
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!localDraft.trim()) return; if (!localDraft.trim()) return;
setMessageBytes(0);
startTransition(() => { startTransition(() => {
sendText(localDraft.trim()); onSend(localDraft.trim());
setLocalDraft(""); setLocalDraft("");
clearDraft(to); clearDraft(to);
setMessageBytes(0);
}); });
}; };

23
src/components/PageComponents/Messages/MessageItem.tsx

@ -11,7 +11,7 @@ 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/stores/messageStore.ts"; import { Message, MessageState } from "../../../core/stores/messageStore/index.ts";
import { Protobuf } from "@meshtastic/js"; import { Protobuf } from "@meshtastic/js";
import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx"; import { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx";
@ -25,12 +25,13 @@ interface MessageStatus {
displayText: string; displayText: string;
icon: LucideIcon; icon: LucideIcon;
ariaLabel: string; ariaLabel: string;
iconClassName?: string;
} }
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = { const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered" }, [MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered", iconClassName: "text-green-500 dark:text-green-500" },
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message" }, [MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message", iconClassName: "text-slate-400 dark:text-slate-400" },
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed" }, [MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed", iconClassName: "text-red-500 dark:text-red-400" },
}; };
const getMessageStatus = (state: MessageState): MessageStatus => const getMessageStatus = (state: MessageState): MessageStatus =>
@ -40,7 +41,7 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children:
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-2 py-1 rounded text-xs"> <TooltipContent className="bg-slate-800 dark:bg-slate-600 text-white px-4 py-1 rounded text-xs">
{status.displayText} {status.displayText}
<TooltipArrow className="fill-slate-800" /> <TooltipArrow className="fill-slate-800" />
</TooltipContent> </TooltipContent>
@ -48,9 +49,9 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children:
</TooltipProvider> </TooltipProvider>
); );
const StatusIcon = ({ status, className }: { status: MessageStatus; className?: string }) => { const StatusIcon = ({ status }: { status: MessageStatus }) => {
const Icon = status.icon; const Icon = status.icon;
const iconClass = cn("size-4 shrink-0", className); const iconClass = cn("size-4 shrink-0", status.iconClassName);
return ( return (
<StatusTooltip status={status}> <StatusTooltip status={status}>
<span aria-label={status.ariaLabel} role="img"> <span aria-label={status.ariaLabel} role="img">
@ -107,14 +108,11 @@ export const MessageItem = ({ message }: MessageProps) => {
"transition-colors duration-100 ease-in-out", "transition-colors duration-100 ease-in-out",
); );
// const avatarSizeClass = "size-11";
const gridGapClass = "gap-x-2"; const gridGapClass = "gap-x-2";
const baseTextStyle = "text-sm text-slate-800 dark:text-slate-200"; const baseTextStyle = "text-sm text-slate-800 dark:text-slate-200";
const nameTextStyle = "font-medium text-slate-900 dark:text-slate-100 mr-2"; const nameTextStyle = "font-medium text-slate-900 dark:text-slate-100 mr-2";
const dateTextStyle = "text-slate-500 dark:text-slate-400"; const dateTextStyle = "text-slate-500 dark:text-slate-400";
const statusIconBaseColor = "text-slate-400 dark:text-slate-400";
const statusIconFailedColor = "text-red-500 dark:text-red-400";
return ( return (
<li className={messageItemWrapperClass}> <li className={messageItemWrapperClass}>
@ -128,10 +126,7 @@ export const MessageItem = ({ message }: MessageProps) => {
{displayName} {displayName}
</span> </span>
<TimeDisplay date={messageDate} className={dateTextStyle} /> <TimeDisplay date={messageDate} className={dateTextStyle} />
<StatusIcon <StatusIcon status={messageStatus} />
status={messageStatus}
className={cn(isFailed ? statusIconFailedColor : statusIconBaseColor)}
/>
</div> </div>
) : null} ) : null}

25
src/components/PageLayout.tsx

@ -48,7 +48,7 @@ export const PageLayout = ({
{leftBar && ( {leftBar && (
<aside <aside
className={cn( className={cn(
"p-2 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ", "px-2 shrink-0 border-r-[0.5px] border-slate-300 dark:border-slate-700 ",
leftBarClassName leftBarClassName
)} )}
> >
@ -56,18 +56,17 @@ export const PageLayout = ({
</aside> </aside>
)} )}
{/* Center column (Header + Main) */}
<div className="flex flex-1 flex-col min-w-0"> <div className="flex flex-1 flex-col min-w-0">
{/* Header */} {/* Header */}
<header <header
className={cn( className={cn(
"flex h-16 shrink-0 p-2 items-center border-b border-slate-300 dark:border-slate-700 px-4", "flex h-14 shrink-0 mt-2 p-2 items-center border-b border-slate-300 dark:border-slate-700",
topBarClassName topBarClassName
)} )}
> >
{/* Header Content */} {/* Header Content */}
<div className="flex flex-1 items-center justify-between min-w-0"> <div className="flex flex-1 items-center justify-between min-w-0">
<span className="text-lg font-medium text-foreground truncate pr-4"> <span className="text-lg font-medium text-foreground truncate px-2">
{label} {label}
</span> </span>
<div className="flex items-center space-x-3 md:space-x-4 shrink-0"> <div className="flex items-center space-x-3 md:space-x-4 shrink-0">
@ -82,13 +81,15 @@ export const PageLayout = ({
aria-disabled={action.disabled} aria-disabled={action.disabled}
aria-busy={action.isLoading} aria-busy={action.isLoading}
> >
{action.isLoading ? ( <div className="mr-6">
<Spinner size="sm" /> {action.isLoading ? (
) : ( <Spinner size="md" />
<action.icon ) : (
className={cn("h-5 w-5", action.iconClasses)} <action.icon
/> className={cn("h-5 w-5", action.iconClasses)}
)} />
)}
</div>
</button> </button>
))} ))}
</div> </div>
@ -112,7 +113,7 @@ export const PageLayout = ({
{rightBar && ( {rightBar && (
<aside <aside
className={cn( className={cn(
"w-56 lg:w-76 max-w-76 shrink-0 border-l border-slate-300 dark:border-slate-700 p-2 overflow-hidden", "w-64 shrink-0 border-l border-slate-300 dark:border-slate-700 px-2 overflow-hidden",
rightBarClassName rightBarClassName
)} )}
> >

83
src/components/Sidebar.tsx

@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "./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";
@ -25,6 +24,7 @@ import { useSidebar } from "@core/stores/sidebarStore.tsx";
import ThemeSwitcher from "@components/ThemeSwitcher.tsx"; import ThemeSwitcher from "@components/ThemeSwitcher.tsx";
import { useAppStore } from "@core/stores/appStore.ts"; import { useAppStore } from "@core/stores/appStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx"; import BatteryStatus from "@components/BatteryStatus.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
export interface SidebarProps { export interface SidebarProps {
children?: React.ReactNode; children?: React.ReactNode;
@ -95,7 +95,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
<div <div
className={cn( className={cn(
'h-14 py-4 px-1 flex gap-2 items-center flex-shrink-0 transition-all duration-300 ease-in-out', 'h-14 flex mt-2 gap-2 items-center flex-shrink-0 transition-all duration-300 ease-in-out',
'border-b-[0.5px] border-slate-300 dark:border-slate-700', 'border-b-[0.5px] border-slate-300 dark:border-slate-700',
isCollapsed && 'justify-center px-0' isCollapsed && 'justify-center px-0'
@ -145,7 +145,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
{children} {children}
</div> </div>
<div className="pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 p-2 flex-shrink-0"> <div className="pt-4 border-t-[0.5px] bg-background-primary border-slate-300 dark:border-slate-700 flex-shrink-0">
{myNode === undefined ? ( {myNode === undefined ? (
<div className="flex flex-col items-center justify-center py-6"> <div className="flex flex-col items-center justify-center py-6">
<Spinner /> <Spinner />
@ -162,54 +162,49 @@ export const Sidebar = ({ children }: SidebarProps) => {
<> <>
<div <div
className={cn( className={cn(
'flex flex-col items-start justify-between', 'flex place-items-center gap-2',
isCollapsed && 'items-center justify-center' isCollapsed && 'justify-center'
)} )}
> >
<div <Avatar
className={cn( text={myNode.user?.shortName ?? myNode.num.toString()}
'flex place-items-center gap-2', className={cn("flex-shrink-0 ml-2",
isCollapsed && 'w-full justify-center' isCollapsed && "ml-0",
)} )}
> size="sm"
<Avatar />
text={myNode.user?.shortName ?? myNode.num.toString()} <p
className="flex-shrink-0"
size="sm"
/>
<p
className={cn(
'max-w-[20ch] text-wrap text-sm font-medium',
'transition-all duration-300 ease-in-out overflow-hidden',
isCollapsed
? 'opacity-0 max-w-0 invisible'
: 'opacity-100 max-w-full visible'
)}
>
{myNode.user?.longName ?? "UNK"}
</p>
</div>
<div
className={cn( className={cn(
'flex flex-col gap-0.5 ml-2 mt-2', 'max-w-[20ch] text-wrap text-sm font-medium',
'transition-all duration-300 ease-in-out', 'transition-all duration-300 ease-in-out overflow-hidden',
isCollapsed isCollapsed
? 'opacity-0 max-w-0 h-0 invisible' ? 'opacity-0 max-w-0 invisible'
: 'opacity-100 max-w-xs h-auto visible' : 'opacity-100 max-w-full visible'
)} )}
> >
<div className="inline-flex gap-2"> {myNode.user?.longName}
<BatteryStatus deviceMetrics={myNode.deviceMetrics} /> </p>
</div> </div>
<div className="inline-flex gap-2">
<ZapIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> <div
<Subtle>{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts</Subtle> className={cn(
</div> 'flex flex-col gap-0.5 ml-2 mt-2',
<div className="inline-flex gap-2"> 'transition-all duration-300 ease-in-out',
<CpuIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" /> isCollapsed
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle> ? 'opacity-0 max-w-0 h-0 invisible'
</div> : 'opacity-100 max-w-xs h-auto visible'
)}
>
<div className="inline-flex gap-2">
<BatteryStatus deviceMetrics={myNode.deviceMetrics} />
</div>
<div className="inline-flex gap-2">
<ZapIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />
<Subtle>{myNode.deviceMetrics?.voltage?.toPrecision(3) ?? "UNK"} volts</Subtle>
</div>
<div className="inline-flex gap-2">
<CpuIcon size={18} className="text-gray-500 dark:text-gray-400 w-4 flex-shrink-0" />
<Subtle>v{myMetadata?.firmwareVersion ?? "UNK"}</Subtle>
</div> </div>
</div> </div>
<div <div

9
src/components/UI/Avatar.tsx

@ -41,8 +41,11 @@ class ColorUtils {
} }
const getColorFromText = (text: string): RGBColor => { const getColorFromText = (text: string): RGBColor => {
if (!text) {
return { r: 0, g: 0, b: 0, a: 255 };
}
let hash = 0; let hash = 0;
for (let i = 0; i < text.length; i++) { for (let i = 0; i < text?.length; i++) {
hash = text.charCodeAt(i) + ((hash << 5) - hash); hash = text.charCodeAt(i) + ((hash << 5) - hash);
hash |= 0; hash |= 0;
} }
@ -66,11 +69,11 @@ export const Avatar = ({
lg: "size-16 text-lg", lg: "size-16 text-lg",
}; };
const safeText = text?.toString().toUpperCase() ?? "UNK"; const safeText = text?.toString().toUpperCase();
const bgColor = getColorFromText(safeText); const bgColor = getColorFromText(safeText);
const isLight = ColorUtils.isLight(bgColor); const isLight = ColorUtils.isLight(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF"; const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = safeText.slice(0, 4) || "UNK"; const initials = safeText?.slice(0, 4) ?? "UNK";
return ( return (
<div <div

81
src/components/UI/Sidebar/SidebarButton.tsx

@ -0,0 +1,81 @@
import React from "react";
import { Button } from "@components/UI/Button.tsx";
import type { LucideIcon } from "lucide-react";
import { cn } from "@core/utils/cn.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx";
export interface SidebarButtonProps {
label: string;
count?: number;
active?: boolean;
Icon?: LucideIcon;
children?: React.ReactNode;
onClick?: () => void;
disabled?: boolean;
preventCollapse?: boolean;
}
export const SidebarButton = ({
label,
active,
Icon,
count,
children,
onClick,
disabled = false,
preventCollapse = false,
}: SidebarButtonProps) => {
const { isCollapsed: isSidebarCollapsed } = useSidebar();
const isButtonCollapsed = isSidebarCollapsed && !preventCollapse;
return (
<Button
onClick={onClick}
variant={active ? "subtle" : "ghost"}
size="sm"
className={cn(
"flex w-full items-center text-wrap",
isButtonCollapsed
? 'justify-center gap-0 px-2 h-9'
: 'justify-start gap-2 min-h-9'
)}
disabled={disabled}
>
{Icon && (
<Icon
size={isButtonCollapsed ? 20 : 18}
className="flex-shrink-0"
/>
)}
{children}
<span
className={cn(
'flex flex-wrap justify-start text-left text-wrap break-all',
'min-w-0',
'px-1',
'transition-all duration-300 ease-in-out',
isButtonCollapsed
? 'opacity-0 max-w-0 invisible w-0 overflow-hidden'
: 'opacity-100 max-w-full visible flex-1 whitespace-normal'
)}
>
{label}
</span>
{!isButtonCollapsed && !active && count && count > 0 && (
<div
className={cn(
"ml-auto flex-shrink-0 justify-end text-white text-xs rounded-full px-1.5 py-0.5 bg-red-600",
"flex-shrink-0",
"transition-opacity duration-300 ease-in-out",
isButtonCollapsed ? 'opacity-0 invisible' : 'opacity-100 visible'
)}
>
{count}
</div>
)}
</Button>
);
};

2
src/components/UI/Sidebar/SidebarSection.tsx

@ -18,7 +18,7 @@ export const SidebarSection = ({
return ( return (
<div className={cn( <div className={cn(
"py-2", "py-2",
isCollapsed ? 'px-0' : 'px-3', isCollapsed ? 'px-0' : 'px-4',
className, className,
)}> )}>

2
src/components/UI/Sidebar/sidebarButton.tsx

@ -52,7 +52,7 @@ export const SidebarButton = ({
<span <span
className={cn( className={cn(
'flex flex-wrap justify-start text-left text-wrap', 'flex flex-wrap justify-start text-left text-wrap break-all',
'min-w-0', 'min-w-0',
'px-1', 'px-1',
'transition-all duration-300 ease-in-out', 'transition-all duration-300 ease-in-out',

2
src/components/UI/Toast.tsx

@ -28,7 +28,7 @@ const toastVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"border bg-backgound-primary text-foreground dark:bg-slate-100 dark:border-slate-600 dark:text-slate-900", "border bg-white text-slate-900 dark:text-slate-100 dark:bg-slate-800",
destructive: destructive:
"group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50", "group destructive bg-red-600 text-white dark:border-red-900 dark:bg-red-900 dark:text-red-50",
}, },

2
src/components/generic/Table/index.tsx

@ -112,8 +112,6 @@ export const Table = ({ headings, rows }: TableProps) => {
// biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed. // biome-ignore lint/suspicious/noArrayIndexKey: TODO: Once this table is sortable, this should get fixed.
return (<tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}> return (<tr key={index} className={`${index % 2 ? 'bg-white dark:bg-white/2' : 'bg-slate-50/50 dark:bg-slate-50/5'} border-b-1 border-slate-200 dark:border-slate-900`}>
{row.map((item, index) => { {row.map((item, index) => {
console.log(item);
return (index === 0 ? return (index === 0 ?
<th <th
key={item.key ?? index} key={item.key ?? index}

2
src/core/dto/PacketToMessageDTO.ts

@ -1,5 +1,5 @@
import type { Types } from "@meshtastic/js"; import type { Types } from "@meshtastic/js";
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts"; import { Message, MessageType, MessageState } from "../stores/messageStore/index.ts";
class PacketToMessageDTO { class PacketToMessageDTO {
channel: Types.ChannelNumber; channel: Types.ChannelNumber;

2
src/core/stores/deviceStore.ts

@ -288,6 +288,8 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
); );
}, },
addNodeInfo: (nodeInfo) => { addNodeInfo: (nodeInfo) => {
console.log("Node Info", nodeInfo);
set( set(
produce<DeviceState>((draft) => { produce<DeviceState>((draft) => {
const device = draft.devices.get(id); const device = draft.devices.get(id);

234
src/core/stores/messageStore.ts

@ -1,234 +0,0 @@
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;
deleteAllMessages: () => void;
clearMessageByMessageId: (params: {
type: MessageType;
from?: number;
to?: 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, from, to, 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 && from !== undefined && to !== undefined) {
const messageMap = state.messages.direct?.[from]?.[to];
if (messageMap?.[messageId]) {
delete messageMap[messageId];
if (Object.keys(messageMap).length === 0) {
delete state.messages.direct[from][to];
if (Object.keys(state.messages.direct[from]).length === 0) {
delete state.messages.direct[from];
}
}
}
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);
}));
},
deleteAllMessages: () => {
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,
// }),
// }
);

212
src/core/stores/messageStore/index.ts

@ -0,0 +1,212 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { produce } from 'immer';
import { Types } from '@meshtastic/core';
import { storageWithMapSupport } from "../storage/indexDB.ts";
import { ChannelId, ClearMessageParams, ConversationId, GetMessagesParams, Message, MessageId, MessageLogMap, NodeNum, SetMessageStateParams } from "@core/stores/messageStore/types.ts";
export enum MessageState {
Ack = "ack",
Waiting = "waiting",
Failed = "failed",
}
export enum MessageType {
Direct = "direct",
Broadcast = "broadcast",
}
function getConversationId(node1: NodeNum, node2: NodeNum): ConversationId {
return [node1, node2].sort((a, b) => a - b).join(':');
}
export interface MessageStore {
messages: {
direct: Map<ConversationId, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
};
export interface MessageStore {
messages: MessageStore['messages'];
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;
getMyNodeNum: () => number;
setActiveChat: (chat: number) => void;
setChatType: (type: MessageType) => void;
saveMessage: (message: Message) => void;
setMessageState: (params: SetMessageStateParams) => void;
getMessages: (params: GetMessagesParams) => Message[];
getDraft: (key: Types.Destination) => string;
setDraft: (key: Types.Destination, message: string) => void;
deleteAllMessages: () => void;
clearMessageByMessageId: (params: ClearMessageParams) => void;
clearDraft: (key: Types.Destination) => void;
}
const CURRENT_STORE_VERSION = 0;
export const useMessageStore = create<MessageStore>()(
persist(
(set, get) => ({
messages: {
direct: new Map<ConversationId, MessageLogMap>(),
broadcast: new Map<ChannelId, MessageLogMap>(),
},
draft: new Map<number, string>(),
activeChat: 0,
chatType: MessageType.Broadcast,
nodeNum: 0,
setNodeNum: (nodeNum) => {
set(produce((state: MessageStore) => {
state.nodeNum = nodeNum;
}));
},
getMyNodeNum: () => get().nodeNum,
setActiveChat: (chat) => {
set(produce((state: MessageStore) => {
state.activeChat = chat;
}));
},
setChatType: (type) => {
set(produce((state: MessageStore) => {
state.chatType = type;
}));
},
saveMessage: (message: Message) => {
set(
produce((state: MessageStore) => {
if (message.type === MessageType.Direct) {
const conversationId = getConversationId(message.from, message.to);
if (!state.messages.direct.has(conversationId)) {
state.messages.direct.set(conversationId, new Map<MessageId, Message>());
}
state.messages.direct.get(conversationId)!.set(message.messageId, message);
} else if (message.type === MessageType.Broadcast) {
const channelId = message.channel as ChannelId;
if (!state.messages.broadcast.has(channelId)) {
state.messages.broadcast.set(channelId, new Map<MessageId, Message>());
}
state.messages.broadcast.get(channelId)!.set(message.messageId, message);
}
})
);
},
setMessageState: (params: SetMessageStateParams) => {
set(
produce((state: MessageStore) => {
let messageLog: MessageLogMap | undefined;
let targetMessage: Message | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageLog = state.messages.direct.get(conversationId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
}
} else { // Broadcast
messageLog = state.messages.broadcast.get(params.channelId);
if (messageLog) {
targetMessage = messageLog.get(params.messageId);
}
}
if (targetMessage) {
targetMessage.state = params.newState ?? MessageState.Ack;
} else {
console.warn(`Message or conversation/channel not found for state update. Params: ${JSON.stringify(params)}`);
}
})
);
},
getMessages: (params: GetMessagesParams): Message[] => {
const state = get();
let messageMap: MessageLogMap | undefined;
if (params.type === MessageType.Direct) {
const conversationId = getConversationId(params.nodeA, params.nodeB);
messageMap = state.messages.direct.get(conversationId);
} else {
messageMap = state.messages.broadcast.get(params.channelId);
}
if (messageMap === undefined) {
return [];
}
const messagesArray = Array.from(messageMap.values());
messagesArray.sort((a, b) => a.date - b.date);
return messagesArray;
},
clearMessageByMessageId: (params: ClearMessageParams) => {
set(
produce((state: MessageStore) => {
let messageLog: MessageLogMap | undefined;
let parentMap: Map<ConversationId | ChannelId, MessageLogMap>;
let parentKey: ConversationId | ChannelId;
if (params.type === MessageType.Direct) {
parentKey = getConversationId(params.nodeA, params.nodeB);
parentMap = state.messages.direct;
messageLog = parentMap.get(parentKey);
} else {
parentKey = params.channelId;
parentMap = state.messages.broadcast;
messageLog = parentMap.get(parentKey);
}
if (messageLog) {
const deleted = messageLog.delete(params.messageId);
if (deleted) {
console.log(`Deleted message ${params.messageId} from ${params.type} chat ${parentKey}`);
// Clean up empty MessageLogMap and its entry in the parent map
if (messageLog.size === 0) {
parentMap.delete(parentKey);
console.log(`Cleaned up empty chat entry for ${parentKey}`);
}
} else {
console.warn(`Message ${params.messageId} not found in ${params.type} chat ${parentKey} for deletion.`);
}
} else {
console.warn(`Chat entry ${parentKey} not found for message deletion.`);
}
})
);
},
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);
}));
},
deleteAllMessages: () => {
set(produce((state: MessageStore) => {
state.messages.direct = new Map<ConversationId, MessageLogMap>();
state.messages.broadcast = new Map<ChannelId, MessageLogMap>();
}));
}
}),
{
name: 'meshtastic-message-store',
storage: storageWithMapSupport,
version: CURRENT_STORE_VERSION,
partialize: (state) => ({
messages: state.messages,
nodeNum: state.nodeNum,
}),
})
)

2
src/core/stores/messageStore.test.ts → src/core/stores/messageStore/messageStore.test.ts

@ -4,7 +4,7 @@ import {
MessageType, MessageType,
MessageState, MessageState,
type Message, type Message,
} from './messageStore.ts'; } from './index.ts';
let memoryStorage: Record<string, string> = {}; let memoryStorage: Record<string, string> = {};

69
src/core/stores/messageStore/types.ts

@ -0,0 +1,69 @@
import { Types } from "@meshtastic/core";
import { MessageState, MessageType } from "@core/stores/messageStore/index.ts";
type NodeNum = number;
type MessageId = number;
type ChannelId = Types.ChannelNumber;
type ConversationId = string;
type MessageLogMap = Map<MessageId, Message>;
export 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>;
type GetMessagesParams =
| { type: MessageType.Direct; nodeA: NodeNum; nodeB: NodeNum }
| { type: MessageType.Broadcast; channelId: ChannelId };
type SetMessageStateParams =
| {
type: MessageType.Direct;
nodeA: NodeNum;
nodeB: NodeNum;
messageId: MessageId; // ID of the message within that chat
newState?: MessageState; // Optional new state, defaults to Ack
}
| {
type: MessageType.Broadcast;
channelId: ChannelId;
messageId: MessageId;
newState?: MessageState; // Optional new state, defaults to Ack
};
type ClearMessageParams =
| {
type: MessageType.Direct;
nodeA: NodeNum;
nodeB: NodeNum;
messageId: MessageId;
}
| {
type: MessageType.Broadcast;
channelId: ChannelId;
messageId: MessageId;
};
export type {
ConversationId,
NodeNum,
MessageLogMap,
ChannelId,
MessageId,
GetMessagesParams,
SetMessageStateParams,
ClearMessageParams,
}

69
src/core/stores/storage/indexDB.ts

@ -1,5 +1,14 @@
import { StateStorage } from "zustand/middleware"; import { PersistStorage, StateStorage, } from "zustand/middleware"; // Added StorageValue for clarity, though not strictly needed in the final signature here
import { get, set, del } from "idb-keyval"; import { get, set, del } from "idb-keyval";
import { ChannelId, MessageLogMap } from "@core/stores/messageStore/types.ts";
type PersistedMessageState = {
messages: {
direct: Map<string, MessageLogMap>;
broadcast: Map<ChannelId, MessageLogMap>;
};
nodeNum: number;
};
export const zustandIndexDBStorage: StateStorage = { export const zustandIndexDBStorage: StateStorage = {
getItem: async (name: string): Promise<string | null> => { getItem: async (name: string): Promise<string | null> => {
@ -12,3 +21,61 @@ export const zustandIndexDBStorage: StateStorage = {
await del(name); await del(name);
}, },
}; };
type SerializedMap<K = unknown, V = unknown> = {
__dataType: 'Map';
value: Array<[K, V]>;
};
type JsonReplacer = (this: any, key: string, value: unknown) => unknown;
const replacer: JsonReplacer = (_, value) => {
if (value instanceof Map) {
const map = value as Map<unknown, unknown>;
const serialized: SerializedMap = {
__dataType: 'Map',
value: Array.from(map.entries()),
};
return serialized;
}
return value;
};
type JsonReviver = (this: any, key: string, value: unknown) => unknown;
function isSerializedMap(value: unknown): value is SerializedMap {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
return false;
}
const potentialMap = value as Partial<SerializedMap>;
return potentialMap.__dataType === 'Map' && Array.isArray(potentialMap.value);
}
const reviver: JsonReviver = (_, value) => {
if (isSerializedMap(value)) {
return new Map(value.value);
}
return value;
};
export const storageWithMapSupport: PersistStorage<PersistedMessageState> = {
getItem: async (name): Promise<PersistedMessageState | null> => {
const str = await zustandIndexDBStorage.getItem(name);
if (!str) { return null; }
try {
const parsed = JSON.parse(str, reviver) as PersistedMessageState;
return parsed;
} catch (error) {
console.error(`Error parsing persisted state (${name}):`, error);
return null;
}
},
setItem: async (name, newValue: PersistedMessageState): Promise<void> => {
try {
const str = JSON.stringify(newValue, replacer);
await zustandIndexDBStorage.setItem(name, str);
} catch (error) {
console.error(`Error stringifying or setting persisted state (${name}):`, error);
}
},
removeItem: async (name): Promise<void> => {
await zustandIndexDBStorage.removeItem(name);
},
};

19
src/core/subscriptions.ts

@ -1,6 +1,6 @@
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 { MessageType, type MessageStore } from "@core/stores/messageStore.ts"; import { MessageState, MessageType, type MessageStore } from "./stores/messageStore/index.ts";
import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts"; import PacketToMessageDTO from "@core/dto/PacketToMessageDTO.ts";
import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts"; import NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
@ -57,16 +57,22 @@ export const subscribeAll = (
}); });
connection.events.onUserPacket.subscribe((user) => { connection.events.onUserPacket.subscribe((user) => {
console.log("User Packet", user);
device.addUser(user); device.addUser(user);
}); });
connection.events.onPositionPacket.subscribe((position) => { connection.events.onPositionPacket.subscribe((position) => {
console.log("Position Packet", position);
device.addPosition(position); device.addPosition(position);
}); });
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => { connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo); // const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
device.addNodeInfo(nodeWithUser); // console.log("Node Info", nodeWithUser);
device.addNodeInfo(nodeInfo);
}); });
connection.events.onChannelPacket.subscribe((channel) => { connection.events.onChannelPacket.subscribe((channel) => {
@ -82,6 +88,8 @@ export const subscribeAll = (
connection.events.onMessagePacket.subscribe((messagePacket) => { connection.events.onMessagePacket.subscribe((messagePacket) => {
// incoming and outgoing messages are handled by this event listener // incoming and outgoing messages are handled by this event listener
console.log("Message Packet", messagePacket);
const dto = new PacketToMessageDTO(messagePacket, myNodeNum); const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
const message = dto.toMessage(); const message = dto.toMessage();
messageStore.saveMessage(message); messageStore.saveMessage(message);
@ -119,16 +127,17 @@ export const subscribeAll = (
connection.events.onRoutingPacket.subscribe((routingPacket) => { connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") { if (routingPacket.data.variant.case === "errorReason") {
switch (routingPacket.data.variant.value) { switch (routingPacket.data.variant.value) {
case Protobuf.Mesh.Routing_Error.MAX_RETRANSMIT:
console.error(`Routing Error: ${routingPacket.data.variant.value}`);
break;
case Protobuf.Mesh.Routing_Error.NO_CHANNEL: case Protobuf.Mesh.Routing_Error.NO_CHANNEL:
console.error(`Routing Error: ${routingPacket.data.variant.value}`); console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]); device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
// TODO: this has be refactored as it isn't working properly
device.setDialogOpen("refreshKeys", true); device.setDialogOpen("refreshKeys", true);
break; break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY: case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`Routing Error: ${routingPacket.data.variant.value}`); console.error(`Routing Error: ${routingPacket.data.variant.value}`);
device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]); device.setNodeError(routingPacket.from, Protobuf.Mesh.Routing_Error[routingPacket?.data?.variant?.value]);
// TODO: this has be refactored as it isn't working properly
device.setDialogOpen("refreshKeys", true); device.setDialogOpen("refreshKeys", true);
break; break;
default: { default: {

4
src/pages/Config/index.tsx

@ -3,7 +3,7 @@ import { useDevice } from "@core/stores/deviceStore.ts";
import { PageLayout } from "@components/PageLayout.tsx"; import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "../../components/UI/Sidebar/sidebarButton.tsx"; import { SidebarButton } from "../../components/UI/Sidebar/SidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx"; import { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx"; import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
@ -72,7 +72,7 @@ const ConfigPage = () => {
const leftSidebar = useMemo( const leftSidebar = useMemo(
() => ( () => (
<Sidebar> <Sidebar>
<SidebarSection label="Config Sections"> <SidebarSection label="Modules">
<SidebarButton <SidebarButton
label="Radio Config" label="Radio Config"
active={activeConfigSection === "device"} active={activeConfigSection === "device"}

62
src/pages/Messages.tsx

@ -3,30 +3,29 @@ import { PageLayout } from "@components/PageLayout.tsx";
import { Sidebar } from "@components/Sidebar.tsx"; import { Sidebar } from "@components/Sidebar.tsx";
import { Avatar } from "@components/UI/Avatar.tsx"; import { Avatar } from "@components/UI/Avatar.tsx";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx"; import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "../components/UI/Sidebar/sidebarButton.tsx"; import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
import { useToast } from "@core/hooks/useToast.ts"; import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores/deviceStore.ts"; import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, Types } from "@meshtastic/core"; import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Channels.tsx"; import { getChannelName } from "@pages/Channels.tsx";
import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react"; import { HashIcon, LockIcon, LockOpenIcon } from "lucide-react";
import { useDeferredValue, useMemo, useState } from "react"; import { useCallback, useDeferredValue, useMemo, 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"; import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore/index.ts";
import { useSidebar } from "@core/stores/sidebarStore.tsx"; import { useSidebar } from "@core/stores/sidebarStore.tsx";
import { Input } from "@components/UI/Input.tsx"; import { Input } from "@components/UI/Input.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number }; type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
export const MessagesPage = () => { export const MessagesPage = () => {
const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread } = useDevice(); const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread, connection } = useDevice();
const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore() const { getMyNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType, setMessageState, } = useMessageStore()
const { toast } = useToast(); const { toast } = useToast();
const { isCollapsed } = useSidebar() const { isCollapsed } = useSidebar()
const [searchTerm, setSearchTerm] = useState<string>(""); const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm); const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = (): NodeInfoWithUnread[] => { const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase(); const lowerCaseSearchTerm = deferredSearch.toLowerCase();
@ -42,35 +41,70 @@ export const MessagesPage = () => {
.sort((a, b) => b.unreadCount - a.unreadCount); .sort((a, b) => b.unreadCount - a.unreadCount);
} }
console.log('filtered nodes', filteredNodes());
const allChannels = Array.from(channels.values()); const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter( const filteredChannels = allChannels.filter(
(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 otherNode = getNode(activeChat); const otherNode = getNode(activeChat);
const isDirect = chatType === MessageType.Direct; const isDirect = chatType === MessageType.Direct;
const isBroadcast = chatType === MessageType.Broadcast; const isBroadcast = chatType === MessageType.Broadcast;
const currentChat = { type: chatType, id: activeChat }; const sendText = useCallback(async (message: string) => {
const isDirect = chatType === MessageType.Direct;
const toValue = isDirect ? activeChat : MessageType.Broadcast;
const channelValue = isDirect ? Types.ChannelNumber.Primary : activeChat ?? 0;
console.log(`Sending message: "${message}" to: ${toValue}, channel: ${channelValue}, type: ${chatType}`);
let messageId: number | undefined;
try {
messageId = await connection?.sendText(message, toValue, true, channelValue);
console.log("Message sent, ID:", messageId);
if (messageId !== undefined) {
if (chatType === MessageType.Broadcast) {
setMessageState({ type: chatType, channelId: channelValue, messageId, newState: MessageState.Ack });
} else {
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId, newState: MessageState.Ack });
}
} else {
console.warn("sendText completed but messageId is undefined");
}
// deno-lint-ignore no-explicit-any
} catch (e: any) {
console.error("Failed to send message:", e);
// Note: messageId might be undefined here if the error occurred before it was assigned
if (chatType === MessageType.Broadcast) {
const failedId = messageId ?? `failed-${Date.now()}`;
setMessageState({ type: chatType, channelId: channelValue, messageId: failedId, newState: MessageState.Failed });
} else { // MessageType.Direct
const failedId = messageId ?? `failed-${Date.now()}`;
setMessageState({ type: chatType, nodeA: getMyNodeNum(), nodeB: activeChat, messageId: failedId, newState: MessageState.Failed });
}
}
}, [activeChat, chatType, connection, getMyNodeNum, setMessageState]);
const renderChatContent = () => { const renderChatContent = () => {
switch (chatType) { switch (chatType) {
case MessageType.Broadcast: case MessageType.Broadcast:
return ( return (
<ChannelChat <ChannelChat
messages={getMessages(MessageType.Broadcast, { messages={getMessages({
myNodeNum: getNodeNum(), type: MessageType.Broadcast,
channel: currentChannel?.index channelId: activeChat ?? 0,
})} })}
/> />
); );
case MessageType.Direct: case MessageType.Direct:
return ( return (
<ChannelChat <ChannelChat
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })} messages={getMessages({ type: MessageType.Direct, nodeA: getMyNodeNum(), nodeB: activeChat })}
/> />
); );
default: default:
@ -106,7 +140,7 @@ export const MessagesPage = () => {
const rightSidebar = useMemo(() => ( const rightSidebar = useMemo(() => (
<SidebarSection label="" className="px-0 flex flex-col h-full overflow-y-auto"> <SidebarSection label="" className="px-0 flex flex-col h-full overflow-y-auto">
<label className="px-4 pt-4"> <label className="p-2 block">
<Input <Input
type="text" type="text"
placeholder="Search nodes..." placeholder="Search nodes..."
@ -177,7 +211,7 @@ export const MessagesPage = () => {
{(isBroadcast || isDirect) ? ( {(isBroadcast || isDirect) ? (
<MessageInput <MessageInput
to={isDirect ? activeChat : MessageType.Broadcast} to={isDirect ? activeChat : MessageType.Broadcast}
channel={isDirect ? Types.ChannelNumber.Primary : currentChat.id} onSend={sendText}
maxBytes={200} maxBytes={200}
/> />
) : ( ) : (

Loading…
Cancel
Save