mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-07 22:05:14 +02:00
297 lines
9.2 KiB
TypeScript
297 lines
9.2 KiB
TypeScript
import {
|
|
useCallback,
|
|
useMemo,
|
|
type Dispatch,
|
|
type MutableRefObject,
|
|
type SetStateAction,
|
|
} from 'react';
|
|
import { api } from '../api';
|
|
import type { UseWebSocketOptions } from '../useWebSocket';
|
|
import { toast } from '../components/ui/sonner';
|
|
import { getStateKey } from '../utils/conversationState';
|
|
import { mergeContactIntoList } from '../utils/contactMerge';
|
|
import { getContactDisplayName } from '../utils/pubkey';
|
|
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
|
|
import type {
|
|
Channel,
|
|
Contact,
|
|
Conversation,
|
|
HealthStatus,
|
|
Message,
|
|
MessagePath,
|
|
RawPacket,
|
|
} from '../types';
|
|
|
|
interface UseRealtimeAppStateArgs {
|
|
prevHealthRef: MutableRefObject<HealthStatus | null>;
|
|
setHealth: Dispatch<SetStateAction<HealthStatus | null>>;
|
|
fetchConfig: () => void | Promise<void>;
|
|
setRawPackets: Dispatch<SetStateAction<RawPacket[]>>;
|
|
reconcileOnReconnect: () => void;
|
|
refreshUnreads: () => Promise<void>;
|
|
setChannels: Dispatch<SetStateAction<Channel[]>>;
|
|
fetchAllContacts: () => Promise<Contact[]>;
|
|
setContacts: Dispatch<SetStateAction<Contact[]>>;
|
|
blockedKeysRef: MutableRefObject<string[]>;
|
|
blockedNamesRef: MutableRefObject<string[]>;
|
|
activeConversationRef: MutableRefObject<Conversation | null>;
|
|
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
|
recordMessageEvent: (args: {
|
|
msg: Message;
|
|
activeConversation: boolean;
|
|
isNewMessage: boolean;
|
|
hasMention?: boolean;
|
|
}) => void;
|
|
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
|
removeConversationState: (stateKey: string) => void;
|
|
checkMention: (text: string) => boolean;
|
|
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
|
setActiveConversation: (conv: Conversation | null) => void;
|
|
renameConversationMessages: (oldId: string, newId: string) => void;
|
|
removeConversationMessages: (conversationId: string) => void;
|
|
receiveMessageAck: (
|
|
messageId: number,
|
|
ackCount: number,
|
|
paths?: MessagePath[],
|
|
packetId?: number | null
|
|
) => void;
|
|
notifyIncomingMessage?: (msg: Message) => void;
|
|
recordRawPacketObservation?: (packet: RawPacket) => void;
|
|
maxRawPackets?: number;
|
|
}
|
|
|
|
function isMessageBlocked(msg: Message, blockedKeys: string[], blockedNames: string[]): boolean {
|
|
if (msg.outgoing) {
|
|
return false;
|
|
}
|
|
|
|
if (blockedKeys.length > 0) {
|
|
if (msg.type === 'PRIV' && blockedKeys.includes(msg.conversation_key.toLowerCase())) {
|
|
return true;
|
|
}
|
|
if (
|
|
msg.type === 'CHAN' &&
|
|
msg.sender_key &&
|
|
blockedKeys.includes(msg.sender_key.toLowerCase())
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return blockedNames.length > 0 && !!msg.sender_name && blockedNames.includes(msg.sender_name);
|
|
}
|
|
|
|
export function useRealtimeAppState({
|
|
prevHealthRef,
|
|
setHealth,
|
|
fetchConfig,
|
|
setRawPackets,
|
|
reconcileOnReconnect,
|
|
refreshUnreads,
|
|
setChannels,
|
|
fetchAllContacts,
|
|
setContacts,
|
|
blockedKeysRef,
|
|
blockedNamesRef,
|
|
activeConversationRef,
|
|
observeMessage,
|
|
recordMessageEvent,
|
|
renameConversationState,
|
|
removeConversationState,
|
|
checkMention,
|
|
pendingDeleteFallbackRef,
|
|
setActiveConversation,
|
|
renameConversationMessages,
|
|
removeConversationMessages,
|
|
receiveMessageAck,
|
|
notifyIncomingMessage,
|
|
recordRawPacketObservation,
|
|
maxRawPackets = 500,
|
|
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
|
|
const mergeChannelIntoList = useCallback(
|
|
(updated: Channel) => {
|
|
setChannels((prev) => {
|
|
const existingIndex = prev.findIndex((channel) => channel.key === updated.key);
|
|
if (existingIndex === -1) {
|
|
return [...prev, updated].sort((a, b) => a.name.localeCompare(b.name));
|
|
}
|
|
const next = [...prev];
|
|
next[existingIndex] = updated;
|
|
return next;
|
|
});
|
|
},
|
|
[setChannels]
|
|
);
|
|
|
|
return useMemo(
|
|
() => ({
|
|
onHealth: (data: HealthStatus) => {
|
|
const prev = prevHealthRef.current;
|
|
prevHealthRef.current = data;
|
|
setHealth(data);
|
|
const nextRadioState =
|
|
data.radio_state ??
|
|
(data.radio_initializing
|
|
? 'initializing'
|
|
: data.radio_connected
|
|
? 'connected'
|
|
: 'disconnected');
|
|
const initializationCompleted =
|
|
prev !== null &&
|
|
prev.radio_connected &&
|
|
prev.radio_initializing &&
|
|
data.radio_connected &&
|
|
!data.radio_initializing;
|
|
|
|
if (prev !== null && prev.radio_connected !== data.radio_connected) {
|
|
if (data.radio_connected) {
|
|
toast.success('Radio connected', {
|
|
description: data.connection_info
|
|
? `Connected via ${data.connection_info}`
|
|
: undefined,
|
|
});
|
|
fetchConfig();
|
|
} else {
|
|
if (nextRadioState === 'paused') {
|
|
toast.success('Radio connection paused');
|
|
} else {
|
|
toast.error('Radio disconnected', {
|
|
description: 'Check radio connection and power',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
if (initializationCompleted) {
|
|
fetchConfig();
|
|
}
|
|
},
|
|
onError: (error: { message: string; details?: string }) => {
|
|
toast.error(error.message, {
|
|
description: error.details,
|
|
});
|
|
},
|
|
onSuccess: (success: { message: string; details?: string }) => {
|
|
toast.success(success.message, {
|
|
description: success.details,
|
|
});
|
|
},
|
|
onReconnect: () => {
|
|
setRawPackets([]);
|
|
reconcileOnReconnect();
|
|
refreshUnreads();
|
|
api.getChannels().then(setChannels).catch(console.error);
|
|
fetchAllContacts()
|
|
.then((data) => setContacts(data))
|
|
.catch(console.error);
|
|
},
|
|
onMessage: (msg: Message) => {
|
|
if (isMessageBlocked(msg, blockedKeysRef.current, blockedNamesRef.current)) {
|
|
return;
|
|
}
|
|
|
|
const { added: isNewMessage, activeConversation: isForActiveConversation } =
|
|
observeMessage(msg);
|
|
recordMessageEvent({
|
|
msg,
|
|
activeConversation: isForActiveConversation,
|
|
isNewMessage,
|
|
hasMention: checkMention(msg.text),
|
|
});
|
|
|
|
if (!msg.outgoing && isNewMessage) {
|
|
notifyIncomingMessage?.(msg);
|
|
}
|
|
},
|
|
onContact: (contact: Contact) => {
|
|
setContacts((prev) => mergeContactIntoList(prev, contact));
|
|
},
|
|
onContactResolved: (previousPublicKey: string, contact: Contact) => {
|
|
setContacts((prev) =>
|
|
mergeContactIntoList(
|
|
prev.filter((candidate) => candidate.public_key !== previousPublicKey),
|
|
contact
|
|
)
|
|
);
|
|
renameConversationMessages(previousPublicKey, contact.public_key);
|
|
renameConversationState(
|
|
getStateKey('contact', previousPublicKey),
|
|
getStateKey('contact', contact.public_key)
|
|
);
|
|
|
|
const active = activeConversationRef.current;
|
|
if (active?.type === 'contact' && active.id === previousPublicKey) {
|
|
setActiveConversation({
|
|
type: 'contact',
|
|
id: contact.public_key,
|
|
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
|
});
|
|
}
|
|
},
|
|
onChannel: (channel: Channel) => {
|
|
mergeChannelIntoList(channel);
|
|
},
|
|
onContactDeleted: (publicKey: string) => {
|
|
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
|
removeConversationMessages(publicKey);
|
|
removeConversationState(getStateKey('contact', publicKey));
|
|
const active = activeConversationRef.current;
|
|
if (active?.type === 'contact' && active.id === publicKey) {
|
|
pendingDeleteFallbackRef.current = true;
|
|
setActiveConversation(null);
|
|
}
|
|
},
|
|
onChannelDeleted: (key: string) => {
|
|
setChannels((prev) => prev.filter((c) => c.key !== key));
|
|
removeConversationMessages(key);
|
|
removeConversationState(getStateKey('channel', key));
|
|
const active = activeConversationRef.current;
|
|
if (active?.type === 'channel' && active.id === key) {
|
|
pendingDeleteFallbackRef.current = true;
|
|
setActiveConversation(null);
|
|
}
|
|
},
|
|
onRawPacket: (packet: RawPacket) => {
|
|
recordRawPacketObservation?.(packet);
|
|
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
|
},
|
|
onMessageAcked: (
|
|
messageId: number,
|
|
ackCount: number,
|
|
paths?: MessagePath[],
|
|
packetId?: number | null
|
|
) => {
|
|
receiveMessageAck(messageId, ackCount, paths, packetId);
|
|
},
|
|
}),
|
|
[
|
|
activeConversationRef,
|
|
blockedKeysRef,
|
|
blockedNamesRef,
|
|
checkMention,
|
|
fetchAllContacts,
|
|
fetchConfig,
|
|
removeConversationState,
|
|
renameConversationState,
|
|
renameConversationMessages,
|
|
maxRawPackets,
|
|
mergeChannelIntoList,
|
|
pendingDeleteFallbackRef,
|
|
prevHealthRef,
|
|
recordMessageEvent,
|
|
recordRawPacketObservation,
|
|
receiveMessageAck,
|
|
observeMessage,
|
|
refreshUnreads,
|
|
reconcileOnReconnect,
|
|
removeConversationMessages,
|
|
setActiveConversation,
|
|
setChannels,
|
|
setContacts,
|
|
setHealth,
|
|
setRawPackets,
|
|
notifyIncomingMessage,
|
|
]
|
|
);
|
|
}
|