From ae0ef90fe2cbdbcddbf2aec8544ab331aabaf711 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 19:12:26 -0700 Subject: [PATCH] extract conversation timeline hook --- frontend/AGENTS.md | 9 +- frontend/src/hooks/useConversationMessages.ts | 447 ++---------------- frontend/src/hooks/useConversationTimeline.ts | 399 ++++++++++++++++ .../test/useConversationMessages.race.test.ts | 61 +++ 4 files changed, 499 insertions(+), 417 deletions(-) create mode 100644 frontend/src/hooks/useConversationTimeline.ts diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index fed4a71..6531178 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -36,7 +36,8 @@ frontend/src/ ├── hooks/ │ ├── index.ts # Central re-export of all hooks │ ├── useConversationActions.ts # Send/navigation/info-pane conversation actions -│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering +│ ├── 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 │ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery │ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries) @@ -159,7 +160,8 @@ frontend/src/ - `useContactsAndChannels`: contact/channel lists, creation, deletion - `useConversationRouter`: URL hash → active conversation routing - `useConversationActions`: send/resend/trace/navigation handlers and info-pane state -- `useConversationMessages`: fetch, pagination, dedup/update helpers +- `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 - `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination - `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions) @@ -319,8 +321,9 @@ 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 `App.tsx`, `useConversationActions`, 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**: `useConversationMessages` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation. +- **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. - **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling. - **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page). diff --git a/frontend/src/hooks/useConversationMessages.ts b/frontend/src/hooks/useConversationMessages.ts index f75980d..e85d146 100644 --- a/frontend/src/hooks/useConversationMessages.ts +++ b/frontend/src/hooks/useConversationMessages.ts @@ -1,10 +1,13 @@ -import { useState, useCallback, useEffect, useRef } from 'react'; -import { toast } from '../components/ui/sonner'; -import { api, isAbortError } from '../api'; -import * as messageCache from '../messageCache'; +import { + useCallback, + useRef, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; +import { useConversationTimeline } from './useConversationTimeline'; import type { Conversation, Message, MessagePath } from '../types'; -const MESSAGE_PAGE_SIZE = 200; const MAX_PENDING_ACKS = 500; interface PendingAckUpdate { @@ -64,8 +67,8 @@ interface UseConversationMessagesResult { hasOlderMessages: boolean; hasNewerMessages: boolean; loadingNewer: boolean; - hasNewerMessagesRef: React.MutableRefObject; - setMessages: React.Dispatch>; + hasNewerMessagesRef: MutableRefObject; + setMessages: Dispatch>; fetchOlderMessages: () => Promise; fetchNewerMessages: () => Promise; jumpToBottom: () => void; @@ -78,13 +81,6 @@ export function useConversationMessages( activeConversation: Conversation | null, targetMessageId?: number | null ): UseConversationMessagesResult { - const [messages, setMessages] = useState([]); - const [messagesLoading, setMessagesLoading] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [hasOlderMessages, setHasOlderMessages] = useState(false); - const [hasNewerMessages, setHasNewerMessages] = useState(false); - const [loadingNewer, setLoadingNewer] = useState(false); - // Track seen message content for deduplication const seenMessageContent = useRef>(new Set()); @@ -92,31 +88,6 @@ export function useConversationMessages( // Buffer latest ACK state by message_id and apply when the message arrives. const pendingAcksRef = useRef>(new Map()); - // AbortController for cancelling in-flight requests on conversation change - const abortControllerRef = useRef(null); - - // Ref to track the conversation ID being fetched to prevent stale responses - const fetchingConversationIdRef = useRef(null); - - // --- Cache integration refs --- - // Keep refs in sync with state so we can read current values in the switch effect - const messagesRef = useRef([]); - const hasOlderMessagesRef = useRef(false); - const hasNewerMessagesRef = useRef(false); - const prevConversationIdRef = useRef(null); - - useEffect(() => { - messagesRef.current = messages; - }, [messages]); - - useEffect(() => { - hasOlderMessagesRef.current = hasOlderMessages; - }, [hasOlderMessages]); - - useEffect(() => { - hasNewerMessagesRef.current = hasNewerMessages; - }, [hasNewerMessages]); - const setPendingAck = useCallback( (messageId: number, ackCount: number, paths?: MessagePath[]) => { const existing = pendingAcksRef.current.get(messageId); @@ -149,379 +120,27 @@ export function useConversationMessages( }; }, []); - // Fetch messages for active conversation - // Note: This is called manually and from the useEffect. The useEffect handles - // cancellation via AbortController; manual calls (e.g., after sending a message) - // don't need cancellation. - const fetchMessages = useCallback( - async (showLoading = false, signal?: AbortSignal) => { - if ( - !activeConversation || - activeConversation.type === 'raw' || - activeConversation.type === 'map' || - activeConversation.type === 'visualizer' || - activeConversation.type === 'search' - ) { - setMessages([]); - setHasOlderMessages(false); - return; - } - - // Track which conversation we're fetching for - const conversationId = activeConversation.id; - - if (showLoading) { - setMessagesLoading(true); - // Clear messages first so MessageList resets scroll state for new conversation - setMessages([]); - } - try { - const data = await api.getMessages( - { - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: activeConversation.id, - limit: MESSAGE_PAGE_SIZE, - }, - signal - ); - - // Check if this response is still for the current conversation - // This handles the race where the conversation changed while awaiting - if (fetchingConversationIdRef.current !== conversationId) { - // Stale response - conversation changed while we were fetching - return; - } - - const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg)); - setMessages(messagesWithPendingAck); - // Track seen content for new messages - seenMessageContent.current.clear(); - for (const msg of messagesWithPendingAck) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - // If we got a full page, there might be more - setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - // Don't show error toast for aborted requests (user switched conversations) - if (isAbortError(err)) { - return; - } - console.error('Failed to fetch messages:', err); - toast.error('Failed to load messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - if (showLoading) { - setMessagesLoading(false); - } - } - }, - [activeConversation, applyPendingAck] - ); - - // Fetch older messages (cursor-based pagination) - const fetchOlderMessages = useCallback(async () => { - if ( - !activeConversation || - activeConversation.type === 'raw' || - loadingOlder || - !hasOlderMessages - ) - return; - - const conversationId = activeConversation.id; - - // Get the true oldest message as cursor for the next page - const oldestMessage = messages.reduce( - (oldest, msg) => { - if (!oldest) return msg; - if (msg.received_at < oldest.received_at) return msg; - if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg; - return oldest; - }, - null as Message | null - ); - if (!oldestMessage) return; - - setLoadingOlder(true); - try { - const data = await api.getMessages({ - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - before: oldestMessage.received_at, - before_id: oldestMessage.id, - }); - - // Guard against stale response if the user switched conversations mid-request - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - - if (dataWithPendingAck.length > 0) { - // Prepend older messages (they come sorted DESC, so older are at the end) - setMessages((prev) => [...prev, ...dataWithPendingAck]); - // Track seen content - for (const msg of dataWithPendingAck) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - } - // If we got less than a full page, no more messages - setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - console.error('Failed to fetch older messages:', err); - toast.error('Failed to load older messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - setLoadingOlder(false); - } - }, [activeConversation, loadingOlder, hasOlderMessages, messages, applyPendingAck]); - - // Fetch newer messages (forward cursor pagination) - const fetchNewerMessages = useCallback(async () => { - if ( - !activeConversation || - activeConversation.type === 'raw' || - loadingNewer || - !hasNewerMessages - ) - return; - - const conversationId = activeConversation.id; - - // Get the newest message as forward cursor - const newestMessage = messages.reduce( - (newest, msg) => { - if (!newest) return msg; - if (msg.received_at > newest.received_at) return msg; - if (msg.received_at === newest.received_at && msg.id > newest.id) return msg; - return newest; - }, - null as Message | null - ); - if (!newestMessage) return; - - setLoadingNewer(true); - try { - const data = await api.getMessages({ - type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - after: newestMessage.received_at, - after_id: newestMessage.id, - }); - - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - - // Deduplicate against already-seen messages (WS race) - const newMessages = dataWithPendingAck.filter( - (msg) => !seenMessageContent.current.has(getMessageContentKey(msg)) - ); - if (newMessages.length > 0) { - setMessages((prev) => [...prev, ...newMessages]); - for (const msg of newMessages) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - } - setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); - } catch (err) { - console.error('Failed to fetch newer messages:', err); - toast.error('Failed to load newer messages', { - description: err instanceof Error ? err.message : 'Check your connection', - }); - } finally { - setLoadingNewer(false); - } - }, [activeConversation, loadingNewer, hasNewerMessages, messages, applyPendingAck]); - - // Jump to bottom: re-fetch latest page, clear hasNewerMessages - const jumpToBottom = useCallback(() => { - if (!activeConversation) return; - setHasNewerMessages(false); - // Invalidate cache so fetchMessages does a fresh load - messageCache.remove(activeConversation.id); - fetchMessages(true); - }, [activeConversation, fetchMessages]); - - // Trigger a background reconciliation for the current conversation. - // Used after WebSocket reconnects to silently recover any missed messages. - const triggerReconcile = useCallback(() => { - const conv = activeConversation; - if ( - !conv || - conv.type === 'raw' || - conv.type === 'map' || - conv.type === 'visualizer' || - conv.type === 'search' - ) - return; - const controller = new AbortController(); - reconcileFromBackend(conv, controller.signal); - }, [activeConversation]); // eslint-disable-line react-hooks/exhaustive-deps - - // Background reconciliation: silently fetch from backend after a cache restore - // and only update state if something differs (missed WS message, stale ack, etc.). - // No-ops on the happy path — zero rerenders when cache is already consistent. - function reconcileFromBackend(conversation: Conversation, signal: AbortSignal) { - const conversationId = conversation.id; - api - .getMessages( - { - type: conversation.type === 'channel' ? 'CHAN' : 'PRIV', - conversation_key: conversationId, - limit: MESSAGE_PAGE_SIZE, - }, - signal - ) - .then((data) => { - // Stale check — conversation may have changed while awaiting - if (fetchingConversationIdRef.current !== conversationId) return; - - const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); - const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); - if (!merged) return; // Cache was consistent — no rerender - - setMessages(merged); - seenMessageContent.current.clear(); - for (const msg of merged) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { - setHasOlderMessages(true); - } - }) - .catch((err) => { - if (isAbortError(err)) return; - // Silent failure — we already have cached data - console.debug('Background reconciliation failed:', err); - }); - } - - // Fetch messages when conversation changes, with proper cancellation and caching - useEffect(() => { - // Abort any previous in-flight request - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const prevId = prevConversationIdRef.current; - - // Track which conversation we're now on - const newId = activeConversation?.id ?? null; - const conversationChanged = prevId !== newId; - fetchingConversationIdRef.current = newId; - prevConversationIdRef.current = newId; - - // When targetMessageId goes from a value to null (onTargetReached cleared it) - // but the conversation hasn't changed, the around-loaded messages are already - // displayed — do nothing. Without this guard the effect would re-enter the - // normal fetch path and replace the mid-history view with the latest page. - if (!conversationChanged && !targetMessageId) { - return; - } - - // Reset loadingOlder/loadingNewer — the previous conversation's in-flight - // fetch is irrelevant now (its stale-check will discard the response). - setLoadingOlder(false); - setLoadingNewer(false); - if (conversationChanged) { - setHasNewerMessages(false); - } - - // Save outgoing conversation to cache only when actually leaving it, and - // only if we were on the latest page (mid-history views would restore stale - // partial data on switch-back). - if ( - conversationChanged && - prevId && - messagesRef.current.length > 0 && - !hasNewerMessagesRef.current - ) { - messageCache.set(prevId, { - messages: messagesRef.current, - seenContent: new Set(seenMessageContent.current), - hasOlderMessages: hasOlderMessagesRef.current, - }); - } - - // Clear state for non-message views - if ( - !activeConversation || - activeConversation.type === 'raw' || - activeConversation.type === 'map' || - activeConversation.type === 'visualizer' || - activeConversation.type === 'search' - ) { - setMessages([]); - setHasOlderMessages(false); - return; - } - - // Create AbortController for this conversation's fetch (cache reconcile or full fetch) - const controller = new AbortController(); - abortControllerRef.current = controller; - - // Jump-to-message: skip cache and load messages around the target - if (targetMessageId) { - setMessagesLoading(true); - setMessages([]); - const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV'; - api - .getMessagesAround( - targetMessageId, - msgType as 'PRIV' | 'CHAN', - activeConversation.id, - controller.signal - ) - .then((response) => { - if (fetchingConversationIdRef.current !== activeConversation.id) return; - const withAcks = response.messages.map((msg) => applyPendingAck(msg)); - setMessages(withAcks); - seenMessageContent.current.clear(); - for (const msg of withAcks) { - seenMessageContent.current.add(getMessageContentKey(msg)); - } - setHasOlderMessages(response.has_older); - setHasNewerMessages(response.has_newer); - }) - .catch((err) => { - if (isAbortError(err)) return; - console.error('Failed to fetch messages around target:', err); - toast.error('Failed to jump to message'); - }) - .finally(() => { - setMessagesLoading(false); - }); - } else { - // Check cache for the new conversation - const cached = messageCache.get(activeConversation.id); - if (cached) { - // Restore from cache instantly — no spinner - setMessages(cached.messages); - seenMessageContent.current = new Set(cached.seenContent); - setHasOlderMessages(cached.hasOlderMessages); - setMessagesLoading(false); - // Silently reconcile with backend in case we missed a WS message - reconcileFromBackend(activeConversation, controller.signal); - } else { - // Not cached — full fetch with spinner - fetchMessages(true, controller.signal); - } - } - - // Cleanup: abort request if conversation changes or component unmounts - return () => { - controller.abort(); - }; - // NOTE: Intentionally omitting fetchMessages and activeConversation from deps: - // - fetchMessages is recreated when activeConversation changes, which would cause infinite loops - // - activeConversation object identity changes on every render; we only care about id/type - // - We use fetchingConversationIdRef and AbortController to handle stale responses safely - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeConversation?.id, activeConversation?.type, targetMessageId]); + const { + messages, + messagesRef, + messagesLoading, + loadingOlder, + hasOlderMessages, + hasNewerMessages, + loadingNewer, + hasNewerMessagesRef, + setMessages, + fetchOlderMessages, + fetchNewerMessages, + jumpToBottom, + triggerReconcile, + } = useConversationTimeline({ + activeConversation, + targetMessageId, + applyPendingAck, + getMessageContentKey, + seenMessageContentRef: seenMessageContent, + }); // Add a message if it's new (deduplication) // Returns true if the message was added, false if it was a duplicate @@ -555,7 +174,7 @@ export function useConversationMessages( return true; }, - [applyPendingAck] + [applyPendingAck, messagesRef, setMessages] ); // Update a message's ack count and paths @@ -592,7 +211,7 @@ export function useConversationMessages( return prev; }); }, - [setPendingAck] + [messagesRef, setMessages, setPendingAck] ); return { diff --git a/frontend/src/hooks/useConversationTimeline.ts b/frontend/src/hooks/useConversationTimeline.ts new file mode 100644 index 0000000..a080338 --- /dev/null +++ b/frontend/src/hooks/useConversationTimeline.ts @@ -0,0 +1,399 @@ +import { + useState, + useCallback, + useEffect, + useRef, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from 'react'; +import { toast } from '../components/ui/sonner'; +import { api, isAbortError } from '../api'; +import * as messageCache from '../messageCache'; +import type { Conversation, Message } from '../types'; + +const MESSAGE_PAGE_SIZE = 200; + +interface UseConversationTimelineArgs { + activeConversation: Conversation | null; + targetMessageId?: number | null; + applyPendingAck: (msg: Message) => Message; + getMessageContentKey: (msg: Message) => string; + seenMessageContentRef: MutableRefObject>; +} + +interface UseConversationTimelineResult { + messages: Message[]; + messagesRef: MutableRefObject; + messagesLoading: boolean; + loadingOlder: boolean; + hasOlderMessages: boolean; + hasNewerMessages: boolean; + loadingNewer: boolean; + hasNewerMessagesRef: MutableRefObject; + setMessages: Dispatch>; + fetchOlderMessages: () => Promise; + fetchNewerMessages: () => Promise; + jumpToBottom: () => void; + triggerReconcile: () => void; +} + +function isMessageConversation(conversation: Conversation | null): conversation is Conversation { + return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type); +} + +export function useConversationTimeline({ + activeConversation, + targetMessageId, + applyPendingAck, + getMessageContentKey, + seenMessageContentRef, +}: UseConversationTimelineArgs): UseConversationTimelineResult { + const [messages, setMessages] = useState([]); + const [messagesLoading, setMessagesLoading] = useState(false); + const [loadingOlder, setLoadingOlder] = useState(false); + const [hasOlderMessages, setHasOlderMessages] = useState(false); + const [hasNewerMessages, setHasNewerMessages] = useState(false); + const [loadingNewer, setLoadingNewer] = useState(false); + + const abortControllerRef = useRef(null); + const fetchingConversationIdRef = useRef(null); + const messagesRef = useRef([]); + const hasOlderMessagesRef = useRef(false); + const hasNewerMessagesRef = useRef(false); + const prevConversationIdRef = useRef(null); + + useEffect(() => { + messagesRef.current = messages; + }, [messages]); + + useEffect(() => { + hasOlderMessagesRef.current = hasOlderMessages; + }, [hasOlderMessages]); + + useEffect(() => { + hasNewerMessagesRef.current = hasNewerMessages; + }, [hasNewerMessages]); + + const syncSeenContent = useCallback( + (nextMessages: Message[]) => { + seenMessageContentRef.current.clear(); + for (const msg of nextMessages) { + seenMessageContentRef.current.add(getMessageContentKey(msg)); + } + }, + [getMessageContentKey, seenMessageContentRef] + ); + + const fetchLatestMessages = useCallback( + async (showLoading = false, signal?: AbortSignal) => { + if (!isMessageConversation(activeConversation)) { + setMessages([]); + setHasOlderMessages(false); + return; + } + + const conversationId = activeConversation.id; + + if (showLoading) { + setMessagesLoading(true); + setMessages([]); + } + + try { + const data = await api.getMessages( + { + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: activeConversation.id, + limit: MESSAGE_PAGE_SIZE, + }, + signal + ); + + if (fetchingConversationIdRef.current !== conversationId) { + return; + } + + const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg)); + setMessages(messagesWithPendingAck); + syncSeenContent(messagesWithPendingAck); + setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + if (isAbortError(err)) { + return; + } + console.error('Failed to fetch messages:', err); + toast.error('Failed to load messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + if (showLoading) { + setMessagesLoading(false); + } + } + }, + [activeConversation, applyPendingAck, syncSeenContent] + ); + + const reconcileFromBackend = useCallback( + (conversation: Conversation, signal: AbortSignal) => { + const conversationId = conversation.id; + api + .getMessages( + { + type: conversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + }, + signal + ) + .then((data) => { + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck); + if (!merged) return; + + setMessages(merged); + syncSeenContent(merged); + if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) { + setHasOlderMessages(true); + } + }) + .catch((err) => { + if (isAbortError(err)) return; + console.debug('Background reconciliation failed:', err); + }); + }, + [applyPendingAck, syncSeenContent] + ); + + const fetchOlderMessages = useCallback(async () => { + if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return; + + const conversationId = activeConversation.id; + const oldestMessage = messages.reduce( + (oldest, msg) => { + if (!oldest) return msg; + if (msg.received_at < oldest.received_at) return msg; + if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg; + return oldest; + }, + null as Message | null + ); + if (!oldestMessage) return; + + setLoadingOlder(true); + try { + const data = await api.getMessages({ + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + before: oldestMessage.received_at, + before_id: oldestMessage.id, + }); + + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + + if (dataWithPendingAck.length > 0) { + setMessages((prev) => [...prev, ...dataWithPendingAck]); + for (const msg of dataWithPendingAck) { + seenMessageContentRef.current.add(getMessageContentKey(msg)); + } + } + setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + console.error('Failed to fetch older messages:', err); + toast.error('Failed to load older messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + setLoadingOlder(false); + } + }, [ + activeConversation, + applyPendingAck, + getMessageContentKey, + hasOlderMessages, + loadingOlder, + messages, + seenMessageContentRef, + ]); + + const fetchNewerMessages = useCallback(async () => { + if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return; + + const conversationId = activeConversation.id; + const newestMessage = messages.reduce( + (newest, msg) => { + if (!newest) return msg; + if (msg.received_at > newest.received_at) return msg; + if (msg.received_at === newest.received_at && msg.id > newest.id) return msg; + return newest; + }, + null as Message | null + ); + if (!newestMessage) return; + + setLoadingNewer(true); + try { + const data = await api.getMessages({ + type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV', + conversation_key: conversationId, + limit: MESSAGE_PAGE_SIZE, + after: newestMessage.received_at, + after_id: newestMessage.id, + }); + + if (fetchingConversationIdRef.current !== conversationId) return; + + const dataWithPendingAck = data.map((msg) => applyPendingAck(msg)); + const newMessages = dataWithPendingAck.filter( + (msg) => !seenMessageContentRef.current.has(getMessageContentKey(msg)) + ); + + if (newMessages.length > 0) { + setMessages((prev) => [...prev, ...newMessages]); + for (const msg of newMessages) { + seenMessageContentRef.current.add(getMessageContentKey(msg)); + } + } + setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE); + } catch (err) { + console.error('Failed to fetch newer messages:', err); + toast.error('Failed to load newer messages', { + description: err instanceof Error ? err.message : 'Check your connection', + }); + } finally { + setLoadingNewer(false); + } + }, [ + activeConversation, + applyPendingAck, + getMessageContentKey, + hasNewerMessages, + loadingNewer, + messages, + seenMessageContentRef, + ]); + + const jumpToBottom = useCallback(() => { + if (!activeConversation) return; + setHasNewerMessages(false); + messageCache.remove(activeConversation.id); + fetchLatestMessages(true); + }, [activeConversation, fetchLatestMessages]); + + const triggerReconcile = useCallback(() => { + if (!isMessageConversation(activeConversation)) return; + const controller = new AbortController(); + reconcileFromBackend(activeConversation, controller.signal); + }, [activeConversation, reconcileFromBackend]); + + useEffect(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + const prevId = prevConversationIdRef.current; + const newId = activeConversation?.id ?? null; + const conversationChanged = prevId !== newId; + fetchingConversationIdRef.current = newId; + prevConversationIdRef.current = newId; + + if (!conversationChanged && !targetMessageId) { + return; + } + + setLoadingOlder(false); + setLoadingNewer(false); + if (conversationChanged) { + setHasNewerMessages(false); + } + + if ( + conversationChanged && + prevId && + messagesRef.current.length > 0 && + !hasNewerMessagesRef.current + ) { + messageCache.set(prevId, { + messages: messagesRef.current, + seenContent: new Set(seenMessageContentRef.current), + hasOlderMessages: hasOlderMessagesRef.current, + }); + } + + if (!isMessageConversation(activeConversation)) { + setMessages([]); + setHasOlderMessages(false); + return; + } + + const controller = new AbortController(); + abortControllerRef.current = controller; + + if (targetMessageId) { + setMessagesLoading(true); + setMessages([]); + const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV'; + api + .getMessagesAround( + targetMessageId, + msgType as 'PRIV' | 'CHAN', + activeConversation.id, + controller.signal + ) + .then((response) => { + if (fetchingConversationIdRef.current !== activeConversation.id) return; + const withAcks = response.messages.map((msg) => applyPendingAck(msg)); + setMessages(withAcks); + syncSeenContent(withAcks); + setHasOlderMessages(response.has_older); + setHasNewerMessages(response.has_newer); + }) + .catch((err) => { + if (isAbortError(err)) return; + console.error('Failed to fetch messages around target:', err); + toast.error('Failed to jump to message'); + }) + .finally(() => { + setMessagesLoading(false); + }); + } else { + const cached = messageCache.get(activeConversation.id); + if (cached) { + setMessages(cached.messages); + seenMessageContentRef.current = new Set(cached.seenContent); + setHasOlderMessages(cached.hasOlderMessages); + setMessagesLoading(false); + reconcileFromBackend(activeConversation, controller.signal); + } else { + fetchLatestMessages(true, controller.signal); + } + } + + return () => { + controller.abort(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeConversation?.id, activeConversation?.type, targetMessageId]); + + return { + messages, + messagesRef, + messagesLoading, + loadingOlder, + hasOlderMessages, + hasNewerMessages, + loadingNewer, + hasNewerMessagesRef, + setMessages, + fetchOlderMessages, + fetchNewerMessages, + jumpToBottom, + triggerReconcile, + }; +} diff --git a/frontend/src/test/useConversationMessages.race.test.ts b/frontend/src/test/useConversationMessages.race.test.ts index 5afa4a0..e56399a 100644 --- a/frontend/src/test/useConversationMessages.race.test.ts +++ b/frontend/src/test/useConversationMessages.race.test.ts @@ -392,4 +392,65 @@ describe('useConversationMessages forward pagination', () => { expect(result.current.messages).toHaveLength(1); expect(result.current.messages[0].text).toBe('latest-msg'); }); + + it('preserves around-loaded messages when the jump target is cleared in the same conversation', async () => { + const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' }; + + const aroundMessages = [ + createMessage({ + id: 4, + conversation_key: 'ch1', + text: 'older-context', + sender_timestamp: 1700000004, + received_at: 1700000004, + }), + createMessage({ + id: 5, + conversation_key: 'ch1', + text: 'target-message', + sender_timestamp: 1700000005, + received_at: 1700000005, + }), + createMessage({ + id: 6, + conversation_key: 'ch1', + text: 'newer-context', + sender_timestamp: 1700000006, + received_at: 1700000006, + }), + ]; + + mockGetMessagesAround.mockResolvedValueOnce({ + messages: aroundMessages, + has_older: true, + has_newer: true, + }); + + const { result, rerender } = renderHook< + ReturnType, + { conv: Conversation; target: number | null } + >(({ conv, target }) => useConversationMessages(conv, target), { + initialProps: { conv, target: 5 }, + }); + + await waitFor(() => expect(result.current.messagesLoading).toBe(false)); + expect(result.current.messages.map((message) => message.text)).toEqual([ + 'older-context', + 'target-message', + 'newer-context', + ]); + expect(mockGetMessages).not.toHaveBeenCalled(); + + rerender({ conv, target: null }); + + await waitFor(() => + expect(result.current.messages.map((message) => message.text)).toEqual([ + 'older-context', + 'target-message', + 'newer-context', + ]) + ); + expect(mockGetMessages).not.toHaveBeenCalled(); + expect(result.current.hasNewerMessages).toBe(true); + }); });