diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 1130b52..fed4a71 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -35,6 +35,7 @@ frontend/src/ │ └── utils.ts # cn() — clsx + tailwind-merge helper ├── hooks/ │ ├── index.ts # Central re-export of all hooks +│ ├── useConversationActions.ts # Send/navigation/info-pane conversation actions │ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps │ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery @@ -157,6 +158,7 @@ frontend/src/ - `useAppSettings`: settings CRUD, favorites, preferences migration - `useContactsAndChannels`: contact/channel lists, creation, deletion - `useConversationRouter`: URL hash → active conversation routing +- `useConversationActions`: send/resend/trace/navigation handlers and info-pane state - `useConversationMessages`: fetch, pagination, dedup/update helpers - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps - `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination @@ -284,7 +286,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf - Nearest repeaters (resolved from first-hop path prefixes) - Recent advert paths -State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot. +State: `useConversationActions` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot. ## Channel Info Pane @@ -296,7 +298,7 @@ Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right d - First message date - Top senders in last 24h (name + count) -State: `infoPaneChannelKey` in App.tsx controls open/close. Live channel data from the `channels` array is preferred over the initial detail snapshot. +State: `useConversationActions` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot. ## Repeater Dashboard @@ -316,7 +318,7 @@ All state is managed by `useRepeaterDashboard` hook. State resets on conversatio The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors: -- **State**: `targetMessageId` in `App.tsx` drives the jump-to-message flow. When a search result is clicked, `handleNavigateToMessage` sets `targetMessageId` and switches to the target conversation. +- **State**: `targetMessageId` is shared between `App.tsx`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. - **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results. - **Jump-to-message**: `useConversationMessages` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. - **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9b2e2f1..51664c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,9 +18,9 @@ import { useAppSettings, useConversationRouter, useContactsAndChannels, + useConversationActions, useRealtimeAppState, } from './hooks'; -import * as messageCache from './messageCache'; import { StatusBar } from './components/StatusBar'; import { Sidebar } from './components/Sidebar'; import { ChatHeader } from './components/ChatHeader'; @@ -61,12 +61,11 @@ import { SheetHeader, SheetTitle, } from './components/ui/sheet'; -import { Toaster, toast } from './components/ui/sonner'; +import { Toaster } from './components/ui/sonner'; import { messageContainsMention } from './utils/messageParser'; import { getLocalLabel, getContrastTextColor } from './utils/localLabel'; import { cn } from '@/lib/utils'; -import type { SearchNavigateTarget } from './components/SearchView'; -import type { Channel, Conversation, Message, RawPacket } from './types'; +import type { Conversation, RawPacket } from './types'; export function App() { const messageInputRef = useRef(null); @@ -78,9 +77,6 @@ export function App() { const [showCracker, setShowCracker] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false); const [localLabel, setLocalLabel] = useState(getLocalLabel); - const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); - const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); - const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); const [targetMessageId, setTargetMessageId] = useState(null); // Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state) @@ -242,21 +238,37 @@ export function App() { setActiveConversation, updateMessageAck, }); - - 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] - ); + const { + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + handleSendMessage, + handleResendChannelMessage, + handleSetChannelFloodScopeOverride, + handleSenderClick, + handleTrace, + handleBlockKey, + handleBlockName, + handleOpenContactInfo, + handleCloseContactInfo, + handleOpenChannelInfo, + handleCloseChannelInfo, + handleSelectConversationWithTargetReset, + handleNavigateToChannel, + handleNavigateToMessage, + } = useConversationActions({ + activeConversation, + activeConversationRef, + setTargetMessageId, + channels, + setChannels, + addMessageIfNew, + jumpToBottom, + handleToggleBlockedKey, + handleToggleBlockedName, + handleSelectConversation, + messageInputRef, + }); // Connect to WebSocket useWebSocket(wsHandlers); @@ -288,106 +300,6 @@ export function App() { setContactsLoaded, ]); - // Send message handler - const handleSendMessage = useCallback( - async (text: string) => { - if (!activeConversation) return; - - const conversationId = activeConversation.id; - - let sent: Message; - if (activeConversation.type === 'channel') { - sent = await api.sendChannelMessage(activeConversation.id, text); - } else { - sent = await api.sendDirectMessage(activeConversation.id, text); - } - - if (activeConversationRef.current?.id === conversationId) { - addMessageIfNew(sent); - } - }, - [activeConversation, addMessageIfNew, activeConversationRef] - ); - - // Handle resend channel message - const handleResendChannelMessage = useCallback( - async (messageId: number, newTimestamp?: boolean) => { - try { - // New-timestamp resend creates a new message; the backend broadcast_event - // will add it to the conversation via WebSocket. - await api.resendChannelMessage(messageId, newTimestamp); - toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent'); - } catch (err) { - toast.error('Failed to resend', { - description: err instanceof Error ? err.message : 'Unknown error', - }); - } - }, - [] - ); - - const handleSetChannelFloodScopeOverride = useCallback( - async (channelKey: string, floodScopeOverride: string) => { - try { - const updated = await api.setChannelFloodScopeOverride(channelKey, floodScopeOverride); - mergeChannelIntoList(updated); - toast.success( - updated.flood_scope_override ? 'Regional override saved' : 'Regional override cleared' - ); - } catch (err) { - toast.error('Failed to update regional override', { - description: err instanceof Error ? err.message : 'Unknown error', - }); - } - }, - [mergeChannelIntoList] - ); - - // Handle sender click to add mention - const handleSenderClick = useCallback((sender: string) => { - messageInputRef.current?.appendText(`@[${sender}] `); - }, []); - - // Handle direct trace request - const handleTrace = useCallback(async () => { - if (!activeConversation || activeConversation.type !== 'contact') return; - toast('Trace started...'); - try { - const result = await api.requestTrace(activeConversation.id); - const parts: string[] = []; - if (result.remote_snr !== null) parts.push(`Remote SNR: ${result.remote_snr.toFixed(1)} dB`); - if (result.local_snr !== null) parts.push(`Local SNR: ${result.local_snr.toFixed(1)} dB`); - const detail = parts.join(', '); - toast.success(detail ? `Trace complete! ${detail}` : 'Trace complete!'); - } catch (err) { - toast.error('Trace failed', { - description: err instanceof Error ? err.message : 'Unknown error', - }); - } - }, [activeConversation]); - - // Wrappers that clear cache and hard-refetch messages after block changes. - // jumpToBottom does cache.remove + fetchMessages(true) which fully replaces - // the message state; triggerReconcile only merges diffs and would keep - // blocked messages already in state. - const handleBlockKey = useCallback( - async (key: string) => { - await handleToggleBlockedKey(key); - messageCache.clear(); - jumpToBottom(); - }, - [handleToggleBlockedKey, jumpToBottom] - ); - - const handleBlockName = useCallback( - async (name: string) => { - await handleToggleBlockedName(name); - messageCache.clear(); - jumpToBottom(); - }, - [handleToggleBlockedName, jumpToBottom] - ); - const handleCloseSettingsView = useCallback(() => { startTransition(() => setShowSettings(false)); setSidebarOpen(false); @@ -409,64 +321,6 @@ export function App() { setShowCracker((prev) => !prev); }, []); - const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => { - setInfoPaneContactKey(publicKey); - setInfoPaneFromChannel(fromChannel ?? false); - }, []); - - const handleCloseContactInfo = useCallback(() => { - setInfoPaneContactKey(null); - }, []); - - const handleOpenChannelInfo = useCallback((channelKey: string) => { - setInfoPaneChannelKey(channelKey); - }, []); - - const handleCloseChannelInfo = useCallback(() => { - setInfoPaneChannelKey(null); - }, []); - - const handleSelectConversationWithTargetReset = useCallback( - (conv: Conversation, options?: { preserveTarget?: boolean }) => { - if (conv.type !== 'search' && !options?.preserveTarget) { - setTargetMessageId(null); - } - handleSelectConversation(conv); - }, - [handleSelectConversation] - ); - - const handleNavigateToChannel = useCallback( - (channelKey: string) => { - const channel = channels.find((c) => c.key === channelKey); - if (channel) { - handleSelectConversationWithTargetReset({ - type: 'channel', - id: channel.key, - name: channel.name, - }); - setInfoPaneContactKey(null); - } - }, - [channels, handleSelectConversationWithTargetReset] - ); - - const handleNavigateToMessage = useCallback( - (target: SearchNavigateTarget) => { - const convType = target.type === 'CHAN' ? 'channel' : 'contact'; - setTargetMessageId(target.id); - handleSelectConversationWithTargetReset( - { - type: convType, - id: target.conversation_key, - name: target.conversation_name, - }, - { preserveTarget: true } - ); - }, - [handleSelectConversationWithTargetReset] - ); - // Sidebar content (shared between desktop and mobile) const sidebarContent = ( ; + setTargetMessageId: Dispatch>; + channels: Channel[]; + setChannels: React.Dispatch>; + addMessageIfNew: (msg: Message) => boolean; + jumpToBottom: () => void; + handleToggleBlockedKey: (key: string) => Promise; + handleToggleBlockedName: (name: string) => Promise; + handleSelectConversation: (conv: Conversation) => void; + messageInputRef: RefObject; +} + +interface UseConversationActionsResult { + infoPaneContactKey: string | null; + infoPaneFromChannel: boolean; + infoPaneChannelKey: string | null; + handleSendMessage: (text: string) => Promise; + handleResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise; + handleSetChannelFloodScopeOverride: ( + channelKey: string, + floodScopeOverride: string + ) => Promise; + handleSenderClick: (sender: string) => void; + handleTrace: () => Promise; + handleBlockKey: (key: string) => Promise; + handleBlockName: (name: string) => Promise; + handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; + handleCloseContactInfo: () => void; + handleOpenChannelInfo: (channelKey: string) => void; + handleCloseChannelInfo: () => void; + handleSelectConversationWithTargetReset: ( + conv: Conversation, + options?: { preserveTarget?: boolean } + ) => void; + handleNavigateToChannel: (channelKey: string) => void; + handleNavigateToMessage: (target: SearchNavigateTarget) => void; +} + +export function useConversationActions({ + activeConversation, + activeConversationRef, + setTargetMessageId, + channels, + setChannels, + addMessageIfNew, + jumpToBottom, + handleToggleBlockedKey, + handleToggleBlockedName, + handleSelectConversation, + messageInputRef, +}: UseConversationActionsArgs): UseConversationActionsResult { + const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); + const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); + const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); + + 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] + ); + + const handleSendMessage = useCallback( + async (text: string) => { + if (!activeConversation) return; + + const conversationId = activeConversation.id; + const sent = + activeConversation.type === 'channel' + ? await api.sendChannelMessage(activeConversation.id, text) + : await api.sendDirectMessage(activeConversation.id, text); + + if (activeConversationRef.current?.id === conversationId) { + addMessageIfNew(sent); + } + }, + [activeConversation, activeConversationRef, addMessageIfNew] + ); + + const handleResendChannelMessage = useCallback( + async (messageId: number, newTimestamp?: boolean) => { + try { + await api.resendChannelMessage(messageId, newTimestamp); + toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent'); + } catch (err) { + toast.error('Failed to resend', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + }, + [] + ); + + const handleSetChannelFloodScopeOverride = useCallback( + async (channelKey: string, floodScopeOverride: string) => { + try { + const updated = await api.setChannelFloodScopeOverride(channelKey, floodScopeOverride); + mergeChannelIntoList(updated); + toast.success( + updated.flood_scope_override ? 'Regional override saved' : 'Regional override cleared' + ); + } catch (err) { + toast.error('Failed to update regional override', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + }, + [mergeChannelIntoList] + ); + + const handleSenderClick = useCallback( + (sender: string) => { + messageInputRef.current?.appendText(`@[${sender}] `); + }, + [messageInputRef] + ); + + const handleTrace = useCallback(async () => { + if (!activeConversation || activeConversation.type !== 'contact') return; + toast('Trace started...'); + try { + const result = await api.requestTrace(activeConversation.id); + const parts: string[] = []; + if (result.remote_snr !== null) parts.push(`Remote SNR: ${result.remote_snr.toFixed(1)} dB`); + if (result.local_snr !== null) parts.push(`Local SNR: ${result.local_snr.toFixed(1)} dB`); + const detail = parts.join(', '); + toast.success(detail ? `Trace complete! ${detail}` : 'Trace complete!'); + } catch (err) { + toast.error('Trace failed', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } + }, [activeConversation]); + + const handleBlockKey = useCallback( + async (key: string) => { + await handleToggleBlockedKey(key); + messageCache.clear(); + jumpToBottom(); + }, + [handleToggleBlockedKey, jumpToBottom] + ); + + const handleBlockName = useCallback( + async (name: string) => { + await handleToggleBlockedName(name); + messageCache.clear(); + jumpToBottom(); + }, + [handleToggleBlockedName, jumpToBottom] + ); + + const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => { + setInfoPaneContactKey(publicKey); + setInfoPaneFromChannel(fromChannel ?? false); + }, []); + + const handleCloseContactInfo = useCallback(() => { + setInfoPaneContactKey(null); + }, []); + + const handleOpenChannelInfo = useCallback((channelKey: string) => { + setInfoPaneChannelKey(channelKey); + }, []); + + const handleCloseChannelInfo = useCallback(() => { + setInfoPaneChannelKey(null); + }, []); + + const handleSelectConversationWithTargetReset = useCallback( + (conv: Conversation, options?: { preserveTarget?: boolean }) => { + if (conv.type !== 'search' && !options?.preserveTarget) { + setTargetMessageId(null); + } + handleSelectConversation(conv); + }, + [handleSelectConversation, setTargetMessageId] + ); + + const handleNavigateToChannel = useCallback( + (channelKey: string) => { + const channel = channels.find((c) => c.key === channelKey); + if (channel) { + handleSelectConversationWithTargetReset({ + type: 'channel', + id: channel.key, + name: channel.name, + }); + setInfoPaneContactKey(null); + } + }, + [channels, handleSelectConversationWithTargetReset] + ); + + const handleNavigateToMessage = useCallback( + (target: SearchNavigateTarget) => { + const convType = target.type === 'CHAN' ? 'channel' : 'contact'; + setTargetMessageId(target.id); + handleSelectConversationWithTargetReset( + { + type: convType, + id: target.conversation_key, + name: target.conversation_name, + }, + { preserveTarget: true } + ); + }, + [handleSelectConversationWithTargetReset, setTargetMessageId] + ); + + return { + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + handleSendMessage, + handleResendChannelMessage, + handleSetChannelFloodScopeOverride, + handleSenderClick, + handleTrace, + handleBlockKey, + handleBlockName, + handleOpenContactInfo, + handleCloseContactInfo, + handleOpenChannelInfo, + handleCloseChannelInfo, + handleSelectConversationWithTargetReset, + handleNavigateToChannel, + handleNavigateToMessage, + }; +} diff --git a/frontend/src/test/useConversationActions.test.ts b/frontend/src/test/useConversationActions.test.ts new file mode 100644 index 0000000..5778166 --- /dev/null +++ b/frontend/src/test/useConversationActions.test.ts @@ -0,0 +1,179 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useConversationActions } from '../hooks/useConversationActions'; +import type { Channel, Conversation, Message } from '../types'; + +const mocks = vi.hoisted(() => ({ + api: { + requestTrace: vi.fn(), + resendChannelMessage: vi.fn(), + sendChannelMessage: vi.fn(), + sendDirectMessage: vi.fn(), + setChannelFloodScopeOverride: vi.fn(), + }, + messageCache: { + clear: vi.fn(), + }, + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock('../api', () => ({ + api: mocks.api, +})); + +vi.mock('../messageCache', () => mocks.messageCache); + +vi.mock('../components/ui/sonner', () => ({ + toast: mocks.toast, +})); + +const publicChannel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +const sentMessage: Message = { + id: 42, + type: 'CHAN', + conversation_key: publicChannel.key, + text: 'hello mesh', + sender_timestamp: 1700000000, + received_at: 1700000001, + paths: null, + txt_type: 0, + signature: null, + sender_key: null, + outgoing: true, + acked: 0, + sender_name: 'Radio', +}; + +function createArgs(overrides: Partial[0]> = {}) { + const activeConversation: Conversation = { + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }; + + return { + activeConversation, + activeConversationRef: { current: activeConversation }, + setTargetMessageId: vi.fn(), + channels: [publicChannel], + setChannels: vi.fn(), + addMessageIfNew: vi.fn(() => true), + jumpToBottom: vi.fn(), + handleToggleBlockedKey: vi.fn(async () => {}), + handleToggleBlockedName: vi.fn(async () => {}), + handleSelectConversation: vi.fn(), + messageInputRef: { current: { appendText: vi.fn() } }, + ...overrides, + }; +} + +describe('useConversationActions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('appends a sent message when the user is still in the same conversation', async () => { + mocks.api.sendChannelMessage.mockResolvedValue(sentMessage); + const args = createArgs(); + + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + await result.current.handleSendMessage(sentMessage.text); + }); + + expect(mocks.api.sendChannelMessage).toHaveBeenCalledWith(publicChannel.key, sentMessage.text); + expect(args.addMessageIfNew).toHaveBeenCalledWith(sentMessage); + }); + + it('does not append a sent message after the active conversation changes', async () => { + let resolveSend: ((message: Message) => void) | null = null; + mocks.api.sendChannelMessage.mockImplementation( + () => + new Promise((resolve) => { + resolveSend = resolve; + }) + ); + + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + const sendPromise = result.current.handleSendMessage(sentMessage.text); + args.activeConversationRef.current = { + type: 'contact', + id: 'aa'.repeat(32), + name: 'Alice', + }; + resolveSend?.(sentMessage); + await sendPromise; + }); + + expect(args.addMessageIfNew).not.toHaveBeenCalled(); + }); + + it('resets the jump target when switching to a normal conversation', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + act(() => { + result.current.handleSelectConversationWithTargetReset({ + type: 'contact', + id: 'bb'.repeat(32), + name: 'Bob', + }); + }); + + expect(args.setTargetMessageId).toHaveBeenCalledWith(null); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'contact', + id: 'bb'.repeat(32), + name: 'Bob', + }); + }); + + it('navigates search results into the target conversation and preserves the jump target', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + act(() => { + result.current.handleNavigateToMessage({ + id: 321, + type: 'CHAN', + conversation_key: publicChannel.key, + conversation_name: publicChannel.name, + }); + }); + + expect(args.setTargetMessageId).toHaveBeenCalledWith(321); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + }); + + it('clears cached messages and jumps to the latest page after blocking a key', async () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + await act(async () => { + await result.current.handleBlockKey('cc'.repeat(32)); + }); + + expect(args.handleToggleBlockedKey).toHaveBeenCalledWith('cc'.repeat(32)); + expect(mocks.messageCache.clear).toHaveBeenCalledTimes(1); + expect(args.jumpToBottom).toHaveBeenCalledTimes(1); + }); +});