mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 01:42:11 +02:00
extract frontend realtime state hook
This commit is contained in:
+9
-1
@@ -25,6 +25,7 @@ frontend/src/
|
||||
├── api.ts # Typed REST client
|
||||
├── types.ts # Shared TS contracts
|
||||
├── useWebSocket.ts # WS lifecycle + event dispatch
|
||||
├── wsEvents.ts # Typed WS event parsing / discriminated union
|
||||
├── messageCache.ts # Conversation-scoped cache
|
||||
├── prefetch.ts # Consumes prefetched API promises started in index.html
|
||||
├── index.css # Global styles/utilities
|
||||
@@ -36,6 +37,7 @@ frontend/src/
|
||||
│ ├── index.ts # Central re-export of all hooks
|
||||
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
|
||||
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
|
||||
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
|
||||
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
@@ -138,8 +140,11 @@ frontend/src/
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useContactsAndChannels.test.ts
|
||||
├── useRealtimeAppState.test.ts
|
||||
├── useUnreadCounts.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
└── useWebSocket.lifecycle.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
└── wsEvents.test.ts
|
||||
|
||||
```
|
||||
|
||||
@@ -154,12 +159,14 @@ frontend/src/
|
||||
- `useConversationRouter`: URL hash → active conversation routing
|
||||
- `useConversationMessages`: fetch, pagination, dedup/update helpers
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
||||
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
||||
|
||||
### Initial load + realtime
|
||||
|
||||
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
|
||||
- WebSocket: realtime deltas/events.
|
||||
- On reconnect, `App.tsx` refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift.
|
||||
- On WS connect, backend sends `health` only; contacts/channels still come from REST.
|
||||
|
||||
### New Message modal
|
||||
@@ -193,6 +200,7 @@ frontend/src/
|
||||
|
||||
- Auto reconnect (3s) with cleanup guard on unmount.
|
||||
- Heartbeat ping every 30s.
|
||||
- Incoming JSON is parsed through `wsEvents.ts`, which returns a typed discriminated union for known events and a centralized `unknown` fallback.
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
|
||||
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
|
||||
|
||||
|
||||
+25
-194
@@ -14,11 +14,11 @@ import { useWebSocket } from './useWebSocket';
|
||||
import {
|
||||
useUnreadCounts,
|
||||
useConversationMessages,
|
||||
getMessageContentKey,
|
||||
useRadioControl,
|
||||
useAppSettings,
|
||||
useConversationRouter,
|
||||
useContactsAndChannels,
|
||||
useRealtimeAppState,
|
||||
} from './hooks';
|
||||
import * as messageCache from './messageCache';
|
||||
import { StatusBar } from './components/StatusBar';
|
||||
@@ -62,24 +62,11 @@ import {
|
||||
SheetTitle,
|
||||
} from './components/ui/sheet';
|
||||
import { Toaster, toast } from './components/ui/sonner';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import { appendRawPacketUnique } from './utils/rawPacketIdentity';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { mergeContactIntoList } from './utils/contactMerge';
|
||||
import { getLocalLabel, getContrastTextColor } from './utils/localLabel';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SearchNavigateTarget } from './components/SearchView';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
HealthStatus,
|
||||
Message,
|
||||
MessagePath,
|
||||
RawPacket,
|
||||
} from './types';
|
||||
|
||||
const MAX_RAW_PACKETS = 500;
|
||||
import type { Channel, Conversation, Message, RawPacket } from './types';
|
||||
|
||||
export function App() {
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
@@ -233,6 +220,29 @@ export function App() {
|
||||
return contact?.type === CONTACT_TYPE_REPEATER;
|
||||
}, [activeConversation, contacts]);
|
||||
|
||||
const wsHandlers = useRealtimeAppState({
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
fetchConfig,
|
||||
setRawPackets,
|
||||
triggerReconcile,
|
||||
refreshUnreads,
|
||||
setChannels,
|
||||
fetchAllContacts,
|
||||
setContacts,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
activeConversationRef,
|
||||
hasNewerMessagesRef,
|
||||
addMessageIfNew,
|
||||
trackNewMessage,
|
||||
incrementUnread,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
updateMessageAck,
|
||||
});
|
||||
|
||||
const mergeChannelIntoList = useCallback(
|
||||
(updated: Channel) => {
|
||||
setChannels((prev) => {
|
||||
@@ -248,185 +258,6 @@ export function App() {
|
||||
[setChannels]
|
||||
);
|
||||
|
||||
// WebSocket handlers - memoized to prevent reconnection loops
|
||||
const wsHandlers = useMemo(
|
||||
() => ({
|
||||
onHealth: (data: HealthStatus) => {
|
||||
const prev = prevHealthRef.current;
|
||||
prevHealthRef.current = data;
|
||||
setHealth(data);
|
||||
const initializationCompleted =
|
||||
prev !== null &&
|
||||
prev.radio_connected &&
|
||||
prev.radio_initializing &&
|
||||
data.radio_connected &&
|
||||
!data.radio_initializing;
|
||||
|
||||
// Show toast on connection status change
|
||||
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,
|
||||
});
|
||||
// Refresh config after reconnection (may have changed after reboot)
|
||||
fetchConfig();
|
||||
} 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: () => {
|
||||
// Clear raw packets: observation_id is a process-local counter that resets
|
||||
// on backend restart, so stale packets would cause new ones to be deduped away.
|
||||
setRawPackets([]);
|
||||
// Silently recover any data missed during the disconnect window
|
||||
triggerReconcile();
|
||||
refreshUnreads();
|
||||
api.getChannels().then(setChannels).catch(console.error);
|
||||
fetchAllContacts()
|
||||
.then((data) => setContacts(data))
|
||||
.catch(console.error);
|
||||
},
|
||||
onMessage: (msg: Message) => {
|
||||
// Filter blocked contacts on incoming (non-outgoing) messages
|
||||
if (!msg.outgoing) {
|
||||
const bKeys = blockedKeysRef.current;
|
||||
const bNames = blockedNamesRef.current;
|
||||
// Block DMs by sender key
|
||||
if (
|
||||
bKeys.length > 0 &&
|
||||
msg.type === 'PRIV' &&
|
||||
bKeys.includes(msg.conversation_key.toLowerCase())
|
||||
)
|
||||
return;
|
||||
// Block channel messages by sender key
|
||||
if (
|
||||
bKeys.length > 0 &&
|
||||
msg.type === 'CHAN' &&
|
||||
msg.sender_key &&
|
||||
bKeys.includes(msg.sender_key.toLowerCase())
|
||||
)
|
||||
return;
|
||||
// Block by sender name (works for both DMs and channel messages)
|
||||
if (bNames.length > 0 && msg.sender_name && bNames.includes(msg.sender_name)) return;
|
||||
}
|
||||
|
||||
const activeConv = activeConversationRef.current;
|
||||
|
||||
// Check if message belongs to the active conversation
|
||||
const isForActiveConversation = (() => {
|
||||
if (!activeConv) return false;
|
||||
if (msg.type === 'CHAN' && activeConv.type === 'channel') {
|
||||
return msg.conversation_key === activeConv.id;
|
||||
}
|
||||
if (msg.type === 'PRIV' && activeConv.type === 'contact') {
|
||||
return msg.conversation_key === activeConv.id;
|
||||
}
|
||||
return false;
|
||||
})();
|
||||
|
||||
// Only add to message list if it's for the active conversation
|
||||
// and we're not viewing historical messages (hasNewerMessages means we jumped mid-history)
|
||||
if (isForActiveConversation && !hasNewerMessagesRef.current) {
|
||||
addMessageIfNew(msg);
|
||||
}
|
||||
|
||||
// Track for unread counts and sorting
|
||||
trackNewMessage(msg);
|
||||
|
||||
const contentKey = getMessageContentKey(msg);
|
||||
|
||||
// For non-active conversations: update cache and count unreads
|
||||
if (!isForActiveConversation) {
|
||||
// Update message cache (instant restore on switch) — returns true if new
|
||||
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
|
||||
|
||||
// Count unread for incoming messages (skip duplicates from multiple mesh paths)
|
||||
if (!msg.outgoing && isNew) {
|
||||
let stateKey: string | null = null;
|
||||
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||
stateKey = getStateKey('channel', msg.conversation_key);
|
||||
} else if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
stateKey = getStateKey('contact', msg.conversation_key);
|
||||
}
|
||||
if (stateKey) {
|
||||
const hasMention = checkMention(msg.text);
|
||||
incrementUnread(stateKey, hasMention);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onContact: (contact: Contact) => {
|
||||
setContacts((prev) => mergeContactIntoList(prev, contact));
|
||||
},
|
||||
onChannel: (channel: Channel) => {
|
||||
mergeChannelIntoList(channel);
|
||||
},
|
||||
onContactDeleted: (publicKey: string) => {
|
||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||
messageCache.remove(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));
|
||||
messageCache.remove(key);
|
||||
const active = activeConversationRef.current;
|
||||
if (active?.type === 'channel' && active.id === key) {
|
||||
pendingDeleteFallbackRef.current = true;
|
||||
setActiveConversation(null);
|
||||
}
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS));
|
||||
},
|
||||
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
updateMessageAck(messageId, ackCount, paths);
|
||||
messageCache.updateAck(messageId, ackCount, paths);
|
||||
},
|
||||
}),
|
||||
[
|
||||
addMessageIfNew,
|
||||
trackNewMessage,
|
||||
incrementUnread,
|
||||
updateMessageAck,
|
||||
checkMention,
|
||||
fetchConfig,
|
||||
mergeChannelIntoList,
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
activeConversationRef,
|
||||
hasNewerMessagesRef,
|
||||
setActiveConversation,
|
||||
setContacts,
|
||||
setChannels,
|
||||
triggerReconcile,
|
||||
refreshUnreads,
|
||||
fetchAllContacts,
|
||||
]
|
||||
);
|
||||
|
||||
// Connect to WebSocket
|
||||
useWebSocket(wsHandlers);
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ export { useRepeaterDashboard } from './useRepeaterDashboard';
|
||||
export { useAppSettings } from './useAppSettings';
|
||||
export { useConversationRouter } from './useConversationRouter';
|
||||
export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { api } from '../api';
|
||||
import * as messageCache from '../messageCache';
|
||||
import type { UseWebSocketOptions } from '../useWebSocket';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
import { mergeContactIntoList } from '../utils/contactMerge';
|
||||
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
|
||||
import { getMessageContentKey } from './useConversationMessages';
|
||||
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[]>>;
|
||||
triggerReconcile: () => 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>;
|
||||
hasNewerMessagesRef: MutableRefObject<boolean>;
|
||||
addMessageIfNew: (msg: Message) => boolean;
|
||||
trackNewMessage: (msg: Message) => void;
|
||||
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
|
||||
checkMention: (text: string) => boolean;
|
||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => 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);
|
||||
}
|
||||
|
||||
function isActiveConversationMessage(
|
||||
activeConversation: Conversation | null,
|
||||
msg: Message
|
||||
): boolean {
|
||||
if (!activeConversation) return false;
|
||||
if (msg.type === 'CHAN' && activeConversation.type === 'channel') {
|
||||
return msg.conversation_key === activeConversation.id;
|
||||
}
|
||||
if (msg.type === 'PRIV' && activeConversation.type === 'contact') {
|
||||
return msg.conversation_key === activeConversation.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useRealtimeAppState({
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
fetchConfig,
|
||||
setRawPackets,
|
||||
triggerReconcile,
|
||||
refreshUnreads,
|
||||
setChannels,
|
||||
fetchAllContacts,
|
||||
setContacts,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
activeConversationRef,
|
||||
hasNewerMessagesRef,
|
||||
addMessageIfNew,
|
||||
trackNewMessage,
|
||||
incrementUnread,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
updateMessageAck,
|
||||
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 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 {
|
||||
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([]);
|
||||
triggerReconcile();
|
||||
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 isForActiveConversation = isActiveConversationMessage(
|
||||
activeConversationRef.current,
|
||||
msg
|
||||
);
|
||||
|
||||
if (isForActiveConversation && !hasNewerMessagesRef.current) {
|
||||
addMessageIfNew(msg);
|
||||
}
|
||||
|
||||
trackNewMessage(msg);
|
||||
|
||||
const contentKey = getMessageContentKey(msg);
|
||||
if (!isForActiveConversation) {
|
||||
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
|
||||
|
||||
if (!msg.outgoing && isNew) {
|
||||
let stateKey: string | null = null;
|
||||
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||
stateKey = getStateKey('channel', msg.conversation_key);
|
||||
} else if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
stateKey = getStateKey('contact', msg.conversation_key);
|
||||
}
|
||||
if (stateKey) {
|
||||
incrementUnread(stateKey, checkMention(msg.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onContact: (contact: Contact) => {
|
||||
setContacts((prev) => mergeContactIntoList(prev, contact));
|
||||
},
|
||||
onChannel: (channel: Channel) => {
|
||||
mergeChannelIntoList(channel);
|
||||
},
|
||||
onContactDeleted: (publicKey: string) => {
|
||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||
messageCache.remove(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));
|
||||
messageCache.remove(key);
|
||||
const active = activeConversationRef.current;
|
||||
if (active?.type === 'channel' && active.id === key) {
|
||||
pendingDeleteFallbackRef.current = true;
|
||||
setActiveConversation(null);
|
||||
}
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
||||
},
|
||||
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
updateMessageAck(messageId, ackCount, paths);
|
||||
messageCache.updateAck(messageId, ackCount, paths);
|
||||
},
|
||||
}),
|
||||
[
|
||||
activeConversationRef,
|
||||
addMessageIfNew,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
checkMention,
|
||||
fetchAllContacts,
|
||||
fetchConfig,
|
||||
hasNewerMessagesRef,
|
||||
incrementUnread,
|
||||
maxRawPackets,
|
||||
mergeChannelIntoList,
|
||||
pendingDeleteFallbackRef,
|
||||
prevHealthRef,
|
||||
refreshUnreads,
|
||||
setActiveConversation,
|
||||
setChannels,
|
||||
setContacts,
|
||||
setHealth,
|
||||
setRawPackets,
|
||||
trackNewMessage,
|
||||
triggerReconcile,
|
||||
updateMessageAck,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useRealtimeAppState } from '../hooks/useRealtimeAppState';
|
||||
import type { Channel, Contact, Conversation, HealthStatus, Message, RawPacket } from '../types';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
getChannels: vi.fn(),
|
||||
},
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
messageCache: {
|
||||
addMessage: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
updateAck: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: mocks.api,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: mocks.toast,
|
||||
}));
|
||||
|
||||
vi.mock('../messageCache', () => mocks.messageCache);
|
||||
|
||||
const publicChannel: Channel = {
|
||||
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
|
||||
name: 'Public',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
const incomingDm: Message = {
|
||||
id: 7,
|
||||
type: 'PRIV',
|
||||
conversation_key: 'aa'.repeat(32),
|
||||
text: 'hello',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
sender_key: 'aa'.repeat(32),
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: 'Alice',
|
||||
};
|
||||
|
||||
function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppState>[0]> = {}) {
|
||||
const setHealth = vi.fn();
|
||||
const setRawPackets = vi.fn();
|
||||
const setChannels = vi.fn();
|
||||
const setContacts = vi.fn();
|
||||
|
||||
return {
|
||||
args: {
|
||||
prevHealthRef: { current: null as HealthStatus | null },
|
||||
setHealth,
|
||||
fetchConfig: vi.fn(),
|
||||
setRawPackets,
|
||||
triggerReconcile: vi.fn(),
|
||||
refreshUnreads: vi.fn(async () => {}),
|
||||
setChannels,
|
||||
fetchAllContacts: vi.fn(async () => [] as Contact[]),
|
||||
setContacts,
|
||||
blockedKeysRef: { current: [] as string[] },
|
||||
blockedNamesRef: { current: [] as string[] },
|
||||
activeConversationRef: { current: null as Conversation | null },
|
||||
hasNewerMessagesRef: { current: false },
|
||||
addMessageIfNew: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
incrementUnread: vi.fn(),
|
||||
checkMention: vi.fn(() => false),
|
||||
pendingDeleteFallbackRef: { current: false },
|
||||
setActiveConversation: vi.fn(),
|
||||
updateMessageAck: vi.fn(),
|
||||
...overrides,
|
||||
},
|
||||
fns: {
|
||||
setHealth,
|
||||
setRawPackets,
|
||||
setChannels,
|
||||
setContacts,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRealtimeAppState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel]);
|
||||
});
|
||||
|
||||
it('reconnect clears raw packets and refetches channels/contacts/unreads', async () => {
|
||||
const contacts: Contact[] = [
|
||||
{
|
||||
public_key: 'bb'.repeat(32),
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
];
|
||||
|
||||
const { args, fns } = createRealtimeArgs({
|
||||
fetchAllContacts: vi.fn(async () => contacts),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
act(() => {
|
||||
result.current.onReconnect?.();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(args.triggerReconcile).toHaveBeenCalledTimes(1);
|
||||
expect(args.refreshUnreads).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
|
||||
expect(args.fetchAllContacts).toHaveBeenCalledTimes(1);
|
||||
expect(fns.setRawPackets).toHaveBeenCalledWith([]);
|
||||
expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]);
|
||||
expect(fns.setContacts).toHaveBeenCalledWith(contacts);
|
||||
});
|
||||
});
|
||||
|
||||
it('tracks unread state for a new non-active incoming message', () => {
|
||||
mocks.messageCache.addMessage.mockReturnValue(true);
|
||||
const { args } = createRealtimeArgs({
|
||||
checkMention: vi.fn(() => true),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
act(() => {
|
||||
result.current.onMessage?.(incomingDm);
|
||||
});
|
||||
|
||||
expect(args.addMessageIfNew).not.toHaveBeenCalled();
|
||||
expect(args.trackNewMessage).toHaveBeenCalledWith(incomingDm);
|
||||
expect(mocks.messageCache.addMessage).toHaveBeenCalledWith(
|
||||
incomingDm.conversation_key,
|
||||
incomingDm,
|
||||
expect.any(String)
|
||||
);
|
||||
expect(args.incrementUnread).toHaveBeenCalledWith(
|
||||
`contact-${incomingDm.conversation_key}`,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('deleting the active contact clears it and marks fallback recovery pending', () => {
|
||||
const pendingDeleteFallbackRef = { current: false };
|
||||
const activeConversationRef = {
|
||||
current: {
|
||||
type: 'contact',
|
||||
id: incomingDm.conversation_key,
|
||||
name: 'Alice',
|
||||
} satisfies Conversation,
|
||||
};
|
||||
const { args, fns } = createRealtimeArgs({
|
||||
activeConversationRef,
|
||||
pendingDeleteFallbackRef,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
act(() => {
|
||||
result.current.onContactDeleted?.(incomingDm.conversation_key);
|
||||
});
|
||||
|
||||
expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mocks.messageCache.remove).toHaveBeenCalledWith(incomingDm.conversation_key);
|
||||
expect(args.setActiveConversation).toHaveBeenCalledWith(null);
|
||||
expect(pendingDeleteFallbackRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('appends raw packets using observation identity dedup', () => {
|
||||
const { args, fns } = createRealtimeArgs();
|
||||
const packet: RawPacket = {
|
||||
id: 1,
|
||||
observation_id: 2,
|
||||
timestamp: 1700000000,
|
||||
data: 'aabb',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
snr: 7.5,
|
||||
rssi: -80,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
act(() => {
|
||||
result.current.onRawPacket?.(packet);
|
||||
});
|
||||
|
||||
expect(fns.setRawPackets).toHaveBeenCalledWith(expect.any(Function));
|
||||
});
|
||||
});
|
||||
@@ -12,7 +12,7 @@ interface SuccessEvent {
|
||||
details?: string;
|
||||
}
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
export interface UseWebSocketOptions {
|
||||
onHealth?: (health: HealthStatus) => void;
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
|
||||
Reference in New Issue
Block a user