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 { 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";
vi.mock('@core/stores/messageStore.ts', () => ({

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

@ -10,7 +10,7 @@ import {
DialogTitle,
} from "@components/UI/Dialog.tsx";
import { AlertTriangleIcon } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore.ts";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
export interface DeleteMessagesDialogProps {
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 { 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 {
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 { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts";
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 { expect, test, vi, beforeEach, afterEach } from 'vitest';
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 { useRefreshKeysDialog } from "./useRefreshKeysDialog.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 {
open: boolean;

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

@ -1,6 +1,6 @@
import { useCallback } from "react";
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() {
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">
<Label htmlFor={fieldName}>{label}</Label>
<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">
{validationText}
</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 { BleConnection, ServiceUuid } from "@meshtastic/js";
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) => {
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 { useForm, useController } from "react-hook-form";
import { AlertTriangle } from "lucide-react";
import { useMessageStore } from "@core/stores/messageStore.ts";
import { useMessageStore } from "../../../core/stores/messageStore/index.ts";
interface FormData {
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 { TransportWebSerial } from "@meshtastic/transport-web-serial";
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) => {
const [serialPorts, setSerialPorts] = useState<SerialPort[]>([]);

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

@ -23,7 +23,7 @@ import {
TooltipTrigger,
} from "@radix-ui/react-tooltip";
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";
export interface NodeDetailProps {

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

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

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

@ -1,57 +1,31 @@
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import type { Types } from "@meshtastic/core";
import { SendIcon } from "lucide-react";
import {
startTransition, useCallback, // @ts-types="react"
useDeferredValue, useState
} from "react";
import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts";
import { startTransition, useState } from "react";
import { useMessageStore } from "@core/stores/messageStore/index.ts";
export interface MessageInputProps {
onSend: (message: string) => void;
to: Types.Destination;
channel: Types.ChannelNumber;
maxBytes: number;
}
export const MessageInput = ({
onSend,
to,
channel,
maxBytes,
}: MessageInputProps) => {
const { connection } = useDevice();
const { setMessageState, activeChat, setDraft, getDraft, clearDraft } = useMessageStore();
const { setDraft, getDraft, clearDraft } = useMessageStore();
const [localDraft, setLocalDraft] = useState(getDraft(to));
const [messageBytes, setMessageBytes] = useState(0);
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 newValue = e.target.value;
const byteLength = deferredBytes
const byteLength = calculateBytes(newValue);
if (byteLength <= maxBytes) {
setLocalDraft(newValue);
@ -63,12 +37,12 @@ export const MessageInput = ({
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!localDraft.trim()) return;
setMessageBytes(0);
startTransition(() => {
sendText(localDraft.trim());
onSend(localDraft.trim());
setLocalDraft("");
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 type { LucideIcon } from "lucide-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 { MessageActionsMenu } from "@components/PageComponents/Messages/MessageActionsMenu.tsx";
@ -25,12 +25,13 @@ interface MessageStatus {
displayText: string;
icon: LucideIcon;
ariaLabel: string;
iconClassName?: string;
}
const MESSAGE_STATUS: Record<MessageState, MessageStatus> = {
[MessageState.Ack]: { state: MessageState.Ack, displayText: "Message delivered", icon: CheckCircle2, ariaLabel: "Message delivered" },
[MessageState.Waiting]: { state: MessageState.Waiting, displayText: "Waiting for delivery", icon: CircleEllipsis, ariaLabel: "Sending message" },
[MessageState.Failed]: { state: MessageState.Failed, displayText: "Delivery failed", icon: AlertCircle, ariaLabel: "Message delivery failed" },
[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", iconClassName: "text-slate-400 dark:text-slate-400" },
[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 =>
@ -40,7 +41,7 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children:
<TooltipProvider delayDuration={300}>
<Tooltip>
<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}
<TooltipArrow className="fill-slate-800" />
</TooltipContent>
@ -48,9 +49,9 @@ const StatusTooltip = ({ status, children }: { status: MessageStatus; children:
</TooltipProvider>
);
const StatusIcon = ({ status, className }: { status: MessageStatus; className?: string }) => {
const StatusIcon = ({ status }: { status: MessageStatus }) => {
const Icon = status.icon;
const iconClass = cn("size-4 shrink-0", className);
const iconClass = cn("size-4 shrink-0", status.iconClassName);
return (
<StatusTooltip status={status}>
<span aria-label={status.ariaLabel} role="img">
@ -107,14 +108,11 @@ export const MessageItem = ({ message }: MessageProps) => {
"transition-colors duration-100 ease-in-out",
);
// const avatarSizeClass = "size-11";
const gridGapClass = "gap-x-2";
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 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 (
<li className={messageItemWrapperClass}>
@ -128,10 +126,7 @@ export const MessageItem = ({ message }: MessageProps) => {
{displayName}
</span>
<TimeDisplay date={messageDate} className={dateTextStyle} />
<StatusIcon
status={messageStatus}
className={cn(isFailed ? statusIconFailedColor : statusIconBaseColor)}
/>
<StatusIcon status={messageStatus} />
</div>
) : null}

25
src/components/PageLayout.tsx

@ -48,7 +48,7 @@ export const PageLayout = ({
{leftBar && (
<aside
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
)}
>
@ -56,18 +56,17 @@ export const PageLayout = ({
</aside>
)}
{/* Center column (Header + Main) */}
<div className="flex flex-1 flex-col min-w-0">
{/* Header */}
<header
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
)}
>
{/* Header Content */}
<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}
</span>
<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-busy={action.isLoading}
>
{action.isLoading ? (
<Spinner size="sm" />
) : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
)}
<div className="mr-6">
{action.isLoading ? (
<Spinner size="md" />
) : (
<action.icon
className={cn("h-5 w-5", action.iconClasses)}
/>
)}
</div>
</button>
))}
</div>
@ -112,7 +113,7 @@ export const PageLayout = ({
{rightBar && (
<aside
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
)}
>

83
src/components/Sidebar.tsx

@ -1,6 +1,5 @@
import React from "react";
import { SidebarSection } from "@components/UI/Sidebar/SidebarSection.tsx";
import { SidebarButton } from "./UI/Sidebar/sidebarButton.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import { useDevice } 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 { useAppStore } from "@core/stores/appStore.ts";
import BatteryStatus from "@components/BatteryStatus.tsx";
import { SidebarButton } from "@components/UI/Sidebar/SidebarButton.tsx";
export interface SidebarProps {
children?: React.ReactNode;
@ -95,7 +95,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
<div
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',
isCollapsed && 'justify-center px-0'
@ -145,7 +145,7 @@ export const Sidebar = ({ children }: SidebarProps) => {
{children}
</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 ? (
<div className="flex flex-col items-center justify-center py-6">
<Spinner />
@ -162,54 +162,49 @@ export const Sidebar = ({ children }: SidebarProps) => {
<>
<div
className={cn(
'flex flex-col items-start justify-between',
isCollapsed && 'items-center justify-center'
'flex place-items-center gap-2',
isCollapsed && 'justify-center'
)}
>
<div
className={cn(
'flex place-items-center gap-2',
isCollapsed && 'w-full justify-center'
<Avatar
text={myNode.user?.shortName ?? myNode.num.toString()}
className={cn("flex-shrink-0 ml-2",
isCollapsed && "ml-0",
)}
>
<Avatar
text={myNode.user?.shortName ?? myNode.num.toString()}
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
size="sm"
/>
<p
className={cn(
'flex flex-col gap-0.5 ml-2 mt-2',
'transition-all duration-300 ease-in-out',
'max-w-[20ch] text-wrap text-sm font-medium',
'transition-all duration-300 ease-in-out overflow-hidden',
isCollapsed
? 'opacity-0 max-w-0 h-0 invisible'
: 'opacity-100 max-w-xs h-auto visible'
? 'opacity-0 max-w-0 invisible'
: 'opacity-100 max-w-full 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>
{myNode.user?.longName}
</p>
</div>
<div
className={cn(
'flex flex-col gap-0.5 ml-2 mt-2',
'transition-all duration-300 ease-in-out',
isCollapsed
? 'opacity-0 max-w-0 h-0 invisible'
: '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

9
src/components/UI/Avatar.tsx

@ -41,8 +41,11 @@ class ColorUtils {
}
const getColorFromText = (text: string): RGBColor => {
if (!text) {
return { r: 0, g: 0, b: 0, a: 255 };
}
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 |= 0;
}
@ -66,11 +69,11 @@ export const Avatar = ({
lg: "size-16 text-lg",
};
const safeText = text?.toString().toUpperCase() ?? "UNK";
const safeText = text?.toString().toUpperCase();
const bgColor = getColorFromText(safeText);
const isLight = ColorUtils.isLight(bgColor);
const textColor = isLight ? "#000000" : "#FFFFFF";
const initials = safeText.slice(0, 4) || "UNK";
const initials = safeText?.slice(0, 4) ?? "UNK";
return (
<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 (
<div className={cn(
"py-2",
isCollapsed ? 'px-0' : 'px-3',
isCollapsed ? 'px-0' : 'px-4',
className,
)}>

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

@ -52,7 +52,7 @@ export const SidebarButton = ({
<span
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',
'px-1',
'transition-all duration-300 ease-in-out',

2
src/components/UI/Toast.tsx

@ -28,7 +28,7 @@ const toastVariants = cva(
variants: {
variant: {
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:
"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.
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) => {
console.log(item);
return (index === 0 ?
<th
key={item.key ?? index}

2
src/core/dto/PacketToMessageDTO.ts

@ -1,5 +1,5 @@
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 {
channel: Types.ChannelNumber;

2
src/core/stores/deviceStore.ts

@ -288,6 +288,8 @@ export const useDeviceStore = createStore<DeviceState>((set, get) => ({
);
},
addNodeInfo: (nodeInfo) => {
console.log("Node Info", nodeInfo);
set(
produce<DeviceState>((draft) => {
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,
MessageState,
type Message,
} from './messageStore.ts';
} from './index.ts';
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 { 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 = {
getItem: async (name: string): Promise<string | null> => {
@ -11,4 +20,62 @@ export const zustandIndexDBStorage: StateStorage = {
removeItem: async (name: string): Promise<void> => {
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 { 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 NodeInfoFactory from "@core/dto/NodeNumToNodeInfoDTO.ts";
@ -57,16 +57,22 @@ export const subscribeAll = (
});
connection.events.onUserPacket.subscribe((user) => {
console.log("User Packet", user);
device.addUser(user);
});
connection.events.onPositionPacket.subscribe((position) => {
console.log("Position Packet", position);
device.addPosition(position);
});
connection.events.onNodeInfoPacket.subscribe((nodeInfo) => {
const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
device.addNodeInfo(nodeWithUser);
// const nodeWithUser = NodeInfoFactory.ensureDefaultUser(nodeInfo);
// console.log("Node Info", nodeWithUser);
device.addNodeInfo(nodeInfo);
});
connection.events.onChannelPacket.subscribe((channel) => {
@ -82,6 +88,8 @@ export const subscribeAll = (
connection.events.onMessagePacket.subscribe((messagePacket) => {
// incoming and outgoing messages are handled by this event listener
console.log("Message Packet", messagePacket);
const dto = new PacketToMessageDTO(messagePacket, myNodeNum);
const message = dto.toMessage();
messageStore.saveMessage(message);
@ -119,16 +127,17 @@ export const subscribeAll = (
connection.events.onRoutingPacket.subscribe((routingPacket) => {
if (routingPacket.data.variant.case === "errorReason") {
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:
console.error(`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);
break;
case Protobuf.Mesh.Routing_Error.PKI_UNKNOWN_PUBKEY:
console.error(`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);
break;
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 { Sidebar } from "@components/Sidebar.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 { DeviceConfig } from "@pages/Config/DeviceConfig.tsx";
import { ModuleConfig } from "@pages/Config/ModuleConfig.tsx";
@ -72,7 +72,7 @@ const ConfigPage = () => {
const leftSidebar = useMemo(
() => (
<Sidebar>
<SidebarSection label="Config Sections">
<SidebarSection label="Modules">
<SidebarButton
label="Radio Config"
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 { Avatar } from "@components/UI/Avatar.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 { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf, Types } from "@meshtastic/core";
import { getChannelName } from "@pages/Channels.tsx";
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 { 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 { Input } from "@components/UI/Input.tsx";
type NodeInfoWithUnread = Protobuf.Mesh.NodeInfo & { unreadCount: number };
export const MessagesPage = () => {
const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread } = useDevice();
const { getNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType } = useMessageStore()
const { channels, getNodes, getNode, hasNodeError, unreadCounts, resetUnread, connection } = useDevice();
const { getMyNodeNum, getMessages, setActiveChat, chatType, activeChat, setChatType, setMessageState, } = useMessageStore()
const { toast } = useToast();
const { isCollapsed } = useSidebar()
const [searchTerm, setSearchTerm] = useState<string>("");
const deferredSearch = useDeferredValue(searchTerm);
const filteredNodes = (): NodeInfoWithUnread[] => {
const lowerCaseSearchTerm = deferredSearch.toLowerCase();
@ -42,35 +41,70 @@ export const MessagesPage = () => {
.sort((a, b) => b.unreadCount - a.unreadCount);
}
console.log('filtered nodes', filteredNodes());
const allChannels = Array.from(channels.values());
const filteredChannels = allChannels.filter(
(ch) => ch.role !== Protobuf.Channel.Channel_Role.DISABLED,
);
const currentChannel = channels.get(activeChat);
const otherNode = getNode(activeChat);
const isDirect = chatType === MessageType.Direct;
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 = () => {
switch (chatType) {
case MessageType.Broadcast:
return (
<ChannelChat
messages={getMessages(MessageType.Broadcast, {
myNodeNum: getNodeNum(),
channel: currentChannel?.index
messages={getMessages({
type: MessageType.Broadcast,
channelId: activeChat ?? 0,
})}
/>
);
case MessageType.Direct:
return (
<ChannelChat
messages={getMessages(MessageType.Direct, { myNodeNum: getNodeNum(), otherNodeNum: activeChat })}
messages={getMessages({ type: MessageType.Direct, nodeA: getMyNodeNum(), nodeB: activeChat })}
/>
);
default:
@ -106,7 +140,7 @@ export const MessagesPage = () => {
const rightSidebar = useMemo(() => (
<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
type="text"
placeholder="Search nodes..."
@ -177,7 +211,7 @@ export const MessagesPage = () => {
{(isBroadcast || isDirect) ? (
<MessageInput
to={isDirect ? activeChat : MessageType.Broadcast}
channel={isDirect ? Types.ChannelNumber.Primary : currentChat.id}
onSend={sendText}
maxBytes={200}
/>
) : (

Loading…
Cancel
Save