mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-11 11:13:04 +02:00
extract conversation timeline hook
This commit is contained in:
+6
-3
@@ -36,7 +36,8 @@ frontend/src/
|
|||||||
├── hooks/
|
├── hooks/
|
||||||
│ ├── index.ts # Central re-export of all hooks
|
│ ├── index.ts # Central re-export of all hooks
|
||||||
│ ├── useConversationActions.ts # Send/navigation/info-pane conversation actions
|
│ ├── 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
|
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
|
||||||
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
|
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
|
||||||
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
|
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
|
||||||
@@ -159,7 +160,8 @@ frontend/src/
|
|||||||
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
||||||
- `useConversationRouter`: URL hash → active conversation routing
|
- `useConversationRouter`: URL hash → active conversation routing
|
||||||
- `useConversationActions`: send/resend/trace/navigation handlers and info-pane state
|
- `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
|
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||||
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
||||||
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
- `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:
|
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.
|
- **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.
|
- **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.
|
- **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).
|
- **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).
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
import {
|
||||||
import { toast } from '../components/ui/sonner';
|
useCallback,
|
||||||
import { api, isAbortError } from '../api';
|
useRef,
|
||||||
import * as messageCache from '../messageCache';
|
type Dispatch,
|
||||||
|
type MutableRefObject,
|
||||||
|
type SetStateAction,
|
||||||
|
} from 'react';
|
||||||
|
import { useConversationTimeline } from './useConversationTimeline';
|
||||||
import type { Conversation, Message, MessagePath } from '../types';
|
import type { Conversation, Message, MessagePath } from '../types';
|
||||||
|
|
||||||
const MESSAGE_PAGE_SIZE = 200;
|
|
||||||
const MAX_PENDING_ACKS = 500;
|
const MAX_PENDING_ACKS = 500;
|
||||||
|
|
||||||
interface PendingAckUpdate {
|
interface PendingAckUpdate {
|
||||||
@@ -64,8 +67,8 @@ interface UseConversationMessagesResult {
|
|||||||
hasOlderMessages: boolean;
|
hasOlderMessages: boolean;
|
||||||
hasNewerMessages: boolean;
|
hasNewerMessages: boolean;
|
||||||
loadingNewer: boolean;
|
loadingNewer: boolean;
|
||||||
hasNewerMessagesRef: React.MutableRefObject<boolean>;
|
hasNewerMessagesRef: MutableRefObject<boolean>;
|
||||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||||
fetchOlderMessages: () => Promise<void>;
|
fetchOlderMessages: () => Promise<void>;
|
||||||
fetchNewerMessages: () => Promise<void>;
|
fetchNewerMessages: () => Promise<void>;
|
||||||
jumpToBottom: () => void;
|
jumpToBottom: () => void;
|
||||||
@@ -78,13 +81,6 @@ export function useConversationMessages(
|
|||||||
activeConversation: Conversation | null,
|
activeConversation: Conversation | null,
|
||||||
targetMessageId?: number | null
|
targetMessageId?: number | null
|
||||||
): UseConversationMessagesResult {
|
): UseConversationMessagesResult {
|
||||||
const [messages, setMessages] = useState<Message[]>([]);
|
|
||||||
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
|
// Track seen message content for deduplication
|
||||||
const seenMessageContent = useRef<Set<string>>(new Set());
|
const seenMessageContent = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
@@ -92,31 +88,6 @@ export function useConversationMessages(
|
|||||||
// Buffer latest ACK state by message_id and apply when the message arrives.
|
// Buffer latest ACK state by message_id and apply when the message arrives.
|
||||||
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
|
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
|
||||||
|
|
||||||
// AbortController for cancelling in-flight requests on conversation change
|
|
||||||
const abortControllerRef = useRef<AbortController | null>(null);
|
|
||||||
|
|
||||||
// Ref to track the conversation ID being fetched to prevent stale responses
|
|
||||||
const fetchingConversationIdRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
// --- Cache integration refs ---
|
|
||||||
// Keep refs in sync with state so we can read current values in the switch effect
|
|
||||||
const messagesRef = useRef<Message[]>([]);
|
|
||||||
const hasOlderMessagesRef = useRef(false);
|
|
||||||
const hasNewerMessagesRef = useRef(false);
|
|
||||||
const prevConversationIdRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
messagesRef.current = messages;
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
hasOlderMessagesRef.current = hasOlderMessages;
|
|
||||||
}, [hasOlderMessages]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
hasNewerMessagesRef.current = hasNewerMessages;
|
|
||||||
}, [hasNewerMessages]);
|
|
||||||
|
|
||||||
const setPendingAck = useCallback(
|
const setPendingAck = useCallback(
|
||||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||||
const existing = pendingAcksRef.current.get(messageId);
|
const existing = pendingAcksRef.current.get(messageId);
|
||||||
@@ -149,379 +120,27 @@ export function useConversationMessages(
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fetch messages for active conversation
|
const {
|
||||||
// Note: This is called manually and from the useEffect. The useEffect handles
|
messages,
|
||||||
// cancellation via AbortController; manual calls (e.g., after sending a message)
|
messagesRef,
|
||||||
// don't need cancellation.
|
messagesLoading,
|
||||||
const fetchMessages = useCallback(
|
loadingOlder,
|
||||||
async (showLoading = false, signal?: AbortSignal) => {
|
hasOlderMessages,
|
||||||
if (
|
hasNewerMessages,
|
||||||
!activeConversation ||
|
loadingNewer,
|
||||||
activeConversation.type === 'raw' ||
|
hasNewerMessagesRef,
|
||||||
activeConversation.type === 'map' ||
|
setMessages,
|
||||||
activeConversation.type === 'visualizer' ||
|
fetchOlderMessages,
|
||||||
activeConversation.type === 'search'
|
fetchNewerMessages,
|
||||||
) {
|
jumpToBottom,
|
||||||
setMessages([]);
|
triggerReconcile,
|
||||||
setHasOlderMessages(false);
|
} = useConversationTimeline({
|
||||||
return;
|
activeConversation,
|
||||||
}
|
targetMessageId,
|
||||||
|
applyPendingAck,
|
||||||
// Track which conversation we're fetching for
|
getMessageContentKey,
|
||||||
const conversationId = activeConversation.id;
|
seenMessageContentRef: seenMessageContent,
|
||||||
|
});
|
||||||
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]);
|
|
||||||
|
|
||||||
// Add a message if it's new (deduplication)
|
// Add a message if it's new (deduplication)
|
||||||
// Returns true if the message was added, false if it was a duplicate
|
// Returns true if the message was added, false if it was a duplicate
|
||||||
@@ -555,7 +174,7 @@ export function useConversationMessages(
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[applyPendingAck]
|
[applyPendingAck, messagesRef, setMessages]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update a message's ack count and paths
|
// Update a message's ack count and paths
|
||||||
@@ -592,7 +211,7 @@ export function useConversationMessages(
|
|||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setPendingAck]
|
[messagesRef, setMessages, setPendingAck]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -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<Set<string>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseConversationTimelineResult {
|
||||||
|
messages: Message[];
|
||||||
|
messagesRef: MutableRefObject<Message[]>;
|
||||||
|
messagesLoading: boolean;
|
||||||
|
loadingOlder: boolean;
|
||||||
|
hasOlderMessages: boolean;
|
||||||
|
hasNewerMessages: boolean;
|
||||||
|
loadingNewer: boolean;
|
||||||
|
hasNewerMessagesRef: MutableRefObject<boolean>;
|
||||||
|
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||||
|
fetchOlderMessages: () => Promise<void>;
|
||||||
|
fetchNewerMessages: () => Promise<void>;
|
||||||
|
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<Message[]>([]);
|
||||||
|
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<AbortController | null>(null);
|
||||||
|
const fetchingConversationIdRef = useRef<string | null>(null);
|
||||||
|
const messagesRef = useRef<Message[]>([]);
|
||||||
|
const hasOlderMessagesRef = useRef(false);
|
||||||
|
const hasNewerMessagesRef = useRef(false);
|
||||||
|
const prevConversationIdRef = useRef<string | null>(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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -392,4 +392,65 @@ describe('useConversationMessages forward pagination', () => {
|
|||||||
expect(result.current.messages).toHaveLength(1);
|
expect(result.current.messages).toHaveLength(1);
|
||||||
expect(result.current.messages[0].text).toBe('latest-msg');
|
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<typeof useConversationMessages>,
|
||||||
|
{ 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user