33 changed files with 601 additions and 397 deletions
@ -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> |
|||
); |
|||
}; |
|||
@ -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,
|
|||
// }),
|
|||
// }
|
|||
); |
|||
@ -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, |
|||
}), |
|||
}) |
|||
) |
|||
@ -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, |
|||
} |
|||
Loading…
Reference in new issue