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
+19 -15
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,
});
+1
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';
+1 -6
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,
+1 -100
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,
};
}
@@ -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,
};
}
+5 -4
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);
});
});
@@ -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] ');
});
});
@@ -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,
});
});
});