extract frontend realtime state hook

This commit is contained in:
Jack Kingsman
2026-03-09 18:27:01 -07:00
parent 946006bd7f
commit 5d509a88d9
8 changed files with 552 additions and 218 deletions
+9 -1
View File
@@ -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
View File
@@ -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);
+1
View File
@@ -5,3 +5,4 @@ export { useRepeaterDashboard } from './useRepeaterDashboard';
export { useAppSettings } from './useAppSettings';
export { useConversationRouter } from './useConversationRouter';
export { useContactsAndChannels } from './useContactsAndChannels';
export { useRealtimeAppState } from './useRealtimeAppState';
+264
View File
@@ -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));
});
});
+1 -1
View File
@@ -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;