extract frontend conversation actions hook

This commit is contained in:
Jack Kingsman
2026-03-09 18:37:06 -07:00
parent 5d509a88d9
commit 56e5e0d278
5 changed files with 474 additions and 183 deletions
+34 -180
View File
@@ -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
+1
View File
@@ -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);
});
});