mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-06 13:33:02 +02:00
extract conversation navigation state
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,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,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,
|
||||
};
|
||||
}
|
||||
|
||||
112
frontend/src/hooks/useConversationNavigation.ts
Normal file
112
frontend/src/hooks/useConversationNavigation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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] ');
|
||||
});
|
||||
});
|
||||
|
||||
82
frontend/src/test/useConversationNavigation.test.ts
Normal file
82
frontend/src/test/useConversationNavigation.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user