extract conversation navigation state

This commit is contained in:
Jack Kingsman
2026-03-09 20:59:52 -07:00
parent f107dce920
commit 319b84455b
9 changed files with 241 additions and 175 deletions

View File

@@ -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.

View File

@@ -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,
});

View File

@@ -8,3 +8,4 @@ export { useConversationRouter } from './useConversationRouter';
export { useContactsAndChannels } from './useContactsAndChannels';
export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';

View File

@@ -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<SetStateAction<number | null>>;
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<number | null>(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,

View File

@@ -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<Conversation | null>;
setTargetMessageId: Dispatch<SetStateAction<number | null>>;
channels: Channel[];
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
addMessageIfNew: (msg: Message) => boolean;
jumpToBottom: () => void;
handleToggleBlockedKey: (key: string) => Promise<void>;
handleToggleBlockedName: (name: string) => Promise<void>;
handleSelectConversation: (conv: Conversation) => void;
messageInputRef: RefObject<MessageInputHandle | null>;
}
interface UseConversationActionsResult {
infoPaneContactKey: string | null;
infoPaneFromChannel: boolean;
infoPaneChannelKey: string | null;
handleSendMessage: (text: string) => Promise<void>;
handleResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
handleSetChannelFloodScopeOverride: (
@@ -41,35 +27,18 @@ interface UseConversationActionsResult {
handleTrace: () => Promise<void>;
handleBlockKey: (key: string) => Promise<void>;
handleBlockName: (name: string) => Promise<void>;
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<string | null>(null);
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(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,
};
}

View File

@@ -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<SetStateAction<number | null>>;
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<number | null>(null);
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(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,
};
}

View File

@@ -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);
});
});

View File

@@ -65,14 +65,11 @@ function createArgs(overrides: Partial<Parameters<typeof useConversationActions>
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] ');
});
});

View File

@@ -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<Parameters<typeof useConversationNavigation>[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,
});
});
});