diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 010d50d..3859e9c 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -35,7 +35,8 @@ 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 +│ ├── useConversationActions.ts # Send/resend/trace/block conversation actions +│ ├── useConversationNavigation.ts # Search target, selection reset, and info-pane navigation state │ ├── useConversationMessages.ts # Dedup/update helpers over the conversation timeline │ ├── useConversationTimeline.ts # Fetch, cache restore, jump-target loading, pagination, reconcile │ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps @@ -145,6 +146,7 @@ frontend/src/ ├── searchView.test.tsx ├── useConversationMessages.test.ts ├── useConversationMessages.race.test.ts + ├── useConversationNavigation.test.ts ├── useAppShell.test.ts ├── useRepeaterDashboard.test.ts ├── useContactsAndChannels.test.ts @@ -169,12 +171,13 @@ frontend/src/ - new-message modal and info panes High-level state is delegated to hooks: -- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal, target message) +- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal) - `useRadioControl`: radio health/config state, reconnect/reboot polling - `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 +- `useConversationNavigation`: search target, conversation selection reset, and info-pane state +- `useConversationActions`: send/resend/trace/block handlers and channel override updates - `useConversationMessages`: dedup/update helpers and pending ACK buffering - `useConversationTimeline`: conversation switch loading, cache restore, jump-target loading, pagination, reconcile - `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps @@ -311,7 +314,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: `useConversationActions` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot. +State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot. ## Channel Info Pane @@ -323,7 +326,7 @@ Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right d - First message date - Top senders in last 24h (name + count) -State: `useConversationActions` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot. +State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot. ## Repeater Dashboard @@ -343,7 +346,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` is shared between `useAppShell`, `useConversationActions`, and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. +- **State**: `targetMessageId` is shared between `useConversationNavigation` and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation. - **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page. - **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**: `useConversationTimeline` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index de0e846..d0ef71a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import { useConversationRouter, useContactsAndChannels, useConversationActions, + useConversationNavigation, useRealtimeAppState, } from './hooks'; import { AppShell } from './components/AppShell'; @@ -29,12 +30,10 @@ export function App() { showCracker, crackerRunning, localLabel, - targetMessageId, setSettingsSection, setSidebarOpen, setCrackerRunning, setLocalLabel, - setTargetMessageId, handleCloseSettingsView, handleToggleSettingsView, handleOpenNewMessage, @@ -137,6 +136,24 @@ export function App() { // Wire up the ref bridge so useContactsAndChannels handlers reach the real setter setActiveConversationRef.current = setActiveConversation; + const { + targetMessageId, + setTargetMessageId, + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + handleOpenContactInfo, + handleCloseContactInfo, + handleOpenChannelInfo, + handleCloseChannelInfo, + handleSelectConversationWithTargetReset, + handleNavigateToChannel, + handleNavigateToMessage, + } = useConversationNavigation({ + channels, + handleSelectConversation, + }); + // Custom hooks for conversation-specific functionality const { messages, @@ -187,9 +204,6 @@ export function App() { updateMessageAck, }); const { - infoPaneContactKey, - infoPaneFromChannel, - infoPaneChannelKey, handleSendMessage, handleResendChannelMessage, handleSetChannelFloodScopeOverride, @@ -197,24 +211,14 @@ export function App() { handleTrace, handleBlockKey, handleBlockName, - handleOpenContactInfo, - handleCloseContactInfo, - handleOpenChannelInfo, - handleCloseChannelInfo, - handleSelectConversationWithTargetReset, - handleNavigateToChannel, - handleNavigateToMessage, } = useConversationActions({ activeConversation, activeConversationRef, - setTargetMessageId, - channels, setChannels, addMessageIfNew, jumpToBottom, handleToggleBlockedKey, handleToggleBlockedName, - handleSelectConversation, messageInputRef, }); diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index f2bc570..d6d1c94 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -8,3 +8,4 @@ export { useConversationRouter } from './useConversationRouter'; export { useContactsAndChannels } from './useContactsAndChannels'; export { useRealtimeAppState } from './useRealtimeAppState'; export { useConversationActions } from './useConversationActions'; +export { useConversationNavigation } from './useConversationNavigation'; diff --git a/frontend/src/hooks/useAppShell.ts b/frontend/src/hooks/useAppShell.ts index 030b3cd..8ab11c1 100644 --- a/frontend/src/hooks/useAppShell.ts +++ b/frontend/src/hooks/useAppShell.ts @@ -1,4 +1,4 @@ -import { startTransition, useCallback, useState, type Dispatch, type SetStateAction } from 'react'; +import { startTransition, useCallback, useState } from 'react'; import { getLocalLabel, type LocalLabel } from '../utils/localLabel'; import type { SettingsSection } from '../components/settings/settingsConstants'; @@ -11,12 +11,10 @@ interface UseAppShellResult { showCracker: boolean; crackerRunning: boolean; localLabel: LocalLabel; - targetMessageId: number | null; setSettingsSection: (section: SettingsSection) => void; setSidebarOpen: (open: boolean) => void; setCrackerRunning: (running: boolean) => void; setLocalLabel: (label: LocalLabel) => void; - setTargetMessageId: Dispatch>; handleCloseSettingsView: () => void; handleToggleSettingsView: () => void; handleOpenNewMessage: () => void; @@ -32,7 +30,6 @@ export function useAppShell(): UseAppShellResult { const [showCracker, setShowCracker] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false); const [localLabel, setLocalLabel] = useState(getLocalLabel); - const [targetMessageId, setTargetMessageId] = useState(null); const handleCloseSettingsView = useCallback(() => { startTransition(() => setShowSettings(false)); @@ -67,12 +64,10 @@ export function useAppShell(): UseAppShellResult { showCracker, crackerRunning, localLabel, - targetMessageId, setSettingsSection, setSidebarOpen, setCrackerRunning, setLocalLabel, - setTargetMessageId, handleCloseSettingsView, handleToggleSettingsView, handleOpenNewMessage, diff --git a/frontend/src/hooks/useConversationActions.ts b/frontend/src/hooks/useConversationActions.ts index 68cf6a7..0749b5a 100644 --- a/frontend/src/hooks/useConversationActions.ts +++ b/frontend/src/hooks/useConversationActions.ts @@ -1,36 +1,22 @@ -import { - useCallback, - useState, - type Dispatch, - type MutableRefObject, - type RefObject, - type SetStateAction, -} from 'react'; +import { useCallback, type MutableRefObject, type RefObject } from 'react'; import { api } from '../api'; import * as messageCache from '../messageCache'; import { toast } from '../components/ui/sonner'; import type { MessageInputHandle } from '../components/MessageInput'; -import type { SearchNavigateTarget } from '../components/SearchView'; import type { Channel, Conversation, Message } from '../types'; interface UseConversationActionsArgs { activeConversation: Conversation | null; activeConversationRef: MutableRefObject; - 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: ( @@ -41,35 +27,18 @@ interface UseConversationActionsResult { 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) => { @@ -175,68 +144,7 @@ export function useConversationActions({ [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, @@ -244,12 +152,5 @@ export function useConversationActions({ handleTrace, handleBlockKey, handleBlockName, - handleOpenContactInfo, - handleCloseContactInfo, - handleOpenChannelInfo, - handleCloseChannelInfo, - handleSelectConversationWithTargetReset, - handleNavigateToChannel, - handleNavigateToMessage, }; } diff --git a/frontend/src/hooks/useConversationNavigation.ts b/frontend/src/hooks/useConversationNavigation.ts new file mode 100644 index 0000000..9a19aa6 --- /dev/null +++ b/frontend/src/hooks/useConversationNavigation.ts @@ -0,0 +1,112 @@ +import { useCallback, useState, type Dispatch, type SetStateAction } from 'react'; + +import type { SearchNavigateTarget } from '../components/SearchView'; +import type { Channel, Conversation } from '../types'; + +interface UseConversationNavigationArgs { + channels: Channel[]; + handleSelectConversation: (conv: Conversation) => void; +} + +interface UseConversationNavigationResult { + targetMessageId: number | null; + setTargetMessageId: Dispatch>; + infoPaneContactKey: string | null; + infoPaneFromChannel: boolean; + infoPaneChannelKey: string | null; + 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 useConversationNavigation({ + channels, + handleSelectConversation, +}: UseConversationNavigationArgs): UseConversationNavigationResult { + const [targetMessageId, setTargetMessageId] = useState(null); + const [infoPaneContactKey, setInfoPaneContactKey] = useState(null); + const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false); + const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); + + 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((item) => item.key === channelKey); + if (!channel) { + return; + } + + 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] + ); + + return { + targetMessageId, + setTargetMessageId, + infoPaneContactKey, + infoPaneFromChannel, + infoPaneChannelKey, + handleOpenContactInfo, + handleCloseContactInfo, + handleOpenChannelInfo, + handleCloseChannelInfo, + handleSelectConversationWithTargetReset, + handleNavigateToChannel, + handleNavigateToMessage, + }; +} diff --git a/frontend/src/test/useAppShell.test.ts b/frontend/src/test/useAppShell.test.ts index 2cc1dca..2366e06 100644 --- a/frontend/src/test/useAppShell.test.ts +++ b/frontend/src/test/useAppShell.test.ts @@ -34,14 +34,15 @@ describe('useAppShell', () => { expect(result.current.showSettings).toBe(false); }); - it('supports React-style target message updates', () => { + it('toggles the cracker shell without affecting sidebar state', () => { const { result } = renderHook(() => useAppShell()); act(() => { - result.current.setTargetMessageId(10); - result.current.setTargetMessageId((prev) => (prev ?? 0) + 5); + result.current.setSidebarOpen(true); + result.current.handleToggleCracker(); }); - expect(result.current.targetMessageId).toBe(15); + expect(result.current.showCracker).toBe(true); + expect(result.current.sidebarOpen).toBe(true); }); }); diff --git a/frontend/src/test/useConversationActions.test.ts b/frontend/src/test/useConversationActions.test.ts index 5778166..a9fb4e6 100644 --- a/frontend/src/test/useConversationActions.test.ts +++ b/frontend/src/test/useConversationActions.test.ts @@ -65,14 +65,11 @@ function createArgs(overrides: Partial 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, }; @@ -123,47 +120,6 @@ describe('useConversationActions', () => { 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)); @@ -176,4 +132,15 @@ describe('useConversationActions', () => { expect(mocks.messageCache.clear).toHaveBeenCalledTimes(1); expect(args.jumpToBottom).toHaveBeenCalledTimes(1); }); + + it('appends sender mentions into the message input', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationActions(args)); + + act(() => { + result.current.handleSenderClick('Alice'); + }); + + expect(args.messageInputRef.current?.appendText).toHaveBeenCalledWith('@[Alice] '); + }); }); diff --git a/frontend/src/test/useConversationNavigation.test.ts b/frontend/src/test/useConversationNavigation.test.ts new file mode 100644 index 0000000..d2cb953 --- /dev/null +++ b/frontend/src/test/useConversationNavigation.test.ts @@ -0,0 +1,82 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { useConversationNavigation } from '../hooks/useConversationNavigation'; +import type { Channel } from '../types'; + +const publicChannel: Channel = { + key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72', + name: 'Public', + is_hashtag: false, + on_radio: false, + last_read_at: null, +}; + +function createArgs(overrides: Partial[0]> = {}) { + return { + channels: [publicChannel], + handleSelectConversation: vi.fn(), + ...overrides, + }; +} + +describe('useConversationNavigation', () => { + it('resets the jump target when switching to a non-search conversation', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationNavigation(args)); + + act(() => { + result.current.setTargetMessageId(10); + result.current.handleSelectConversationWithTargetReset({ + type: 'contact', + id: 'aa'.repeat(32), + name: 'Alice', + }); + }); + + expect(result.current.targetMessageId).toBeNull(); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'contact', + id: 'aa'.repeat(32), + name: 'Alice', + }); + }); + + it('preserves the jump target when navigating from search results', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationNavigation(args)); + + act(() => { + result.current.handleNavigateToMessage({ + id: 321, + type: 'CHAN', + conversation_key: publicChannel.key, + conversation_name: publicChannel.name, + }); + }); + + expect(result.current.targetMessageId).toBe(321); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + }); + + it('closes the contact info pane when navigating to a channel', () => { + const args = createArgs(); + const { result } = renderHook(() => useConversationNavigation(args)); + + act(() => { + result.current.handleOpenContactInfo('bb'.repeat(32), true); + result.current.handleNavigateToChannel(publicChannel.key); + }); + + expect(result.current.infoPaneContactKey).toBeNull(); + expect(args.handleSelectConversation).toHaveBeenCalledWith({ + type: 'channel', + id: publicChannel.key, + name: publicChannel.name, + }); + }); +});