mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Finish frontend phase 3
This commit is contained in:
@@ -1,12 +1,174 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { toast } from '../components/ui/sonner';
|
import { toast } from '../components/ui/sonner';
|
||||||
import { api, isAbortError } from '../api';
|
import { api, isAbortError } from '../api';
|
||||||
import * as messageCache from '../messageCache';
|
|
||||||
import type { Conversation, Message, MessagePath } from '../types';
|
import type { Conversation, Message, MessagePath } from '../types';
|
||||||
import { getMessageContentKey } from '../utils/messageIdentity';
|
import { getMessageContentKey } from '../utils/messageIdentity';
|
||||||
|
|
||||||
const MAX_PENDING_ACKS = 500;
|
const MAX_PENDING_ACKS = 500;
|
||||||
const MESSAGE_PAGE_SIZE = 200;
|
const MESSAGE_PAGE_SIZE = 200;
|
||||||
|
export const MAX_CACHED_CONVERSATIONS = 20;
|
||||||
|
export const MAX_MESSAGES_PER_ENTRY = 200;
|
||||||
|
|
||||||
|
interface CachedConversationEntry {
|
||||||
|
messages: Message[];
|
||||||
|
hasOlderMessages: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalCachedConversationEntry extends CachedConversationEntry {
|
||||||
|
contentKeys: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ConversationMessageCache {
|
||||||
|
private readonly cache = new Map<string, InternalCachedConversationEntry>();
|
||||||
|
|
||||||
|
get(id: string): CachedConversationEntry | undefined {
|
||||||
|
const entry = this.cache.get(id);
|
||||||
|
if (!entry) return undefined;
|
||||||
|
this.cache.delete(id);
|
||||||
|
this.cache.set(id, entry);
|
||||||
|
return {
|
||||||
|
messages: entry.messages,
|
||||||
|
hasOlderMessages: entry.hasOlderMessages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
set(id: string, entry: CachedConversationEntry): void {
|
||||||
|
const contentKeys = new Set(entry.messages.map((message) => getMessageContentKey(message)));
|
||||||
|
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
|
||||||
|
const trimmed = [...entry.messages]
|
||||||
|
.sort((a, b) => b.received_at - a.received_at)
|
||||||
|
.slice(0, MAX_MESSAGES_PER_ENTRY);
|
||||||
|
entry = { ...entry, messages: trimmed, hasOlderMessages: true };
|
||||||
|
}
|
||||||
|
const internalEntry: InternalCachedConversationEntry = {
|
||||||
|
...entry,
|
||||||
|
contentKeys,
|
||||||
|
};
|
||||||
|
this.cache.delete(id);
|
||||||
|
this.cache.set(id, internalEntry);
|
||||||
|
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
|
||||||
|
const lruKey = this.cache.keys().next().value as string;
|
||||||
|
this.cache.delete(lruKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessage(id: string, msg: Message): boolean {
|
||||||
|
const entry = this.cache.get(id);
|
||||||
|
const contentKey = getMessageContentKey(msg);
|
||||||
|
if (!entry) {
|
||||||
|
this.cache.set(id, {
|
||||||
|
messages: [msg],
|
||||||
|
hasOlderMessages: true,
|
||||||
|
contentKeys: new Set([contentKey]),
|
||||||
|
});
|
||||||
|
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
|
||||||
|
const lruKey = this.cache.keys().next().value as string;
|
||||||
|
this.cache.delete(lruKey);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (entry.contentKeys.has(contentKey)) return false;
|
||||||
|
if (entry.messages.some((message) => message.id === msg.id)) return false;
|
||||||
|
entry.contentKeys.add(contentKey);
|
||||||
|
entry.messages = [...entry.messages, msg];
|
||||||
|
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
|
||||||
|
entry.messages = [...entry.messages]
|
||||||
|
.sort((a, b) => b.received_at - a.received_at)
|
||||||
|
.slice(0, MAX_MESSAGES_PER_ENTRY);
|
||||||
|
}
|
||||||
|
this.cache.delete(id);
|
||||||
|
this.cache.set(id, entry);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
|
||||||
|
for (const entry of this.cache.values()) {
|
||||||
|
const index = entry.messages.findIndex((message) => message.id === messageId);
|
||||||
|
if (index < 0) continue;
|
||||||
|
const current = entry.messages[index];
|
||||||
|
const updated = [...entry.messages];
|
||||||
|
updated[index] = {
|
||||||
|
...current,
|
||||||
|
acked: Math.max(current.acked, ackCount),
|
||||||
|
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
|
||||||
|
};
|
||||||
|
entry.messages = updated;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(id: string): void {
|
||||||
|
this.cache.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
rename(oldId: string, newId: string): void {
|
||||||
|
if (oldId === newId) return;
|
||||||
|
const oldEntry = this.cache.get(oldId);
|
||||||
|
if (!oldEntry) return;
|
||||||
|
|
||||||
|
const newEntry = this.cache.get(newId);
|
||||||
|
if (!newEntry) {
|
||||||
|
this.cache.delete(oldId);
|
||||||
|
this.cache.set(newId, oldEntry);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedMessages = [...newEntry.messages];
|
||||||
|
const seenIds = new Set(mergedMessages.map((message) => message.id));
|
||||||
|
for (const message of oldEntry.messages) {
|
||||||
|
if (!seenIds.has(message.id)) {
|
||||||
|
mergedMessages.push(message);
|
||||||
|
seenIds.add(message.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.delete(oldId);
|
||||||
|
this.cache.set(newId, {
|
||||||
|
messages: mergedMessages,
|
||||||
|
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
|
||||||
|
contentKeys: new Set([...newEntry.contentKeys, ...oldEntry.contentKeys]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.cache.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reconcileConversationMessages(
|
||||||
|
current: Message[],
|
||||||
|
fetched: Message[]
|
||||||
|
): Message[] | null {
|
||||||
|
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
|
||||||
|
for (const message of current) {
|
||||||
|
currentById.set(message.id, {
|
||||||
|
acked: message.acked,
|
||||||
|
pathsLen: message.paths?.length ?? 0,
|
||||||
|
text: message.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let needsUpdate = false;
|
||||||
|
for (const message of fetched) {
|
||||||
|
const currentMessage = currentById.get(message.id);
|
||||||
|
if (
|
||||||
|
!currentMessage ||
|
||||||
|
currentMessage.acked !== message.acked ||
|
||||||
|
currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
|
||||||
|
currentMessage.text !== message.text
|
||||||
|
) {
|
||||||
|
needsUpdate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!needsUpdate) return null;
|
||||||
|
|
||||||
|
const fetchedIds = new Set(fetched.map((message) => message.id));
|
||||||
|
const olderMessages = current.filter((message) => !fetchedIds.has(message.id));
|
||||||
|
return [...fetched, ...olderMessages];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conversationMessageCache = new ConversationMessageCache();
|
||||||
|
|
||||||
interface PendingAckUpdate {
|
interface PendingAckUpdate {
|
||||||
ackCount: number;
|
ackCount: number;
|
||||||
@@ -167,6 +329,7 @@ export function useConversationMessages(
|
|||||||
const pendingReconnectReconcileRef = useRef(false);
|
const pendingReconnectReconcileRef = useRef(false);
|
||||||
const messagesRef = useRef<Message[]>([]);
|
const messagesRef = useRef<Message[]>([]);
|
||||||
const loadingOlderRef = useRef(false);
|
const loadingOlderRef = useRef(false);
|
||||||
|
const loadingNewerRef = useRef(false);
|
||||||
const hasOlderMessagesRef = useRef(false);
|
const hasOlderMessagesRef = useRef(false);
|
||||||
const hasNewerMessagesRef = useRef(false);
|
const hasNewerMessagesRef = useRef(false);
|
||||||
const prevConversationIdRef = useRef<string | null>(null);
|
const prevConversationIdRef = useRef<string | null>(null);
|
||||||
@@ -181,6 +344,10 @@ export function useConversationMessages(
|
|||||||
loadingOlderRef.current = loadingOlder;
|
loadingOlderRef.current = loadingOlder;
|
||||||
}, [loadingOlder]);
|
}, [loadingOlder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadingNewerRef.current = loadingNewer;
|
||||||
|
}, [loadingNewer]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
hasOlderMessagesRef.current = hasOlderMessages;
|
hasOlderMessagesRef.current = hasOlderMessages;
|
||||||
}, [hasOlderMessages]);
|
}, [hasOlderMessages]);
|
||||||
@@ -230,7 +397,7 @@ export function useConversationMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||||
const merged = messageCache.reconcile(messagesRef.current, messagesWithPendingAck);
|
const merged = reconcileConversationMessages(messagesRef.current, messagesWithPendingAck);
|
||||||
const nextMessages = merged ?? messagesRef.current;
|
const nextMessages = merged ?? messagesRef.current;
|
||||||
if (merged) {
|
if (merged) {
|
||||||
setMessages(merged);
|
setMessages(merged);
|
||||||
@@ -272,7 +439,7 @@ export function useConversationMessages(
|
|||||||
|
|
||||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||||
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||||
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
|
const merged = reconcileConversationMessages(messagesRef.current, dataWithPendingAck);
|
||||||
if (!merged) return;
|
if (!merged) return;
|
||||||
|
|
||||||
setMessages(merged);
|
setMessages(merged);
|
||||||
@@ -296,7 +463,7 @@ export function useConversationMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const conversationId = activeConversation.id;
|
const conversationId = activeConversation.id;
|
||||||
const oldestMessage = messages.reduce(
|
const oldestMessage = messagesRef.current.reduce(
|
||||||
(oldest, msg) => {
|
(oldest, msg) => {
|
||||||
if (!oldest) return msg;
|
if (!oldest) return msg;
|
||||||
if (msg.received_at < oldest.received_at) return msg;
|
if (msg.received_at < oldest.received_at) return msg;
|
||||||
@@ -357,13 +524,19 @@ export function useConversationMessages(
|
|||||||
loadingOlderRef.current = false;
|
loadingOlderRef.current = false;
|
||||||
setLoadingOlder(false);
|
setLoadingOlder(false);
|
||||||
}
|
}
|
||||||
}, [activeConversation, applyPendingAck, messages, syncSeenContent]);
|
}, [activeConversation, applyPendingAck, syncSeenContent]);
|
||||||
|
|
||||||
const fetchNewerMessages = useCallback(async () => {
|
const fetchNewerMessages = useCallback(async () => {
|
||||||
if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return;
|
if (
|
||||||
|
!isMessageConversation(activeConversation) ||
|
||||||
|
loadingNewerRef.current ||
|
||||||
|
!hasNewerMessagesRef.current
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const conversationId = activeConversation.id;
|
const conversationId = activeConversation.id;
|
||||||
const newestMessage = messages.reduce(
|
const newestMessage = messagesRef.current.reduce(
|
||||||
(newest, msg) => {
|
(newest, msg) => {
|
||||||
if (!newest) return msg;
|
if (!newest) return msg;
|
||||||
if (msg.received_at > newest.received_at) return msg;
|
if (msg.received_at > newest.received_at) return msg;
|
||||||
@@ -374,6 +547,7 @@ export function useConversationMessages(
|
|||||||
);
|
);
|
||||||
if (!newestMessage) return;
|
if (!newestMessage) return;
|
||||||
|
|
||||||
|
loadingNewerRef.current = true;
|
||||||
setLoadingNewer(true);
|
setLoadingNewer(true);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
newerAbortControllerRef.current = controller;
|
newerAbortControllerRef.current = controller;
|
||||||
@@ -423,28 +597,22 @@ export function useConversationMessages(
|
|||||||
if (newerAbortControllerRef.current === controller) {
|
if (newerAbortControllerRef.current === controller) {
|
||||||
newerAbortControllerRef.current = null;
|
newerAbortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
|
loadingNewerRef.current = false;
|
||||||
setLoadingNewer(false);
|
setLoadingNewer(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [activeConversation, applyPendingAck, reconcileFromBackend]);
|
||||||
activeConversation,
|
|
||||||
applyPendingAck,
|
|
||||||
hasNewerMessages,
|
|
||||||
loadingNewer,
|
|
||||||
messages,
|
|
||||||
reconcileFromBackend,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const jumpToBottom = useCallback(() => {
|
const jumpToBottom = useCallback(() => {
|
||||||
if (!activeConversation) return;
|
if (!activeConversation) return;
|
||||||
setHasNewerMessages(false);
|
setHasNewerMessages(false);
|
||||||
messageCache.remove(activeConversation.id);
|
conversationMessageCache.remove(activeConversation.id);
|
||||||
void fetchLatestMessages(true);
|
void fetchLatestMessages(true);
|
||||||
}, [activeConversation, fetchLatestMessages]);
|
}, [activeConversation, fetchLatestMessages]);
|
||||||
|
|
||||||
const reloadCurrentConversation = useCallback(() => {
|
const reloadCurrentConversation = useCallback(() => {
|
||||||
if (!isMessageConversation(activeConversation)) return;
|
if (!isMessageConversation(activeConversation)) return;
|
||||||
setHasNewerMessages(false);
|
setHasNewerMessages(false);
|
||||||
messageCache.remove(activeConversation.id);
|
conversationMessageCache.remove(activeConversation.id);
|
||||||
setReloadVersion((current) => current + 1);
|
setReloadVersion((current) => current + 1);
|
||||||
}, [activeConversation]);
|
}, [activeConversation]);
|
||||||
|
|
||||||
@@ -506,7 +674,7 @@ export function useConversationMessages(
|
|||||||
messagesRef.current.length > 0 &&
|
messagesRef.current.length > 0 &&
|
||||||
!hasNewerMessagesRef.current
|
!hasNewerMessagesRef.current
|
||||||
) {
|
) {
|
||||||
messageCache.set(prevId, {
|
conversationMessageCache.set(prevId, {
|
||||||
messages: messagesRef.current,
|
messages: messagesRef.current,
|
||||||
hasOlderMessages: hasOlderMessagesRef.current,
|
hasOlderMessages: hasOlderMessagesRef.current,
|
||||||
});
|
});
|
||||||
@@ -549,7 +717,7 @@ export function useConversationMessages(
|
|||||||
setMessagesLoading(false);
|
setMessagesLoading(false);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const cached = messageCache.get(activeConversation.id);
|
const cached = conversationMessageCache.get(activeConversation.id);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setMessages(cached.messages);
|
setMessages(cached.messages);
|
||||||
seenMessageContent.current = new Set(
|
seenMessageContent.current = new Set(
|
||||||
@@ -645,7 +813,7 @@ export function useConversationMessages(
|
|||||||
const receiveMessageAck = useCallback(
|
const receiveMessageAck = useCallback(
|
||||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||||
updateMessageAck(messageId, ackCount, paths);
|
updateMessageAck(messageId, ackCount, paths);
|
||||||
messageCache.updateAck(messageId, ackCount, paths);
|
conversationMessageCache.updateAck(messageId, ackCount, paths);
|
||||||
},
|
},
|
||||||
[updateMessageAck]
|
[updateMessageAck]
|
||||||
);
|
);
|
||||||
@@ -670,7 +838,10 @@ export function useConversationMessages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
added: messageCache.addMessage(msgWithPendingAck.conversation_key, msgWithPendingAck),
|
added: conversationMessageCache.addMessage(
|
||||||
|
msgWithPendingAck.conversation_key,
|
||||||
|
msgWithPendingAck
|
||||||
|
),
|
||||||
activeConversation: false,
|
activeConversation: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -678,15 +849,15 @@ export function useConversationMessages(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renameConversationMessages = useCallback((oldId: string, newId: string) => {
|
const renameConversationMessages = useCallback((oldId: string, newId: string) => {
|
||||||
messageCache.rename(oldId, newId);
|
conversationMessageCache.rename(oldId, newId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const removeConversationMessages = useCallback((conversationId: string) => {
|
const removeConversationMessages = useCallback((conversationId: string) => {
|
||||||
messageCache.remove(conversationId);
|
conversationMessageCache.remove(conversationId);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clearConversationMessages = useCallback(() => {
|
const clearConversationMessages = useCallback(() => {
|
||||||
messageCache.clear();
|
conversationMessageCache.clear();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
/**
|
|
||||||
* LRU message cache for recently-visited conversations.
|
|
||||||
*
|
|
||||||
* Uses Map insertion-order semantics: the most recently used entry
|
|
||||||
* is always at the end. Eviction removes the first (least-recently-used) entry.
|
|
||||||
*
|
|
||||||
* Cache size: 20 conversations, 200 messages each (~2.4MB worst case).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Message, MessagePath } from './types';
|
|
||||||
import { getMessageContentKey } from './utils/messageIdentity';
|
|
||||||
|
|
||||||
export const MAX_CACHED_CONVERSATIONS = 20;
|
|
||||||
export const MAX_MESSAGES_PER_ENTRY = 200;
|
|
||||||
|
|
||||||
interface CacheEntry {
|
|
||||||
messages: Message[];
|
|
||||||
hasOlderMessages: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InternalCacheEntry extends CacheEntry {
|
|
||||||
contentKeys: Set<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = new Map<string, InternalCacheEntry>();
|
|
||||||
|
|
||||||
/** Get a cached entry and promote it to most-recently-used. */
|
|
||||||
export function get(id: string): CacheEntry | undefined {
|
|
||||||
const entry = cache.get(id);
|
|
||||||
if (!entry) return undefined;
|
|
||||||
// Promote to MRU: delete and re-insert
|
|
||||||
cache.delete(id);
|
|
||||||
cache.set(id, entry);
|
|
||||||
return {
|
|
||||||
messages: entry.messages,
|
|
||||||
hasOlderMessages: entry.hasOlderMessages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Insert or update an entry at MRU position, evicting LRU if over capacity. */
|
|
||||||
export function set(id: string, entry: CacheEntry): void {
|
|
||||||
const contentKeys = new Set(entry.messages.map((message) => getMessageContentKey(message)));
|
|
||||||
// Trim to most recent messages to bound memory
|
|
||||||
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
|
|
||||||
const trimmed = [...entry.messages]
|
|
||||||
.sort((a, b) => b.received_at - a.received_at)
|
|
||||||
.slice(0, MAX_MESSAGES_PER_ENTRY);
|
|
||||||
entry = { ...entry, messages: trimmed, hasOlderMessages: true };
|
|
||||||
}
|
|
||||||
const internalEntry: InternalCacheEntry = {
|
|
||||||
...entry,
|
|
||||||
contentKeys,
|
|
||||||
};
|
|
||||||
// Remove first so re-insert moves to end
|
|
||||||
cache.delete(id);
|
|
||||||
cache.set(id, internalEntry);
|
|
||||||
// Evict LRU (first entry) if over capacity
|
|
||||||
if (cache.size > MAX_CACHED_CONVERSATIONS) {
|
|
||||||
const lruKey = cache.keys().next().value as string;
|
|
||||||
cache.delete(lruKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add a message to a cached conversation with dedup. Returns true if new, false if duplicate. */
|
|
||||||
export function addMessage(id: string, msg: Message): boolean {
|
|
||||||
const entry = cache.get(id);
|
|
||||||
const contentKey = getMessageContentKey(msg);
|
|
||||||
if (!entry) {
|
|
||||||
// Auto-create a minimal entry for never-visited conversations
|
|
||||||
cache.set(id, {
|
|
||||||
messages: [msg],
|
|
||||||
hasOlderMessages: true,
|
|
||||||
contentKeys: new Set([contentKey]),
|
|
||||||
});
|
|
||||||
// Evict LRU if over capacity
|
|
||||||
if (cache.size > MAX_CACHED_CONVERSATIONS) {
|
|
||||||
const lruKey = cache.keys().next().value as string;
|
|
||||||
cache.delete(lruKey);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (entry.contentKeys.has(contentKey)) return false;
|
|
||||||
if (entry.messages.some((m) => m.id === msg.id)) return false;
|
|
||||||
entry.contentKeys.add(contentKey);
|
|
||||||
entry.messages = [...entry.messages, msg];
|
|
||||||
// Trim if over limit (drop oldest by received_at)
|
|
||||||
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
|
|
||||||
entry.messages = [...entry.messages]
|
|
||||||
.sort((a, b) => b.received_at - a.received_at)
|
|
||||||
.slice(0, MAX_MESSAGES_PER_ENTRY);
|
|
||||||
}
|
|
||||||
// Promote to MRU so actively-messaged conversations aren't evicted
|
|
||||||
cache.delete(id);
|
|
||||||
cache.set(id, entry);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scan all cached entries for a message ID and update its ack/paths. */
|
|
||||||
export function updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
|
|
||||||
for (const entry of cache.values()) {
|
|
||||||
const idx = entry.messages.findIndex((m) => m.id === messageId);
|
|
||||||
if (idx >= 0) {
|
|
||||||
const current = entry.messages[idx];
|
|
||||||
const updated = [...entry.messages];
|
|
||||||
updated[idx] = {
|
|
||||||
...current,
|
|
||||||
acked: Math.max(current.acked, ackCount),
|
|
||||||
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
|
|
||||||
};
|
|
||||||
entry.messages = updated;
|
|
||||||
return; // Message IDs are unique, stop after first match
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Compare fetched messages against current state.
|
|
||||||
* Returns merged array if there are differences (new messages or ack changes),
|
|
||||||
* or null if the cache is already consistent (happy path — no rerender needed).
|
|
||||||
* Preserves any older paginated messages not present in the fetched page.
|
|
||||||
*/
|
|
||||||
export function reconcile(current: Message[], fetched: Message[]): Message[] | null {
|
|
||||||
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
|
|
||||||
for (const m of current) {
|
|
||||||
currentById.set(m.id, { acked: m.acked, pathsLen: m.paths?.length ?? 0, text: m.text });
|
|
||||||
}
|
|
||||||
|
|
||||||
let needsUpdate = false;
|
|
||||||
for (const m of fetched) {
|
|
||||||
const cur = currentById.get(m.id);
|
|
||||||
if (
|
|
||||||
!cur ||
|
|
||||||
cur.acked !== m.acked ||
|
|
||||||
cur.pathsLen !== (m.paths?.length ?? 0) ||
|
|
||||||
cur.text !== m.text
|
|
||||||
) {
|
|
||||||
needsUpdate = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!needsUpdate) return null;
|
|
||||||
|
|
||||||
// Merge: fresh recent page + any older paginated messages not in the fetch
|
|
||||||
const fetchedIds = new Set(fetched.map((m) => m.id));
|
|
||||||
const olderMessages = current.filter((m) => !fetchedIds.has(m.id));
|
|
||||||
return [...fetched, ...olderMessages];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Evict a specific conversation from the cache. */
|
|
||||||
export function remove(id: string): void {
|
|
||||||
cache.delete(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Move cached conversation state to a new conversation id. */
|
|
||||||
export function rename(oldId: string, newId: string): void {
|
|
||||||
if (oldId === newId) return;
|
|
||||||
const oldEntry = cache.get(oldId);
|
|
||||||
if (!oldEntry) return;
|
|
||||||
|
|
||||||
const newEntry = cache.get(newId);
|
|
||||||
if (!newEntry) {
|
|
||||||
cache.delete(oldId);
|
|
||||||
cache.set(newId, oldEntry);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedMessages = [...newEntry.messages];
|
|
||||||
const seenIds = new Set(mergedMessages.map((message) => message.id));
|
|
||||||
for (const message of oldEntry.messages) {
|
|
||||||
if (!seenIds.has(message.id)) {
|
|
||||||
mergedMessages.push(message);
|
|
||||||
seenIds.add(message.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.delete(oldId);
|
|
||||||
cache.set(newId, {
|
|
||||||
messages: mergedMessages,
|
|
||||||
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
|
|
||||||
contentKeys: new Set([...newEntry.contentKeys, ...oldEntry.contentKeys]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear the entire cache. */
|
|
||||||
export function clear(): void {
|
|
||||||
cache.clear();
|
|
||||||
}
|
|
||||||
@@ -8,12 +8,12 @@
|
|||||||
* between backend and frontend - both sides test against the same data.
|
* between backend and frontend - both sides test against the same data.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import fixtures from './fixtures/websocket_events.json';
|
import fixtures from './fixtures/websocket_events.json';
|
||||||
import { getStateKey } from '../utils/conversationState';
|
import { getStateKey } from '../utils/conversationState';
|
||||||
import { mergeContactIntoList } from '../utils/contactMerge';
|
import { mergeContactIntoList } from '../utils/contactMerge';
|
||||||
|
import { ConversationMessageCache } from '../hooks/useConversationMessages';
|
||||||
import { getMessageContentKey } from '../utils/messageIdentity';
|
import { getMessageContentKey } from '../utils/messageIdentity';
|
||||||
import * as messageCache from '../messageCache';
|
|
||||||
import type { Contact, Message } from '../types';
|
import type { Contact, Message } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,6 +25,7 @@ interface MockState {
|
|||||||
unreadCounts: Record<string, number>;
|
unreadCounts: Record<string, number>;
|
||||||
lastMessageTimes: Record<string, number>;
|
lastMessageTimes: Record<string, number>;
|
||||||
seenActiveContent: Set<string>;
|
seenActiveContent: Set<string>;
|
||||||
|
messageCache: ConversationMessageCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMockState(): MockState {
|
function createMockState(): MockState {
|
||||||
@@ -33,6 +34,7 @@ function createMockState(): MockState {
|
|||||||
unreadCounts: {},
|
unreadCounts: {},
|
||||||
lastMessageTimes: {},
|
lastMessageTimes: {},
|
||||||
seenActiveContent: new Set(),
|
seenActiveContent: new Set(),
|
||||||
|
messageCache: new ConversationMessageCache(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ function handleMessageEvent(
|
|||||||
state.lastMessageTimes[stateKey] = msg.received_at;
|
state.lastMessageTimes[stateKey] = msg.received_at;
|
||||||
|
|
||||||
if (!isForActiveConversation) {
|
if (!isForActiveConversation) {
|
||||||
const isNew = messageCache.addMessage(msg.conversation_key, msg);
|
const isNew = state.messageCache.addMessage(msg.conversation_key, msg);
|
||||||
if (!msg.outgoing && isNew) {
|
if (!msg.outgoing && isNew) {
|
||||||
state.unreadCounts[stateKey] = (state.unreadCounts[stateKey] || 0) + 1;
|
state.unreadCounts[stateKey] = (state.unreadCounts[stateKey] || 0) + 1;
|
||||||
unreadIncremented = true;
|
unreadIncremented = true;
|
||||||
@@ -78,11 +80,6 @@ function handleMessageEvent(
|
|||||||
return { added, unreadIncremented };
|
return { added, unreadIncremented };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear messageCache between tests to avoid cross-test contamination
|
|
||||||
beforeEach(() => {
|
|
||||||
messageCache.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Integration: Channel Message Events', () => {
|
describe('Integration: Channel Message Events', () => {
|
||||||
const fixture = fixtures.channel_message;
|
const fixture = fixtures.channel_message;
|
||||||
|
|
||||||
@@ -342,11 +339,8 @@ describe('Integration: Contact Merge', () => {
|
|||||||
// --- ACK + messageCache propagation tests ---
|
// --- ACK + messageCache propagation tests ---
|
||||||
|
|
||||||
describe('Integration: ACK + messageCache propagation', () => {
|
describe('Integration: ACK + messageCache propagation', () => {
|
||||||
beforeEach(() => {
|
|
||||||
messageCache.clear();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('updateAck updates acked count on cached message', () => {
|
it('updateAck updates acked count on cached message', () => {
|
||||||
|
const messageCache = new ConversationMessageCache();
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
id: 100,
|
id: 100,
|
||||||
type: 'PRIV',
|
type: 'PRIV',
|
||||||
@@ -372,6 +366,7 @@ describe('Integration: ACK + messageCache propagation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('updateAck updates paths when longer', () => {
|
it('updateAck updates paths when longer', () => {
|
||||||
|
const messageCache = new ConversationMessageCache();
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
id: 101,
|
id: 101,
|
||||||
type: 'PRIV',
|
type: 'PRIV',
|
||||||
@@ -401,6 +396,7 @@ describe('Integration: ACK + messageCache propagation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('preserves higher existing ack count (max semantics)', () => {
|
it('preserves higher existing ack count (max semantics)', () => {
|
||||||
|
const messageCache = new ConversationMessageCache();
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
id: 102,
|
id: 102,
|
||||||
type: 'PRIV',
|
type: 'PRIV',
|
||||||
@@ -426,6 +422,7 @@ describe('Integration: ACK + messageCache propagation', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('is a no-op for unknown message ID', () => {
|
it('is a no-op for unknown message ID', () => {
|
||||||
|
const messageCache = new ConversationMessageCache();
|
||||||
const msg: Message = {
|
const msg: Message = {
|
||||||
id: 103,
|
id: 103,
|
||||||
type: 'PRIV',
|
type: 'PRIV',
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import * as messageCache from '../messageCache';
|
import {
|
||||||
import { MAX_CACHED_CONVERSATIONS, MAX_MESSAGES_PER_ENTRY } from '../messageCache';
|
ConversationMessageCache,
|
||||||
|
MAX_CACHED_CONVERSATIONS,
|
||||||
|
MAX_MESSAGES_PER_ENTRY,
|
||||||
|
reconcileConversationMessages,
|
||||||
|
} from '../hooks/useConversationMessages';
|
||||||
import type { Message } from '../types';
|
import type { Message } from '../types';
|
||||||
|
|
||||||
function createMessage(overrides: Partial<Message> = {}): Message {
|
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||||
@@ -31,8 +35,10 @@ function createEntry(messages: Message[] = [], hasOlderMessages = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('messageCache', () => {
|
describe('messageCache', () => {
|
||||||
|
let messageCache: ConversationMessageCache;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
messageCache.clear();
|
messageCache = new ConversationMessageCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('get/set', () => {
|
describe('get/set', () => {
|
||||||
@@ -337,7 +343,7 @@ describe('messageCache', () => {
|
|||||||
createMessage({ id: 3, acked: 1 }),
|
createMessage({ id: 3, acked: 1 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
expect(messageCache.reconcile(msgs, fetched)).toBeNull();
|
expect(reconcileConversationMessages(msgs, fetched)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects new messages missing from cache', () => {
|
it('detects new messages missing from cache', () => {
|
||||||
@@ -348,7 +354,7 @@ describe('messageCache', () => {
|
|||||||
createMessage({ id: 3, text: 'missed via WS' }),
|
createMessage({ id: 3, text: 'missed via WS' }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const merged = messageCache.reconcile(current, fetched);
|
const merged = reconcileConversationMessages(current, fetched);
|
||||||
expect(merged).not.toBeNull();
|
expect(merged).not.toBeNull();
|
||||||
expect(merged!.map((m) => m.id)).toEqual([1, 2, 3]);
|
expect(merged!.map((m) => m.id)).toEqual([1, 2, 3]);
|
||||||
});
|
});
|
||||||
@@ -357,7 +363,7 @@ describe('messageCache', () => {
|
|||||||
const current = [createMessage({ id: 1, acked: 0 })];
|
const current = [createMessage({ id: 1, acked: 0 })];
|
||||||
const fetched = [createMessage({ id: 1, acked: 3 })];
|
const fetched = [createMessage({ id: 1, acked: 3 })];
|
||||||
|
|
||||||
const merged = messageCache.reconcile(current, fetched);
|
const merged = reconcileConversationMessages(current, fetched);
|
||||||
expect(merged).not.toBeNull();
|
expect(merged).not.toBeNull();
|
||||||
expect(merged![0].acked).toBe(3);
|
expect(merged![0].acked).toBe(3);
|
||||||
});
|
});
|
||||||
@@ -376,20 +382,20 @@ describe('messageCache', () => {
|
|||||||
createMessage({ id: 2 }),
|
createMessage({ id: 2 }),
|
||||||
];
|
];
|
||||||
|
|
||||||
const merged = messageCache.reconcile(current, fetched);
|
const merged = reconcileConversationMessages(current, fetched);
|
||||||
expect(merged).not.toBeNull();
|
expect(merged).not.toBeNull();
|
||||||
// Should have fetched page + older paginated message
|
// Should have fetched page + older paginated message
|
||||||
expect(merged!.map((m) => m.id)).toEqual([4, 3, 2, 1]);
|
expect(merged!.map((m) => m.id)).toEqual([4, 3, 2, 1]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for empty fetched and empty current', () => {
|
it('returns null for empty fetched and empty current', () => {
|
||||||
expect(messageCache.reconcile([], [])).toBeNull();
|
expect(reconcileConversationMessages([], [])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('detects difference when current is empty but fetch has messages', () => {
|
it('detects difference when current is empty but fetch has messages', () => {
|
||||||
const fetched = [createMessage({ id: 1 })];
|
const fetched = [createMessage({ id: 1 })];
|
||||||
|
|
||||||
const merged = messageCache.reconcile([], fetched);
|
const merged = reconcileConversationMessages([], fetched);
|
||||||
expect(merged).not.toBeNull();
|
expect(merged).not.toBeNull();
|
||||||
expect(merged!).toHaveLength(1);
|
expect(merged!).toHaveLength(1);
|
||||||
});
|
});
|
||||||
@@ -409,7 +415,7 @@ describe('messageCache', () => {
|
|||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
const merged = messageCache.reconcile(current, fetched);
|
const merged = reconcileConversationMessages(current, fetched);
|
||||||
expect(merged).not.toBeNull();
|
expect(merged).not.toBeNull();
|
||||||
expect(merged![0].paths).toHaveLength(2);
|
expect(merged![0].paths).toHaveLength(2);
|
||||||
});
|
});
|
||||||
@@ -418,7 +424,7 @@ describe('messageCache', () => {
|
|||||||
const current = [createMessage({ id: 1, text: '[encrypted]' })];
|
const current = [createMessage({ id: 1, text: '[encrypted]' })];
|
||||||
const fetched = [createMessage({ id: 1, text: 'Hello world' })];
|
const fetched = [createMessage({ id: 1, text: 'Hello world' })];
|
||||||
|
|
||||||
const merged = messageCache.reconcile(current, fetched);
|
const merged = reconcileConversationMessages(current, fetched);
|
||||||
expect(merged).not.toBeNull();
|
expect(merged).not.toBeNull();
|
||||||
expect(merged![0].text).toBe('Hello world');
|
expect(merged![0].text).toBe('Hello world');
|
||||||
});
|
});
|
||||||
@@ -428,7 +434,7 @@ describe('messageCache', () => {
|
|||||||
const current = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
|
const current = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
|
||||||
const fetched = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
|
const fetched = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
|
||||||
|
|
||||||
expect(messageCache.reconcile(current, fetched)).toBeNull();
|
expect(reconcileConversationMessages(current, fetched)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||||
|
|
||||||
import * as messageCache from '../messageCache';
|
|
||||||
import { api } from '../api';
|
import { api } from '../api';
|
||||||
import { useConversationMessages } from '../hooks/useConversationMessages';
|
import {
|
||||||
|
conversationMessageCache,
|
||||||
|
useConversationMessages,
|
||||||
|
} from '../hooks/useConversationMessages';
|
||||||
import type { Conversation, Message } from '../types';
|
import type { Conversation, Message } from '../types';
|
||||||
|
|
||||||
const mockGetMessages = vi.fn<typeof api.getMessages>();
|
const mockGetMessages = vi.fn<typeof api.getMessages>();
|
||||||
@@ -62,7 +64,7 @@ function createDeferred<T>() {
|
|||||||
describe('useConversationMessages ACK ordering', () => {
|
describe('useConversationMessages ACK ordering', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetMessages.mockReset();
|
mockGetMessages.mockReset();
|
||||||
messageCache.clear();
|
conversationMessageCache.clear();
|
||||||
mockToastError.mockReset();
|
mockToastError.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -175,7 +177,7 @@ describe('useConversationMessages ACK ordering', () => {
|
|||||||
describe('useConversationMessages conversation switch', () => {
|
describe('useConversationMessages conversation switch', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetMessages.mockReset();
|
mockGetMessages.mockReset();
|
||||||
messageCache.clear();
|
conversationMessageCache.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets loadingOlder when switching conversations mid-fetch', async () => {
|
it('resets loadingOlder when switching conversations mid-fetch', async () => {
|
||||||
@@ -300,7 +302,7 @@ describe('useConversationMessages conversation switch', () => {
|
|||||||
describe('useConversationMessages background reconcile ordering', () => {
|
describe('useConversationMessages background reconcile ordering', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetMessages.mockReset();
|
mockGetMessages.mockReset();
|
||||||
messageCache.clear();
|
conversationMessageCache.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ignores stale reconnect reconcile responses that finish after newer ones', async () => {
|
it('ignores stale reconnect reconcile responses that finish after newer ones', async () => {
|
||||||
@@ -342,7 +344,7 @@ describe('useConversationMessages background reconcile ordering', () => {
|
|||||||
const conv = createConversation();
|
const conv = createConversation();
|
||||||
const cachedMessage = createMessage({ id: 42, text: 'cached snapshot' });
|
const cachedMessage = createMessage({ id: 42, text: 'cached snapshot' });
|
||||||
|
|
||||||
messageCache.set(conv.id, {
|
conversationMessageCache.set(conv.id, {
|
||||||
messages: [cachedMessage],
|
messages: [cachedMessage],
|
||||||
hasOlderMessages: true,
|
hasOlderMessages: true,
|
||||||
});
|
});
|
||||||
@@ -362,7 +364,7 @@ describe('useConversationMessages background reconcile ordering', () => {
|
|||||||
describe('useConversationMessages older-page dedup and reentry', () => {
|
describe('useConversationMessages older-page dedup and reentry', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetMessages.mockReset();
|
mockGetMessages.mockReset();
|
||||||
messageCache.clear();
|
conversationMessageCache.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prevents duplicate overlapping older-page fetches in the same tick', async () => {
|
it('prevents duplicate overlapping older-page fetches in the same tick', async () => {
|
||||||
@@ -508,7 +510,7 @@ describe('useConversationMessages forward pagination', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
mockGetMessages.mockReset();
|
mockGetMessages.mockReset();
|
||||||
mockGetMessagesAround.mockReset();
|
mockGetMessagesAround.mockReset();
|
||||||
messageCache.clear();
|
conversationMessageCache.clear();
|
||||||
mockToastError.mockReset();
|
mockToastError.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user