Outgoing WS now echoes, websock reclamation after unmount cleanup, hash fix for empty contacts, no double bot broadcast, AGENTS.md + test fixes (this should have been more than one commit lol)

This commit is contained in:
Jack Kingsman
2026-02-11 23:59:05 -08:00
parent fef18a943c
commit f73fa54532
14 changed files with 816 additions and 1391 deletions
+29 -15
View File
@@ -78,6 +78,7 @@ export function App() {
const [config, setConfig] = useState<RadioConfig | null>(null);
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [contacts, setContacts] = useState<Contact[]>([]);
const [contactsLoaded, setContactsLoaded] = useState(false);
const [channels, setChannels] = useState<Channel[]>([]);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
@@ -178,7 +179,10 @@ export function App() {
description: success.details,
});
},
onContacts: (data: Contact[]) => setContacts(data),
onContacts: (data: Contact[]) => {
setContacts(data);
setContactsLoaded(true);
},
onChannels: (data: Channel[]) => setChannels(data),
onMessage: (msg: Message) => {
const activeConv = activeConversationRef.current;
@@ -339,7 +343,15 @@ export function App() {
// Fetch contacts and channels via REST (parallel, faster than WS serial push)
api.getChannels().then(setChannels).catch(console.error);
fetchAllContacts().then(setContacts).catch(console.error);
fetchAllContacts()
.then((data) => {
setContacts(data);
setContactsLoaded(true);
})
.catch((err) => {
console.error(err);
setContactsLoaded(true);
});
}, [fetchConfig, fetchAppSettings, fetchUndecryptedCount, fetchAllContacts]);
// One-time migration of localStorage preferences to server
@@ -467,10 +479,12 @@ export function App() {
// Phase 2: Resolve contact hash (only if phase 1 didn't set a conversation)
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
if (contacts.length === 0) return;
const hashConv = parseHashConversation();
if (hashConv?.type === 'contact') {
// Wait until the initial contacts load finishes so we don't fall back early.
if (!contactsLoaded) return;
const contact = resolveContactFromHashToken(hashConv.name, contacts);
if (contact) {
setActiveConversation({
@@ -481,21 +495,21 @@ export function App() {
hasSetDefaultConversation.current = true;
return;
}
}
// Contact hash didn't match — fall back to Public if channels loaded
if (channels.length > 0) {
const publicChannel = channels.find((c) => c.name === 'Public');
if (publicChannel) {
setActiveConversation({
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
});
hasSetDefaultConversation.current = true;
// Contact hash didn't match — fall back to Public if channels loaded.
if (channels.length > 0) {
const publicChannel = channels.find((c) => c.name === 'Public');
if (publicChannel) {
setActiveConversation({
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
});
hasSetDefaultConversation.current = true;
}
}
}
}, [contacts, channels, activeConversation]);
}, [contacts, channels, activeConversation, contactsLoaded]);
// Keep ref in sync and update URL hash
useEffect(() => {
+1 -1
View File
@@ -1584,7 +1584,7 @@ export function PacketVisualizer({
title="Split ambiguous repeaters into separate nodes based on traffic patterns (prev→next). Helps identify colliding prefixes representing different physical nodes."
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
>
Hueristically group repeaters by traffic pattern
Heuristically group repeaters by traffic pattern
</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
+189
View File
@@ -0,0 +1,189 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
api: {
getRadioConfig: vi.fn(),
getSettings: vi.fn(),
getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
migratePreferences: vi.fn(),
},
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
vi.mock('../useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../hooks', () => ({
useConversationMessages: () => ({
messages: [],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
setMessages: vi.fn(),
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
}),
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
}),
useRepeaterMode: () => ({
repeaterLoggedIn: false,
activeContactIsRepeater: false,
handleTelemetryRequest: vi.fn(),
handleRepeaterCommand: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
}));
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
vi.mock('../components/Sidebar', () => ({
Sidebar: ({
activeConversation,
}: {
activeConversation: { type: string; id: string; name: string } | null;
}) => (
<div data-testid="active-conversation">
{activeConversation
? `${activeConversation.type}:${activeConversation.id}:${activeConversation.name}`
: 'none'}
</div>
),
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
}));
vi.mock('../components/MessageInput', () => ({
MessageInput: React.forwardRef((_props, ref) => {
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
return <div data-testid="message-input" />;
}),
}));
vi.mock('../components/NewMessageModal', () => ({
NewMessageModal: () => null,
}));
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
SETTINGS_SECTION_ORDER: ['radio', 'identity', 'connectivity', 'database', 'bot'],
SETTINGS_SECTION_LABELS: {
radio: 'Radio',
identity: 'Identity',
connectivity: 'Connectivity',
database: 'Database',
bot: 'Bot',
},
}));
vi.mock('../components/RawPacketList', () => ({
RawPacketList: () => null,
}));
vi.mock('../components/MapView', () => ({
MapView: () => null,
}));
vi.mock('../components/VisualizerView', () => ({
VisualizerView: () => null,
}));
vi.mock('../components/CrackerPanel', () => ({
CrackerPanel: () => null,
}));
vi.mock('../components/ui/sheet', () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ui/sonner', () => ({
Toaster: () => null,
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { App } from '../App';
const publicChannel = {
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
};
describe('App startup hash resolution', () => {
beforeEach(() => {
vi.clearAllMocks();
window.location.hash = `#contact/${'a'.repeat(64)}/Alice`;
mocks.api.getRadioConfig.mockResolvedValue({
public_key: 'aa'.repeat(32),
name: 'TestNode',
lat: 0,
lon: 0,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
experimental_channel_double_send: false,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
bots: [],
});
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
mocks.api.getChannels.mockResolvedValue([publicChannel]);
mocks.api.getContacts.mockResolvedValue([]);
});
afterEach(() => {
window.location.hash = '';
});
it('falls back to Public when contact hash is unresolvable and contacts are empty', async () => {
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`);
}
});
});
});
@@ -0,0 +1,63 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useWebSocket } from '../useWebSocket';
class MockWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
static instances: MockWebSocket[] = [];
url: string;
readyState = MockWebSocket.OPEN;
onopen: (() => void) | null = null;
onclose: (() => void) | null = null;
onerror: ((error: unknown) => void) | null = null;
onmessage: ((event: { data: string }) => void) | null = null;
constructor(url: string) {
this.url = url;
MockWebSocket.instances.push(this);
}
close(): void {
this.readyState = MockWebSocket.CLOSED;
this.onclose?.();
}
send(): void {}
}
const originalWebSocket = globalThis.WebSocket;
describe('useWebSocket lifecycle', () => {
beforeEach(() => {
vi.useFakeTimers();
MockWebSocket.instances = [];
globalThis.WebSocket = MockWebSocket as unknown as typeof WebSocket;
});
afterEach(() => {
globalThis.WebSocket = originalWebSocket;
vi.useRealTimers();
});
it('does not reconnect after hook unmount cleanup', () => {
const { unmount } = renderHook(() => useWebSocket({}));
expect(MockWebSocket.instances).toHaveLength(1);
act(() => {
unmount();
});
act(() => {
vi.advanceTimersByTime(3100);
});
// Unmount-triggered socket close should not start a new connection.
expect(MockWebSocket.instances).toHaveLength(1);
});
});
+7
View File
@@ -31,6 +31,7 @@ interface UseWebSocketOptions {
export function useWebSocket(options: UseWebSocketOptions) {
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const shouldReconnectRef = useRef(true);
const [connected, setConnected] = useState(false);
// Store options in ref to avoid stale closures in WebSocket handlers.
@@ -71,6 +72,10 @@ export function useWebSocket(options: UseWebSocketOptions) {
setConnected(false);
wsRef.current = null;
if (!shouldReconnectRef.current) {
return;
}
// Reconnect after 3 seconds
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
@@ -140,6 +145,7 @@ export function useWebSocket(options: UseWebSocketOptions) {
}, []); // No dependencies - handlers accessed through ref
useEffect(() => {
shouldReconnectRef.current = true;
connect();
// Ping every 30 seconds to keep connection alive
@@ -150,6 +156,7 @@ export function useWebSocket(options: UseWebSocketOptions) {
}, 30000);
return () => {
shouldReconnectRef.current = false;
clearInterval(pingInterval);
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);