mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 18:01:22 +02:00
extract frontend conversation actions hook
This commit is contained in:
+34
-180
@@ -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<MessageInputHandle>(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<string | null>(null);
|
||||
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
|
||||
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
|
||||
const [targetMessageId, setTargetMessageId] = useState<number | null>(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 = (
|
||||
<Sidebar
|
||||
|
||||
@@ -6,3 +6,4 @@ export { useAppSettings } from './useAppSettings';
|
||||
export { useConversationRouter } from './useConversationRouter';
|
||||
export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import {
|
||||
useCallback,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type RefObject,
|
||||
type SetStateAction,
|
||||
} 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: (
|
||||
channelKey: string,
|
||||
floodScopeOverride: string
|
||||
) => Promise<void>;
|
||||
handleSenderClick: (sender: string) => void;
|
||||
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) => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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<Parameters<typeof useConversationActions>[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<Message>((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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user