mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-31 22:12:34 +02:00
Keep overheard packet traffic out of the chat render path
Typing in a conversation got progressively slower as its history grew.
The cause was not the input: every packet the node overhears was stored
in App state, so each one re-rendered the whole tree, including the
un-memoized MessageList. That cost scales with history length, and on a
busy mesh it saturated the main thread so keystrokes queued behind it.
Measured in Chromium with the real components, cost per overheard packet:
history before after
50 7.3ms ~0ms
500 48.5ms ~0ms
1000 97.7ms ~0ms
2000 228.4ms ~0ms
The packet stream now lives in a small external store that views subscribe
to individually, so only the map, visualizer, raw feed, and cracker re-render
when a packet arrives, so the chat view is no longer along for the ride.
This also retires useRawPacketStatsSession, whose session state was App
state for the same reason.
This commit is contained in:
+1
-15
@@ -16,7 +16,6 @@ import {
|
||||
useBrowserNotifications,
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
useRawPacketStatsSession,
|
||||
} from './hooks';
|
||||
import { toast } from './components/ui/sonner';
|
||||
import { AppShell } from './components/AppShell';
|
||||
@@ -27,13 +26,7 @@ import { RichPayloadProvider } from './contexts/RichPayloadContext';
|
||||
import { usePush } from './contexts/PushSubscriptionContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type {
|
||||
BulkCreateHashtagChannelsResult,
|
||||
Channel,
|
||||
Conversation,
|
||||
Message,
|
||||
RawPacket,
|
||||
} from './types';
|
||||
import type { BulkCreateHashtagChannelsResult, Channel, Conversation, Message } from './types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||
|
||||
@@ -92,7 +85,6 @@ export function App() {
|
||||
}, []);
|
||||
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
@@ -109,7 +101,6 @@ export function App() {
|
||||
notifyIncomingMessage,
|
||||
} = useBrowserNotifications();
|
||||
const pushSubscription = usePush();
|
||||
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
|
||||
const {
|
||||
showNewMessage,
|
||||
showSettings,
|
||||
@@ -436,7 +427,6 @@ export function App() {
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
fetchConfig,
|
||||
setRawPackets,
|
||||
reconcileOnReconnect,
|
||||
refreshUnreads,
|
||||
setChannels,
|
||||
@@ -457,7 +447,6 @@ export function App() {
|
||||
removeConversationMessages,
|
||||
receiveMessageAck,
|
||||
notifyIncomingMessage,
|
||||
recordRawPacketObservation,
|
||||
});
|
||||
const handleVisibilityPolicyChanged = useCallback(() => {
|
||||
clearConversationMessages();
|
||||
@@ -601,8 +590,6 @@ export function App() {
|
||||
activeConversation,
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
messages: sortedMessages,
|
||||
@@ -734,7 +721,6 @@ export function App() {
|
||||
onToggleTrackedTelemetryContact: handleToggleTrackedTelemetryContact,
|
||||
};
|
||||
const crackerProps = {
|
||||
packets: rawPackets,
|
||||
channels,
|
||||
onChannelCreate: handleCreateCrackedChannel,
|
||||
};
|
||||
|
||||
@@ -13,12 +13,10 @@ import type {
|
||||
HealthStatus,
|
||||
Message,
|
||||
PathDiscoveryResponse,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
RadioTraceHopRequest,
|
||||
RadioTraceResponse,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
@@ -38,8 +36,6 @@ interface ConversationPaneProps {
|
||||
activeConversation: Conversation | null;
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
rawPackets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
notificationsSupported: boolean;
|
||||
@@ -125,8 +121,6 @@ export function ConversationPane({
|
||||
activeConversation,
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported,
|
||||
@@ -217,7 +211,6 @@ export function ConversationPane({
|
||||
<MapView
|
||||
contacts={contacts}
|
||||
focusedKey={activeConversation.mapFocusKey}
|
||||
rawPackets={rawPackets}
|
||||
config={config}
|
||||
blockedKeys={blockedKeys}
|
||||
blockedNames={blockedNames}
|
||||
@@ -242,25 +235,13 @@ export function ConversationPane({
|
||||
if (activeConversation.type === 'visualizer') {
|
||||
return (
|
||||
<Suspense fallback={<LoadingPane label="Loading visualizer..." />}>
|
||||
<VisualizerView
|
||||
packets={rawPackets}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
config={config}
|
||||
/>
|
||||
<VisualizerView contacts={contacts} channels={channels} config={config} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'raw') {
|
||||
return (
|
||||
<RawPacketFeedView
|
||||
packets={rawPackets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
return <RawPacketFeedView contacts={contacts} channels={channels} />;
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'search') {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
||||
import { useRawPackets } from '../stores/rawPacketStore';
|
||||
|
||||
interface CrackedChannel {
|
||||
channelName: string;
|
||||
@@ -23,7 +24,6 @@ interface QueueItem {
|
||||
}
|
||||
|
||||
export interface CrackerPanelProps {
|
||||
packets: RawPacket[];
|
||||
channels: Channel[];
|
||||
onChannelCreate: (name: string, key: string) => Promise<void>;
|
||||
onRunningChange?: (running: boolean) => void;
|
||||
@@ -31,12 +31,12 @@ export interface CrackerPanelProps {
|
||||
}
|
||||
|
||||
export function CrackerPanel({
|
||||
packets,
|
||||
channels,
|
||||
onChannelCreate,
|
||||
onRunningChange,
|
||||
visible = false,
|
||||
}: CrackerPanelProps) {
|
||||
const packets = useRawPackets();
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [maxLength, setMaxLength] = useState(6);
|
||||
const [maxLengthInput, setMaxLengthInput] = useState('6');
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||
import type { Contact, RadioConfig } from '../types';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
@@ -23,13 +23,13 @@ import {
|
||||
dedupeConsecutive,
|
||||
} from '../utils/visualizerUtils';
|
||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||
import { useRawPackets } from '../stores/rawPacketStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MapViewProps {
|
||||
contacts: Contact[];
|
||||
/** Public key of contact to focus on and open popup */
|
||||
focusedKey?: string | null;
|
||||
rawPackets?: RawPacket[];
|
||||
config?: RadioConfig | null;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
@@ -541,12 +541,12 @@ function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
|
||||
export function MapView({
|
||||
contacts,
|
||||
focusedKey,
|
||||
rawPackets,
|
||||
config,
|
||||
blockedKeys,
|
||||
blockedNames,
|
||||
onSelectContact,
|
||||
}: MapViewProps) {
|
||||
const rawPackets = useRawPackets();
|
||||
const [sinceId, setSinceId] = useState<MapSinceId>(getSavedSinceId);
|
||||
const [customSince, setCustomSince] = useState('');
|
||||
const [nowSec, setNowSec] = useState(() => Date.now() / 1000);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type RawPacketStatsWindow,
|
||||
} from '../utils/rawPacketStats';
|
||||
import { createDecoderOptions } from '../utils/rawPacketInspector';
|
||||
import { useRawPacketStatsSession, useRawPackets } from '../stores/rawPacketStore';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -197,8 +198,6 @@ function FeedFilterControls({
|
||||
}
|
||||
|
||||
interface RawPacketFeedViewProps {
|
||||
packets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
}
|
||||
@@ -588,12 +587,9 @@ function TimelineChart({
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketFeedView({
|
||||
packets,
|
||||
rawPacketStatsSession,
|
||||
contacts,
|
||||
channels,
|
||||
}: RawPacketFeedViewProps) {
|
||||
export function RawPacketFeedView({ contacts, channels }: RawPacketFeedViewProps) {
|
||||
const packets = useRawPackets();
|
||||
const rawPacketStatsSession = useRawPacketStatsSession();
|
||||
const [statsOpen, setStatsOpen] = useState(() =>
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(min-width: 768px)').matches
|
||||
|
||||
@@ -7,15 +7,16 @@ import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualizerSettings';
|
||||
import { useRawPackets } from '../stores/rawPacketStore';
|
||||
|
||||
interface VisualizerViewProps {
|
||||
packets: RawPacket[];
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
config: RadioConfig | null;
|
||||
}
|
||||
|
||||
export function VisualizerView({ packets, contacts, channels, config }: VisualizerViewProps) {
|
||||
export function VisualizerView({ contacts, channels, config }: VisualizerViewProps) {
|
||||
const packets = useRawPackets();
|
||||
const [fullScreen, setFullScreen] = useState(() => getVisualizerSettings().hidePacketFeed);
|
||||
const [paneFullScreen, setPaneFullScreen] = useState(false);
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
|
||||
@@ -11,4 +11,3 @@ export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge';
|
||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { RawPacket } from '../types';
|
||||
import {
|
||||
MAX_RAW_PACKET_STATS_OBSERVATIONS,
|
||||
summarizeRawPacketForStats,
|
||||
type RawPacketStatsSessionState,
|
||||
} from '../utils/rawPacketStats';
|
||||
|
||||
export function useRawPacketStatsSession() {
|
||||
const [session, setSession] = useState<RawPacketStatsSessionState>(() => ({
|
||||
sessionStartedAt: Date.now(),
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
}));
|
||||
|
||||
const recordRawPacketObservation = useCallback((packet: RawPacket) => {
|
||||
setSession((prev) => {
|
||||
const observation = summarizeRawPacketForStats(packet);
|
||||
if (
|
||||
prev.observations.some(
|
||||
(candidate) => candidate.observationKey === observation.observationKey
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const observations = [...prev.observations, observation];
|
||||
if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) {
|
||||
return {
|
||||
...prev,
|
||||
totalObservedPackets: prev.totalObservedPackets + 1,
|
||||
observations,
|
||||
};
|
||||
}
|
||||
|
||||
const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS;
|
||||
return {
|
||||
...prev,
|
||||
totalObservedPackets: prev.totalObservedPackets + 1,
|
||||
trimmedObservationCount: prev.trimmedObservationCount + overflow,
|
||||
observations: observations.slice(overflow),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rawPacketStatsSession: session,
|
||||
recordRawPacketObservation,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,7 @@ 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 { clearRawPackets, recordRawPacket } from '../stores/rawPacketStore';
|
||||
import { emitStatusDotPulse } from '../utils/statusDotPulse';
|
||||
import type {
|
||||
Channel,
|
||||
@@ -27,7 +27,6 @@ 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[]>>;
|
||||
@@ -58,7 +57,6 @@ interface UseRealtimeAppStateArgs {
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
notifyIncomingMessage?: (msg: Message) => void;
|
||||
recordRawPacketObservation?: (packet: RawPacket) => void;
|
||||
maxRawPackets?: number;
|
||||
}
|
||||
|
||||
@@ -87,7 +85,6 @@ export function useRealtimeAppState({
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
fetchConfig,
|
||||
setRawPackets,
|
||||
reconcileOnReconnect,
|
||||
refreshUnreads,
|
||||
setChannels,
|
||||
@@ -108,7 +105,6 @@ export function useRealtimeAppState({
|
||||
removeConversationMessages,
|
||||
receiveMessageAck,
|
||||
notifyIncomingMessage,
|
||||
recordRawPacketObservation,
|
||||
maxRawPackets = 500,
|
||||
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
|
||||
const mergeChannelIntoList = useCallback(
|
||||
@@ -180,7 +176,7 @@ export function useRealtimeAppState({
|
||||
});
|
||||
},
|
||||
onReconnect: () => {
|
||||
setRawPackets([]);
|
||||
clearRawPackets();
|
||||
reconcileOnReconnect();
|
||||
refreshUnreads();
|
||||
api.getChannels().then(setChannels).catch(console.error);
|
||||
@@ -263,9 +259,8 @@ export function useRealtimeAppState({
|
||||
}
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
recordRawPacketObservation?.(packet);
|
||||
emitStatusDotPulse(packet.payload_type);
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
||||
recordRawPacket(packet, maxRawPackets);
|
||||
},
|
||||
onMessageAcked: (
|
||||
messageId: number,
|
||||
@@ -291,7 +286,6 @@ export function useRealtimeAppState({
|
||||
pendingDeleteFallbackRef,
|
||||
prevHealthRef,
|
||||
recordMessageEvent,
|
||||
recordRawPacketObservation,
|
||||
receiveMessageAck,
|
||||
observeMessage,
|
||||
refreshUnreads,
|
||||
@@ -301,7 +295,6 @@ export function useRealtimeAppState({
|
||||
setChannels,
|
||||
setContacts,
|
||||
setHealth,
|
||||
setRawPackets,
|
||||
notifyIncomingMessage,
|
||||
]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { RawPacket } from '../types';
|
||||
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
|
||||
import {
|
||||
MAX_RAW_PACKET_STATS_OBSERVATIONS,
|
||||
summarizeRawPacketForStats,
|
||||
type RawPacketStatsSessionState,
|
||||
} from '../utils/rawPacketStats';
|
||||
|
||||
/**
|
||||
* Live radio traffic arrives continuously — every packet the node overhears, not
|
||||
* just our own conversations. Holding that stream in App state re-rendered the whole
|
||||
* tree (including the message list) on every packet, which made typing crawl in
|
||||
* conversations with a lot of history.
|
||||
*
|
||||
* The stream lives here instead, outside React, so only the views that actually read
|
||||
* packets (map, visualizer, raw feed, cracker) re-render when one arrives.
|
||||
*/
|
||||
|
||||
export const MAX_RAW_PACKETS = 500;
|
||||
|
||||
function createStatsSession(): RawPacketStatsSessionState {
|
||||
return {
|
||||
sessionStartedAt: Date.now(),
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
};
|
||||
}
|
||||
|
||||
let packets: RawPacket[] = [];
|
||||
let statsSession: RawPacketStatsSessionState = createStatsSession();
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function observePacket(
|
||||
session: RawPacketStatsSessionState,
|
||||
packet: RawPacket
|
||||
): RawPacketStatsSessionState {
|
||||
const observation = summarizeRawPacketForStats(packet);
|
||||
if (
|
||||
session.observations.some(
|
||||
(candidate) => candidate.observationKey === observation.observationKey
|
||||
)
|
||||
) {
|
||||
return session;
|
||||
}
|
||||
|
||||
const observations = [...session.observations, observation];
|
||||
if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) {
|
||||
return {
|
||||
...session,
|
||||
totalObservedPackets: session.totalObservedPackets + 1,
|
||||
observations,
|
||||
};
|
||||
}
|
||||
|
||||
const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS;
|
||||
return {
|
||||
...session,
|
||||
totalObservedPackets: session.totalObservedPackets + 1,
|
||||
trimmedObservationCount: session.trimmedObservationCount + overflow,
|
||||
observations: observations.slice(overflow),
|
||||
};
|
||||
}
|
||||
|
||||
/** Record one observed packet into both the rolling buffer and the session stats. */
|
||||
export function recordRawPacket(packet: RawPacket, maxPackets: number = MAX_RAW_PACKETS): void {
|
||||
const nextPackets = appendRawPacketUnique(packets, packet, maxPackets);
|
||||
const nextStats = observePacket(statsSession, packet);
|
||||
if (nextPackets === packets && nextStats === statsSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
packets = nextPackets;
|
||||
statsSession = nextStats;
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the buffered packets on reconnect — we may have missed traffic while offline.
|
||||
* Session stats deliberately survive: they describe the whole observation session.
|
||||
*/
|
||||
export function clearRawPackets(): void {
|
||||
if (packets.length === 0) {
|
||||
return;
|
||||
}
|
||||
packets = [];
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Full reset, including session stats. Used by tests to isolate cases. */
|
||||
export function resetRawPacketStore(): void {
|
||||
packets = [];
|
||||
statsSession = createStatsSession();
|
||||
emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a specific stream/stats snapshot. Exists so tests can drive the views
|
||||
* from fixed fixtures — including stats shapes that a live packet feed would take
|
||||
* minutes to produce — without reintroducing prop drilling through the app tree.
|
||||
*/
|
||||
export function seedRawPacketStore(next: {
|
||||
packets?: RawPacket[];
|
||||
statsSession?: RawPacketStatsSessionState;
|
||||
}): void {
|
||||
if (next.packets) {
|
||||
packets = next.packets;
|
||||
}
|
||||
if (next.statsSession) {
|
||||
statsSession = next.statsSession;
|
||||
}
|
||||
emit();
|
||||
}
|
||||
|
||||
export function getRawPackets(): RawPacket[] {
|
||||
return packets;
|
||||
}
|
||||
|
||||
export function getRawPacketStatsSession(): RawPacketStatsSessionState {
|
||||
return statsSession;
|
||||
}
|
||||
|
||||
export function useRawPackets(): RawPacket[] {
|
||||
return useSyncExternalStore(subscribe, getRawPackets);
|
||||
}
|
||||
|
||||
export function useRawPacketStatsSession(): RawPacketStatsSessionState {
|
||||
return useSyncExternalStore(subscribe, getRawPacketStatsSession);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ describe('CrackerPanel', () => {
|
||||
});
|
||||
|
||||
it('allows clearing max length while editing', async () => {
|
||||
render(<CrackerPanel packets={[]} channels={[]} onChannelCreate={vi.fn()} visible={false} />);
|
||||
render(<CrackerPanel channels={[]} onChannelCreate={vi.fn()} visible={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.getUndecryptedPacketCount).toHaveBeenCalled();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketFeedView } from '../components/RawPacketFeedView';
|
||||
import { resetRawPacketStore, seedRawPacketStore } from '../stores/rawPacketStore';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
|
||||
@@ -108,17 +109,15 @@ function renderView({
|
||||
channels?: Channel[];
|
||||
rawPacketStatsSession?: RawPacketStatsSessionState;
|
||||
} = {}) {
|
||||
return render(
|
||||
<RawPacketFeedView
|
||||
packets={packets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
seedRawPacketStore({ packets, statsSession: rawPacketStatsSession });
|
||||
return render(<RawPacketFeedView contacts={contacts} channels={channels} />);
|
||||
}
|
||||
|
||||
describe('RawPacketFeedView', () => {
|
||||
beforeEach(() => {
|
||||
resetRawPacketStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -225,7 +224,7 @@ describe('RawPacketFeedView', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = renderView({
|
||||
renderView({
|
||||
packets: initialPackets,
|
||||
rawPacketStatsSession: initialSession,
|
||||
contacts: [],
|
||||
@@ -236,14 +235,7 @@ describe('RawPacketFeedView', () => {
|
||||
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T00:01:10Z'));
|
||||
rerender(
|
||||
<RawPacketFeedView
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={initialSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
act(() => seedRawPacketStore({ packets: nextPackets, statsSession: initialSession }));
|
||||
expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T00:01:30Z'));
|
||||
@@ -257,14 +249,7 @@ describe('RawPacketFeedView', () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(
|
||||
<RawPacketFeedView
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={nextSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
act(() => seedRawPacketStore({ packets: nextPackets, statsSession: nextSession }));
|
||||
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { act, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
import {
|
||||
clearRawPackets,
|
||||
getRawPacketStatsSession,
|
||||
getRawPackets,
|
||||
recordRawPacket,
|
||||
resetRawPacketStore,
|
||||
useRawPackets,
|
||||
} from '../stores/rawPacketStore';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RadioConfig,
|
||||
RawPacket,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
messageList: vi.fn(() => <div data-testid="message-list" />),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageList', () => ({
|
||||
MessageList: mocks.messageList,
|
||||
}));
|
||||
|
||||
vi.mock('../components/ChatHeader', () => ({
|
||||
ChatHeader: () => <div data-testid="chat-header" />,
|
||||
}));
|
||||
|
||||
function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
|
||||
return {
|
||||
id: 1,
|
||||
observation_id: 1,
|
||||
timestamp: 1700000000,
|
||||
data: 'aabb',
|
||||
payload_type: 'GROUP_TEXT',
|
||||
snr: 7.5,
|
||||
rssi: -80,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const channel: Channel = {
|
||||
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
|
||||
name: 'Public',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const config: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Radio',
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
tx_power: 17,
|
||||
max_tx_power: 22,
|
||||
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: true,
|
||||
};
|
||||
|
||||
const health: HealthStatus = {
|
||||
status: 'ok',
|
||||
radio_connected: true,
|
||||
radio_initializing: false,
|
||||
connection_info: 'serial',
|
||||
database_size_mb: 1,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
fanout_statuses: {},
|
||||
bots_disabled: false,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: channel.key,
|
||||
text: 'hello',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
sender_key: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
};
|
||||
|
||||
const rawPacketStatsSession: RawPacketStatsSessionState = {
|
||||
sessionStartedAt: 1_700_000_000_000,
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
};
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
function chatPaneProps(): any {
|
||||
return {
|
||||
activeConversation: { type: 'channel', id: channel.key, name: channel.name } as Conversation,
|
||||
contacts: [] as Contact[],
|
||||
channels: [channel],
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
messages: [message],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
unreadMarkerLastReadAt: undefined,
|
||||
targetMessageId: null,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
messageInputRef: { current: null },
|
||||
onTrace: vi.fn(async () => {}),
|
||||
onRunTracePath: vi.fn(async () => ({ path_len: 0, timeout_seconds: 5, nodes: [] })),
|
||||
onPathDiscovery: vi.fn(async () => {
|
||||
throw new Error('unused');
|
||||
}),
|
||||
onToggleFavorite: vi.fn(async () => {}),
|
||||
onToggleMute: vi.fn(async () => {}),
|
||||
onDeleteContact: vi.fn(async () => {}),
|
||||
onDeleteChannel: vi.fn(async () => {}),
|
||||
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
||||
onSelectConversation: vi.fn(),
|
||||
onOpenContactInfo: vi.fn(),
|
||||
onOpenChannelInfo: vi.fn(),
|
||||
onSenderClick: vi.fn(),
|
||||
onLoadOlder: vi.fn(async () => {}),
|
||||
onResendChannelMessage: vi.fn(async () => {}),
|
||||
onTargetReached: vi.fn(),
|
||||
onLoadNewer: vi.fn(async () => {}),
|
||||
onJumpToBottom: vi.fn(),
|
||||
onDismissUnreadMarker: vi.fn(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
repeaterAutoLoginKey: null,
|
||||
onClearRepeaterAutoLogin: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('rawPacketStore', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRawPacketStore();
|
||||
});
|
||||
|
||||
it('appends packets and counts them in the session stats', () => {
|
||||
recordRawPacket(createPacket({ id: 1, observation_id: 1 }));
|
||||
recordRawPacket(createPacket({ id: 2, observation_id: 2 }));
|
||||
|
||||
expect(getRawPackets()).toHaveLength(2);
|
||||
expect(getRawPacketStatsSession().totalObservedPackets).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores a repeat of the same observation', () => {
|
||||
recordRawPacket(createPacket({ id: 1, observation_id: 9 }));
|
||||
recordRawPacket(createPacket({ id: 1, observation_id: 9 }));
|
||||
|
||||
expect(getRawPackets()).toHaveLength(1);
|
||||
expect(getRawPacketStatsSession().totalObservedPackets).toBe(1);
|
||||
});
|
||||
|
||||
it('caps the buffer at the requested size, keeping the newest packets', () => {
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
recordRawPacket(createPacket({ id: i, observation_id: i }), 3);
|
||||
}
|
||||
|
||||
expect(getRawPackets().map((p) => p.id)).toEqual([3, 4, 5]);
|
||||
});
|
||||
|
||||
it('keeps session stats across a reconnect clear', () => {
|
||||
recordRawPacket(createPacket({ id: 1, observation_id: 1 }));
|
||||
clearRawPackets();
|
||||
|
||||
expect(getRawPackets()).toEqual([]);
|
||||
expect(getRawPacketStatsSession().totalObservedPackets).toBe(1);
|
||||
});
|
||||
|
||||
it('notifies subscribed views when a packet arrives', () => {
|
||||
function PacketCount() {
|
||||
return <span data-testid="count">{useRawPackets().length}</span>;
|
||||
}
|
||||
render(<PacketCount />);
|
||||
expect(screen.getByTestId('count').textContent).toBe('0');
|
||||
|
||||
act(() => recordRawPacket(createPacket({ id: 1, observation_id: 1 })));
|
||||
|
||||
expect(screen.getByTestId('count').textContent).toBe('1');
|
||||
});
|
||||
|
||||
/**
|
||||
* The reason this store exists: overheard traffic used to live in App state, so every
|
||||
* packet re-rendered the whole message list. That cost scales with history length and
|
||||
* made typing in a busy channel crawl.
|
||||
*/
|
||||
it('does not re-render the chat message list when packets arrive', () => {
|
||||
render(<ConversationPane {...chatPaneProps()} />);
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
|
||||
const rendersAfterMount = mocks.messageList.mock.calls.length;
|
||||
act(() => {
|
||||
for (let i = 1; i <= 25; i++) {
|
||||
recordRawPacket(createPacket({ id: i, observation_id: i }));
|
||||
}
|
||||
});
|
||||
|
||||
expect(getRawPackets()).toHaveLength(25);
|
||||
expect(mocks.messageList.mock.calls.length).toBe(rendersAfterMount);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,12 @@ import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useRealtimeAppState } from '../hooks/useRealtimeAppState';
|
||||
import {
|
||||
getRawPacketStatsSession,
|
||||
getRawPackets,
|
||||
resetRawPacketStore,
|
||||
seedRawPacketStore,
|
||||
} from '../stores/rawPacketStore';
|
||||
import type { Channel, Contact, Conversation, HealthStatus, Message, RawPacket } from '../types';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -32,6 +38,18 @@ const publicChannel: Channel = {
|
||||
muted: false,
|
||||
};
|
||||
|
||||
const rawPacketFixture: 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 incomingDm: Message = {
|
||||
id: 7,
|
||||
type: 'PRIV',
|
||||
@@ -50,7 +68,6 @@ const incomingDm: Message = {
|
||||
|
||||
function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppState>[0]> = {}) {
|
||||
const setHealth = vi.fn();
|
||||
const setRawPackets = vi.fn();
|
||||
const setChannels = vi.fn();
|
||||
const setContacts = vi.fn();
|
||||
|
||||
@@ -59,7 +76,6 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
prevHealthRef: { current: null as HealthStatus | null },
|
||||
setHealth,
|
||||
fetchConfig: vi.fn(),
|
||||
setRawPackets,
|
||||
reconcileOnReconnect: vi.fn(),
|
||||
refreshUnreads: vi.fn(async () => {}),
|
||||
setChannels,
|
||||
@@ -84,7 +100,6 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
},
|
||||
fns: {
|
||||
setHealth,
|
||||
setRawPackets,
|
||||
setChannels,
|
||||
setContacts,
|
||||
},
|
||||
@@ -94,6 +109,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
describe('useRealtimeAppState', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRawPacketStore();
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel]);
|
||||
});
|
||||
|
||||
@@ -125,6 +141,8 @@ describe('useRealtimeAppState', () => {
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
seedRawPacketStore({ packets: [rawPacketFixture] });
|
||||
|
||||
act(() => {
|
||||
result.current.onReconnect?.();
|
||||
});
|
||||
@@ -134,7 +152,7 @@ describe('useRealtimeAppState', () => {
|
||||
expect(args.refreshUnreads).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
|
||||
expect(args.fetchAllContacts).toHaveBeenCalledTimes(1);
|
||||
expect(fns.setRawPackets).toHaveBeenCalledWith([]);
|
||||
expect(getRawPackets()).toEqual([]);
|
||||
expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]);
|
||||
expect(fns.setContacts).toHaveBeenCalledWith(contacts);
|
||||
});
|
||||
@@ -168,6 +186,8 @@ describe('useRealtimeAppState', () => {
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
seedRawPacketStore({ packets: [rawPacketFixture] });
|
||||
|
||||
act(() => {
|
||||
result.current.onReconnect?.();
|
||||
});
|
||||
@@ -177,7 +197,7 @@ describe('useRealtimeAppState', () => {
|
||||
expect(args.refreshUnreads).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
|
||||
expect(args.fetchAllContacts).toHaveBeenCalledTimes(1);
|
||||
expect(fns.setRawPackets).toHaveBeenCalledWith([]);
|
||||
expect(getRawPackets()).toEqual([]);
|
||||
expect(fns.setChannels).toHaveBeenCalledWith([publicChannel]);
|
||||
expect(fns.setContacts).toHaveBeenCalledWith(contacts);
|
||||
});
|
||||
@@ -285,18 +305,8 @@ describe('useRealtimeAppState', () => {
|
||||
});
|
||||
|
||||
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 { args } = createRealtimeArgs();
|
||||
const packet = rawPacketFixture;
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
@@ -304,6 +314,7 @@ describe('useRealtimeAppState', () => {
|
||||
result.current.onRawPacket?.(packet);
|
||||
});
|
||||
|
||||
expect(fns.setRawPackets).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(getRawPackets()).toEqual([packet]);
|
||||
expect(getRawPacketStatsSession().totalObservedPackets).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VisualizerView } from '../components/VisualizerView';
|
||||
import { resetRawPacketStore, seedRawPacketStore } from '../stores/rawPacketStore';
|
||||
import type { RawPacket } from '../types';
|
||||
|
||||
// The 3D scene needs WebGL, which jsdom does not provide.
|
||||
@@ -26,17 +27,12 @@ function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
|
||||
describe('VisualizerView packet feed', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
resetRawPacketStore();
|
||||
});
|
||||
|
||||
it('opens the packet analyzer when a feed packet is clicked', () => {
|
||||
render(
|
||||
<VisualizerView
|
||||
packets={[createPacket({ id: 7, observation_id: 21 })]}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
config={null}
|
||||
/>
|
||||
);
|
||||
seedRawPacketStore({ packets: [createPacket({ id: 7, observation_id: 21 })] });
|
||||
render(<VisualizerView contacts={[]} channels={[]} config={null} />);
|
||||
|
||||
expect(screen.queryByText('Packet Details')).not.toBeInTheDocument();
|
||||
|
||||
@@ -47,7 +43,7 @@ describe('VisualizerView packet feed', () => {
|
||||
});
|
||||
|
||||
it('does not render the analyzer until a packet is selected', () => {
|
||||
render(<VisualizerView packets={[]} contacts={[]} channels={[]} config={null} />);
|
||||
render(<VisualizerView contacts={[]} channels={[]} config={null} />);
|
||||
|
||||
expect(screen.queryByText('Packet Details')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user