Add global message search and more e2e tests

This commit is contained in:
Jack Kingsman
2026-03-03 19:19:24 -08:00
parent 73a835688d
commit e0e71180b2
26 changed files with 2309 additions and 136 deletions
+12
View File
@@ -61,6 +61,7 @@ frontend/src/
│ ├── MessageList.tsx
│ ├── MessageInput.tsx
│ ├── NewMessageModal.tsx
│ ├── SearchView.tsx # Full-text message search pane
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
│ ├── RawPacketList.tsx
│ ├── MapView.tsx
@@ -186,6 +187,7 @@ Supported routes:
- `#map`
- `#map/focus/{pubkey_or_prefix}`
- `#visualizer`
- `#search`
- `#channel/{channelKey}`
- `#channel/{channelKey}/{label}`
- `#contact/{publicKey}`
@@ -285,6 +287,16 @@ For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
## Message Search Pane
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
- **State**: `targetMessageId` in `App.tsx` drives the jump-to-message flow. When a search result is clicked, `handleNavigateToMessage` sets `targetMessageId` and switches to the target conversation.
- **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.
- **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).
## Styling
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
+88 -8
View File
@@ -51,6 +51,9 @@ const SettingsModal = lazy(() =>
const CrackerPanel = lazy(() =>
import('./components/CrackerPanel').then((m) => ({ default: m.CrackerPanel }))
);
const SearchView = lazy(() =>
import('./components/SearchView').then((m) => ({ default: m.SearchView }))
);
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
import { getStateKey } from './utils/conversationState';
@@ -59,6 +62,7 @@ import { messageContainsMention } from './utils/messageParser';
import { mergeContactIntoList } from './utils/contactMerge';
import { getLocalLabel, getContrastTextColor } from './utils/localLabel';
import { cn } from '@/lib/utils';
import type { SearchNavigateTarget } from './components/SearchView';
import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types';
const MAX_RAW_PACKETS = 500;
@@ -75,6 +79,7 @@ export function App() {
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
const [targetMessageId, setTargetMessageId] = useState<number | null>(null);
// Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state)
const crackerMounted = useRef(false);
@@ -166,17 +171,26 @@ export function App() {
// Wire up the ref bridge so useContactsAndChannels handlers reach the real setter
setActiveConversationRef.current = setActiveConversation;
// Keep SearchView mounted after first open to preserve search state
const searchMounted = useRef(false);
if (activeConversation?.type === 'search') searchMounted.current = true;
// Custom hooks for conversation-specific functionality
const {
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
} = useConversationMessages(activeConversation);
} = useConversationMessages(activeConversation, targetMessageId);
const {
unreadCounts,
@@ -257,7 +271,8 @@ export function App() {
})();
// Only add to message list if it's for the active conversation
if (isForActiveConversation) {
// and we're not viewing historical messages (hasNewerMessages means we jumped mid-history)
if (isForActiveConversation && !hasNewerMessagesRef.current) {
addMessageIfNew(msg);
}
@@ -315,6 +330,7 @@ export function App() {
setHealth,
setConfig,
activeConversationRef,
hasNewerMessagesRef,
setContacts,
setChannels,
triggerReconcile,
@@ -451,15 +467,45 @@ export function App() {
setInfoPaneChannelKey(null);
}, []);
const handleSelectConversationWithTargetReset = useCallback(
(conv: Conversation, options?: { preserveTarget?: boolean }) => {
if (conv.type !== 'search' && !options?.preserveTarget) {
setTargetMessageId(null);
}
handleSelectConversation(conv);
},
[handleSelectConversation]
);
const handleNavigateToChannel = useCallback(
(channelKey: string) => {
const channel = channels.find((c) => c.key === channelKey);
if (channel) {
handleSelectConversation({ type: 'channel', id: channel.key, name: channel.name });
handleSelectConversationWithTargetReset({
type: 'channel',
id: channel.key,
name: channel.name,
});
setInfoPaneContactKey(null);
}
},
[channels, handleSelectConversation]
[channels, handleSelectConversationWithTargetReset]
);
const handleNavigateToMessage = useCallback(
(target: SearchNavigateTarget) => {
const convType = target.type === 'CHAN' ? 'channel' : 'contact';
setTargetMessageId(target.id);
handleSelectConversationWithTargetReset(
{
type: convType,
id: target.conversation_key,
name: target.conversation_name,
},
{ preserveTarget: true }
);
},
[handleSelectConversationWithTargetReset]
);
// Sidebar content (shared between desktop and mobile)
@@ -468,7 +514,7 @@ export function App() {
contacts={contacts}
channels={channels}
activeConversation={activeConversation}
onSelectConversation={handleSelectConversation}
onSelectConversation={handleSelectConversationWithTargetReset}
onNewMessage={handleNewMessage}
lastMessageTimes={lastMessageTimes}
unreadCounts={unreadCounts}
@@ -559,7 +605,12 @@ export function App() {
</Sheet>
<main className="flex-1 flex flex-col bg-background min-w-0">
<div className={cn('flex-1 flex flex-col min-h-0', showSettings && 'hidden')}>
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(showSettings || activeConversation?.type === 'search') && 'hidden'
)}
>
{activeConversation ? (
activeConversation.type === 'map' ? (
<>
@@ -597,7 +648,7 @@ export function App() {
<RawPacketList packets={rawPackets} />
</div>
</>
) : activeContactIsRepeater ? (
) : activeConversation.type === 'search' ? null : activeContactIsRepeater ? (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -650,6 +701,12 @@ export function App() {
radioName={config?.name}
config={config}
onOpenContactInfo={handleOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={() => setTargetMessageId(null)}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={fetchNewerMessages}
onJumpToBottom={jumpToBottom}
/>
<MessageInput
ref={messageInputRef}
@@ -672,6 +729,29 @@ export function App() {
)}
</div>
{searchMounted.current && (
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(activeConversation?.type !== 'search' || showSettings) && 'hidden'
)}
>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading search...
</div>
}
>
<SearchView
contacts={contacts}
channels={channels}
onNavigateToMessage={handleNavigateToMessage}
/>
</Suspense>
</div>
)}
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
@@ -754,7 +834,7 @@ export function App() {
undecryptedCount={undecryptedCount}
onClose={() => setShowNewMessage(false)}
onSelectConversation={(conv) => {
setActiveConversation(conv);
handleSelectConversationWithTargetReset(conv);
setShowNewMessage(false);
}}
onCreateContact={handleCreateContact}
+22
View File
@@ -12,6 +12,7 @@ import type {
HealthStatus,
MaintenanceResult,
Message,
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RadioConfig,
@@ -164,6 +165,9 @@ export const api = {
conversation_key?: string;
before?: number;
before_id?: number;
after?: number;
after_id?: number;
q?: string;
},
signal?: AbortSignal
) => {
@@ -174,9 +178,27 @@ export const api = {
if (params?.conversation_key) searchParams.set('conversation_key', params.conversation_key);
if (params?.before !== undefined) searchParams.set('before', params.before.toString());
if (params?.before_id !== undefined) searchParams.set('before_id', params.before_id.toString());
if (params?.after !== undefined) searchParams.set('after', params.after.toString());
if (params?.after_id !== undefined) searchParams.set('after_id', params.after_id.toString());
if (params?.q) searchParams.set('q', params.q);
const query = searchParams.toString();
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`, { signal });
},
getMessagesAround: (
messageId: number,
type?: 'PRIV' | 'CHAN',
conversationKey?: string,
signal?: AbortSignal
) => {
const searchParams = new URLSearchParams();
if (type) searchParams.set('type', type);
if (conversationKey) searchParams.set('conversation_key', conversationKey);
const query = searchParams.toString();
return fetchJson<MessagesAroundResponse>(
`/messages/around/${messageId}${query ? `?${query}` : ''}`,
{ signal }
);
},
sendDirectMessage: (destination: string, text: string) =>
fetchJson<Message>('/messages/direct', {
method: 'POST',
+2
View File
@@ -46,6 +46,7 @@ export function ChatHeader({
onKeyDown={handleKeyboardActivate}
onClick={() => onOpenContactInfo(conversation.id)}
title="View contact info"
aria-label={`View info for ${conversation.name}`}
>
<ContactAvatar
name={conversation.name}
@@ -60,6 +61,7 @@ export function ChatHeader({
className={`flex-shrink-0 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={titleClickable ? 'button' : undefined}
tabIndex={titleClickable ? 0 : undefined}
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
onClick={
titleClickable
+75 -9
View File
@@ -28,6 +28,12 @@ interface MessageListProps {
radioName?: string;
config?: RadioConfig | null;
onOpenContactInfo?: (publicKey: string) => void;
targetMessageId?: number | null;
onTargetReached?: () => void;
hasNewerMessages?: boolean;
loadingNewer?: boolean;
onLoadNewer?: () => void;
onJumpToBottom?: () => void;
}
// URL regex for linkifying plain text
@@ -154,6 +160,12 @@ export function MessageList({
radioName,
config,
onOpenContactInfo,
targetMessageId,
onTargetReached,
hasNewerMessages = false,
loadingNewer = false,
onLoadNewer,
onJumpToBottom,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
@@ -167,6 +179,8 @@ export function MessageList({
} | null>(null);
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const targetScrolledRef = useRef(false);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
@@ -205,8 +219,10 @@ export function MessageList({
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
// User was near top (loading older) - preserve position by adding the height diff
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
} else if (scrollStateRef.current.wasNearBottom) {
// User was near bottom - scroll to bottom for new messages (including sent)
} else if (scrollStateRef.current.wasNearBottom && !hasNewerMessagesRef.current) {
// User was near bottom - scroll to bottom for new messages (including sent).
// Skip when browsing mid-history (hasNewerMessages) so that forward-pagination
// appends in place instead of chasing the bottom in an infinite load loop.
list.scrollTop = list.scrollHeight;
}
}
@@ -214,6 +230,25 @@ export function MessageList({
prevMessagesLengthRef.current = messages.length;
}, [messages]);
// Scroll to target message and highlight it
useLayoutEffect(() => {
if (!targetMessageId || targetScrolledRef.current || messages.length === 0) return;
const el = listRef.current?.querySelector(`[data-message-id="${targetMessageId}"]`);
if (!el) return;
// Prevent the initial-load layout effect from overriding our scroll
isInitialLoadRef.current = false;
el.scrollIntoView({ block: 'center' });
setHighlightedMessageId(targetMessageId);
targetScrolledRef.current = true;
onTargetReached?.();
}, [messages, targetMessageId, onTargetReached]);
// Reset target scroll tracking when targetMessageId changes
useEffect(() => {
targetScrolledRef.current = false;
}, [targetMessageId]);
// Reset initial load flag when conversation changes (messages becomes empty then filled)
useEffect(() => {
if (messages.length === 0) {
@@ -271,9 +306,15 @@ export function MessageList({
const onLoadOlderRef = useRef(onLoadOlder);
const loadingOlderRef = useRef(loadingOlder);
const hasOlderMessagesRef = useRef(hasOlderMessages);
const onLoadNewerRef = useRef(onLoadNewer);
const loadingNewerRef = useRef(loadingNewer);
const hasNewerMessagesRef = useRef(hasNewerMessages);
onLoadOlderRef.current = onLoadOlder;
loadingOlderRef.current = loadingOlder;
hasOlderMessagesRef.current = hasOlderMessages;
onLoadNewerRef.current = onLoadNewer;
loadingNewerRef.current = loadingNewer;
hasNewerMessagesRef.current = hasNewerMessages;
// Handle scroll - capture state and detect when user is near top/bottom
// Stable callback: reads changing values from refs, never recreated.
@@ -295,20 +336,33 @@ export function MessageList({
// Show scroll-to-bottom button when not near the bottom (more than 100px away)
setShowScrollToBottom(distanceFromBottom > 100);
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) return;
// Trigger load when within 100px of top
if (scrollTop < 100) {
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) {
// skip older load
} else if (scrollTop < 100) {
onLoadOlderRef.current();
}
// Trigger load newer when within 100px of bottom
if (
onLoadNewerRef.current &&
!loadingNewerRef.current &&
hasNewerMessagesRef.current &&
distanceFromBottom < 100
) {
onLoadNewerRef.current();
}
}, []);
// Scroll to bottom handler
// Scroll to bottom handler (or jump to bottom if viewing historical messages)
const scrollToBottom = useCallback(() => {
if (hasNewerMessages && onJumpToBottom) {
onJumpToBottom();
return;
}
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, []);
}, [hasNewerMessages, onJumpToBottom]);
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
@@ -465,6 +519,7 @@ export function MessageList({
return (
<div
key={msg.id}
data-message-id={msg.id}
className={cn(
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
@@ -503,7 +558,8 @@ export function MessageList({
<div
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming'
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
)}
>
{showAvatar && (
@@ -618,6 +674,16 @@ export function MessageList({
</div>
);
})}
{loadingNewer && (
<div className="text-center py-2 text-muted-foreground text-sm" role="status">
Loading newer messages...
</div>
)}
{!loadingNewer && hasNewerMessages && (
<div className="text-center py-2 text-muted-foreground text-xs">
Scroll down for newer messages
</div>
)}
</div>
{/* Scroll to bottom button */}
+270
View File
@@ -0,0 +1,270 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, isAbortError } from '../api';
import type { Contact, Channel } from '../types';
import { formatTime } from '../utils/messageParser';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
const SEARCH_PAGE_SIZE = 50;
const DEBOUNCE_MS = 300;
interface SearchResult {
id: number;
type: 'PRIV' | 'CHAN';
conversation_key: string;
text: string;
received_at: number;
outgoing: boolean;
sender_name: string | null;
}
export interface SearchNavigateTarget {
id: number;
type: 'PRIV' | 'CHAN';
conversation_key: string;
conversation_name: string;
}
interface SearchViewProps {
contacts: Contact[];
channels: Channel[];
onNavigateToMessage: (target: SearchNavigateTarget) => void;
}
function highlightMatch(text: string, query: string): React.ReactNode[] {
if (!query) return [text];
const parts: React.ReactNode[] = [];
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const segments = text.split(regex);
for (let i = 0; i < segments.length; i++) {
if (regex.test(segments[i])) {
parts.push(
<mark key={i} className="bg-primary/30 text-foreground rounded-sm px-0.5">
{segments[i]}
</mark>
);
} else {
parts.push(segments[i]);
}
// Reset lastIndex since we're using test() in a loop
regex.lastIndex = 0;
}
return parts;
}
export function SearchView({ contacts, channels, onNavigateToMessage }: SearchViewProps) {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Debounce query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query.trim());
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [query]);
// Reset results when query changes
useEffect(() => {
setResults([]);
setOffset(0);
setHasMore(false);
}, [debouncedQuery]);
// Fetch search results
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
setHasMore(false);
return;
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
api
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset: 0 }, controller.signal)
.then((data) => {
setResults(data as SearchResult[]);
setHasMore(data.length >= SEARCH_PAGE_SIZE);
setOffset(data.length);
})
.catch((err) => {
if (!isAbortError(err)) {
console.error('Search failed:', err);
}
})
.finally(() => {
setLoading(false);
});
return () => controller.abort();
}, [debouncedQuery]);
const loadMore = useCallback(() => {
if (!debouncedQuery || loading) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
api
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
.then((data) => {
setResults((prev) => [...prev, ...(data as SearchResult[])]);
setHasMore(data.length >= SEARCH_PAGE_SIZE);
setOffset((prev) => prev + data.length);
})
.catch((err) => {
if (!isAbortError(err)) {
console.error('Search load more failed:', err);
}
})
.finally(() => {
setLoading(false);
});
}, [debouncedQuery, loading, offset]);
// Resolve conversation name from contacts/channels
const getConversationName = useCallback(
(result: SearchResult): string => {
if (result.type === 'CHAN') {
const channel = channels.find(
(c) => c.key.toUpperCase() === result.conversation_key.toUpperCase()
);
return channel?.name || result.conversation_key.slice(0, 8);
}
const contact = contacts.find(
(c) => c.public_key.toLowerCase() === result.conversation_key.toLowerCase()
);
return contact?.name || result.conversation_key.slice(0, 12);
},
[contacts, channels]
);
const handleResultClick = useCallback(
(result: SearchResult) => {
onNavigateToMessage({
id: result.id,
type: result.type,
conversation_key: result.conversation_key,
conversation_name: getConversationName(result),
});
},
[onNavigateToMessage, getConversationName]
);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Message Search
</div>
{/* Search input */}
<div className="px-4 py-3 border-b border-border">
<Input
ref={inputRef}
type="text"
placeholder="Search all messages..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 text-sm"
aria-label="Search messages"
/>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto">
{!debouncedQuery && (
<div className="p-8 text-center text-muted-foreground text-sm">
Type to search across all messages
</div>
)}
{debouncedQuery && results.length === 0 && !loading && (
<div className="p-8 text-center text-muted-foreground text-sm">
No messages found for &ldquo;{debouncedQuery}&rdquo;
</div>
)}
{results.map((result) => {
const convName = getConversationName(result);
const typeBadge = result.type === 'CHAN' ? 'Channel' : 'DM';
return (
<div
key={result.id}
className="px-4 py-3 border-b border-border/50 cursor-pointer hover:bg-accent/50 transition-colors"
role="button"
tabIndex={0}
onClick={() => handleResultClick(result)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleResultClick(result);
}
}}
>
<div className="flex items-center gap-2 mb-1">
<span
className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded',
result.type === 'CHAN'
? 'bg-primary/20 text-primary'
: 'bg-secondary text-secondary-foreground'
)}
>
{typeBadge}
</span>
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
{formatTime(result.received_at)}
</span>
</div>
<div className="text-[13px] text-foreground/80 line-clamp-2 break-words">
{result.sender_name && !result.outgoing && (
<span className="text-muted-foreground">{result.sender_name}: </span>
)}
{result.outgoing && <span className="text-muted-foreground">You: </span>}
{highlightMatch(
result.sender_name && result.text.startsWith(`${result.sender_name}: `)
? result.text.slice(result.sender_name.length + 2)
: result.text,
debouncedQuery
)}
</div>
</div>
);
})}
{loading && (
<div className="p-4 text-center text-muted-foreground text-sm">Searching...</div>
)}
{hasMore && !loading && (
<div className="p-4 text-center">
<Button variant="outline" size="sm" onClick={loadMore}>
Load more results
</Button>
</div>
)}
</div>
</div>
);
}
+30 -2
View File
@@ -116,8 +116,10 @@ export function Sidebar({
onSelectConversation(conversation);
};
const isActive = (type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer', id: string) =>
activeConversation?.type === type && activeConversation?.id === id;
const isActive = (
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
id: string
) => activeConversation?.type === type && activeConversation?.id === id;
// Get unread count for a conversation
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
@@ -614,6 +616,32 @@ export function Sidebar({
</div>
)}
{/* Message Search */}
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('search', 'search') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('search', 'search') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'search',
id: 'search',
name: 'Message Search',
})
}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🔍
</span>
<span className="flex-1 truncate text-muted-foreground">Message Search</span>
</div>
)}
{/* Cracker Toggle */}
{!query && (
<div
+177 -28
View File
@@ -62,20 +62,28 @@ interface UseConversationMessagesResult {
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
hasNewerMessagesRef: React.MutableRefObject<boolean>;
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
fetchOlderMessages: () => Promise<void>;
fetchNewerMessages: () => Promise<void>;
jumpToBottom: () => void;
addMessageIfNew: (msg: Message) => boolean;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
triggerReconcile: () => void;
}
export function useConversationMessages(
activeConversation: Conversation | null
activeConversation: Conversation | null,
targetMessageId?: number | null
): 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
const seenMessageContent = useRef<Set<string>>(new Set());
@@ -94,6 +102,7 @@ export function useConversationMessages(
// 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(() => {
@@ -104,6 +113,10 @@ export function useConversationMessages(
hasOlderMessagesRef.current = hasOlderMessages;
}, [hasOlderMessages]);
useEffect(() => {
hasNewerMessagesRef.current = hasNewerMessages;
}, [hasNewerMessages]);
const setPendingAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
const existing = pendingAcksRef.current.get(messageId);
@@ -146,7 +159,8 @@ export function useConversationMessages(
!activeConversation ||
activeConversation.type === 'raw' ||
activeConversation.type === 'map' ||
activeConversation.type === 'visualizer'
activeConversation.type === 'visualizer' ||
activeConversation.type === 'search'
) {
setMessages([]);
setHasOlderMessages(false);
@@ -264,11 +278,86 @@ export function useConversationMessages(
}
}, [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') return;
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
@@ -318,9 +407,39 @@ export function useConversationMessages(
abortControllerRef.current.abort();
}
// Save outgoing conversation to cache (if it had messages loaded)
const prevId = prevConversationIdRef.current;
if (prevId && messagesRef.current.length > 0) {
// 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),
@@ -328,21 +447,13 @@ export function useConversationMessages(
});
}
// Track which conversation we're now on
const newId = activeConversation?.id ?? null;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
// Reset loadingOlder — the previous conversation's in-flight older-message
// fetch is irrelevant now (its stale-check will discard the response).
setLoadingOlder(false);
// Clear state for non-message views
if (
!activeConversation ||
activeConversation.type === 'raw' ||
activeConversation.type === 'map' ||
activeConversation.type === 'visualizer'
activeConversation.type === 'visualizer' ||
activeConversation.type === 'search'
) {
setMessages([]);
setHasOlderMessages(false);
@@ -353,19 +464,52 @@ export function useConversationMessages(
const controller = new AbortController();
abortControllerRef.current = controller;
// 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);
// 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 {
// Not cached — full fetch with spinner
fetchMessages(true, controller.signal);
// 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
@@ -377,7 +521,7 @@ export function useConversationMessages(
// - 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]);
}, [activeConversation?.id, activeConversation?.type, targetMessageId]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate
@@ -456,8 +600,13 @@ export function useConversationMessages(
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
setMessages,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
+6 -1
View File
@@ -82,6 +82,11 @@ export function useConversationRouter({
hasSetDefaultConversation.current = true;
return;
}
if (hashConv?.type === 'search') {
setActiveConversationState({ type: 'search', id: 'search', name: 'Message Search' });
hasSetDefaultConversation.current = true;
return;
}
// Handle channel hash (ID-first with legacy-name fallback)
if (hashConv?.type === 'channel') {
@@ -202,7 +207,7 @@ export function useConversationRouter({
if (hashSyncEnabledRef.current) {
updateUrlHash(activeConversation);
}
if (getReopenLastConversationEnabled()) {
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation);
}
}
+13
View File
@@ -71,6 +71,19 @@
scrollbar-color: hsl(224 11% 22%) transparent;
}
/* Message highlight animation for jump-to-message */
@keyframes message-highlight {
0% {
box-shadow: 0 0 0 2px hsl(var(--primary));
}
100% {
box-shadow: 0 0 0 2px transparent;
}
}
.message-highlight {
animation: message-highlight 2s ease-out forwards;
}
/* Constrain CodeMirror editor width */
.cm-editor {
max-width: 100% !important;
+259
View File
@@ -0,0 +1,259 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
const mocks = vi.hoisted(() => ({
api: {
getRadioConfig: vi.fn(),
getSettings: vi.fn(),
getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
migratePreferences: vi.fn(),
},
useConversationMessagesCalls: vi.fn(),
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
vi.mock('../useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>();
return {
...actual,
useConversationMessages: (activeConversation: unknown, targetMessageId: number | null) => {
mocks.useConversationMessagesCalls(activeConversation, targetMessageId);
return {
messages: [],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
};
},
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
vi.mock('../components/Sidebar', () => ({
Sidebar: ({
onSelectConversation,
activeConversation,
}: {
onSelectConversation: (conv: { type: 'search' | 'channel'; id: string; name: string }) => void;
activeConversation: { type: string; id: string } | null;
}) => (
<div>
<button
type="button"
onClick={() =>
onSelectConversation({
type: 'search',
id: 'search',
name: 'Message Search',
})
}
>
Open Search
</button>
<button
type="button"
onClick={() =>
onSelectConversation({
type: 'channel',
id: PUBLIC_CHANNEL_KEY,
name: 'Public',
})
}
>
Open Public
</button>
<div data-testid="active-conversation">
{activeConversation ? `${activeConversation.type}:${activeConversation.id}` : 'none'}
</div>
</div>
),
}));
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
}));
vi.mock('../components/MessageInput', () => ({
MessageInput: React.forwardRef((_props, ref) => {
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
return <div data-testid="message-input" />;
}),
}));
vi.mock('../components/NewMessageModal', () => ({
NewMessageModal: () => null,
}));
vi.mock('../components/SearchView', () => ({
SearchView: ({
onNavigateToMessage,
}: {
onNavigateToMessage: (target: {
id: number;
type: 'CHAN' | 'PRIV';
conversation_key: string;
conversation_name: string;
}) => void;
}) => (
<button
type="button"
onClick={() =>
onNavigateToMessage({
id: 321,
type: 'CHAN',
conversation_key: PUBLIC_CHANNEL_KEY,
conversation_name: 'Public',
})
}
>
Jump Result
</button>
),
}));
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
}));
vi.mock('../components/RawPacketList', () => ({
RawPacketList: () => null,
}));
vi.mock('../components/ContactInfoPane', () => ({
ContactInfoPane: () => null,
}));
vi.mock('../components/ChannelInfoPane', () => ({
ChannelInfoPane: () => null,
}));
vi.mock('../components/ui/sheet', () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ui/sonner', () => ({
Toaster: () => null,
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { App } from '../App';
describe('App search jump target handling', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.api.getRadioConfig.mockResolvedValue({
public_key: 'aa'.repeat(32),
name: 'TestNode',
lat: 0,
lon: 0,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
bots: [],
});
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
mocks.api.getChannels.mockResolvedValue([
{
key: PUBLIC_CHANNEL_KEY,
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
},
]);
mocks.api.getContacts.mockResolvedValue([]);
});
it('clears jump target when user selects a non-search conversation', async () => {
render(<App />);
await waitFor(() => {
expect(screen.getAllByText('Open Search').length).toBeGreaterThan(0);
});
fireEvent.click(screen.getAllByText('Open Search')[0]);
await waitFor(() => {
expect(screen.getByText('Jump Result')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Jump Result'));
await waitFor(() => {
expect(mocks.useConversationMessagesCalls.mock.calls.some((call) => call[1] === 321)).toBe(
true
);
});
fireEvent.click(screen.getAllByText('Open Public')[0]);
await waitFor(() => {
const lastCall =
mocks.useConversationMessagesCalls.mock.calls[
mocks.useConversationMessagesCalls.mock.calls.length - 1
];
expect(lastCall?.[1]).toBeNull();
});
});
});
+6
View File
@@ -195,6 +195,7 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
signature: null,
outgoing: false,
acked: 0,
sender_name: null,
};
handleMessageEvent(state, msg, 'other_active_conv');
}
@@ -215,6 +216,7 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
signature: null,
outgoing: false,
acked: 0,
sender_name: null,
};
const result = handleMessageEvent(state, echo, 'other_active_conv');
@@ -354,6 +356,7 @@ describe('Integration: ACK + messageCache propagation', () => {
signature: null,
outgoing: true,
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-100');
@@ -377,6 +380,7 @@ describe('Integration: ACK + messageCache propagation', () => {
signature: null,
outgoing: true,
acked: 1,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-101');
@@ -404,6 +408,7 @@ describe('Integration: ACK + messageCache propagation', () => {
signature: null,
outgoing: true,
acked: 5,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-102');
@@ -427,6 +432,7 @@ describe('Integration: ACK + messageCache propagation', () => {
signature: null,
outgoing: true,
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-103');
+1
View File
@@ -20,6 +20,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
signature: null,
outgoing: false,
acked: 0,
sender_name: null,
...overrides,
};
}
+247
View File
@@ -0,0 +1,247 @@
import { fireEvent, render, screen, act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Message } from '../types';
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
vi.mock('../api', () => ({
api: {
getMessages: (...args: unknown[]) => mockGetMessages(...args),
},
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
}));
import { SearchView } from '../components/SearchView';
function createSearchResult(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'ABC123',
text: 'hello world',
sender_timestamp: 1700000000,
received_at: 1700000000,
paths: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
...overrides,
};
}
const defaultProps = {
contacts: [],
channels: [
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
],
onNavigateToMessage: vi.fn(),
};
/** Type the query into the search input and wait for debounced results to render. */
async function typeAndWaitForResults(query: string) {
const input = screen.getByLabelText('Search messages');
// Use fake timers only for the debounce, then switch to real timers for
// React's async state updates and waitFor polling.
vi.useFakeTimers();
await act(async () => {
fireEvent.change(input, { target: { value: query } });
vi.advanceTimersByTime(350);
});
vi.useRealTimers();
// Wait for the mock API promise to resolve and React to commit
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
describe('SearchView', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders empty state with prompt text', () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
});
it('focuses input on mount', () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
expect(screen.getByLabelText('Search messages')).toHaveFocus();
});
it('debounces search input', async () => {
mockGetMessages.mockResolvedValue([]);
vi.useFakeTimers();
render(<SearchView {...defaultProps} />);
const input = screen.getByLabelText('Search messages');
await act(async () => {
fireEvent.change(input, { target: { value: 'hello' } });
});
// Should not have called API yet (within debounce window)
expect(mockGetMessages).not.toHaveBeenCalled();
// Advance past debounce timer
await act(async () => {
vi.advanceTimersByTime(350);
});
vi.useRealTimers();
expect(mockGetMessages).toHaveBeenCalledTimes(1);
expect(mockGetMessages).toHaveBeenCalledWith(
expect.objectContaining({ q: 'hello' }),
expect.any(AbortSignal)
);
});
it('displays search results', async () => {
mockGetMessages.mockResolvedValue([
createSearchResult({ id: 1, text: 'hello world', sender_name: 'Alice' }),
createSearchResult({ id: 2, text: 'hello there', sender_name: 'Bob' }),
]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('hello');
// Text is split by highlightMatch into segments, so use container text content
const buttons = screen.getAllByRole('button');
const texts = buttons.map((b) => b.textContent);
expect(texts.some((t) => t?.includes('hello world') || t?.includes('world'))).toBe(true);
expect(texts.some((t) => t?.includes('hello there') || t?.includes('there'))).toBe(true);
});
it('shows no-results message when search returns empty', async () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('nonexistent');
expect(screen.getByText(/No messages found/)).toBeInTheDocument();
});
it('navigates to message on click', async () => {
const result = createSearchResult({
id: 42,
type: 'CHAN',
conversation_key: 'ABC123',
text: 'click me',
});
mockGetMessages.mockResolvedValue([result]);
const onNavigate = vi.fn();
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
await typeAndWaitForResults('click');
const resultBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('me'));
expect(resultBtn).toBeDefined();
fireEvent.click(resultBtn!);
expect(onNavigate).toHaveBeenCalledWith({
id: 42,
type: 'CHAN',
conversation_key: 'ABC123',
conversation_name: 'Public',
});
});
it('navigates on Enter key', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ id: 10, text: 'keyboard nav' })]);
const onNavigate = vi.fn();
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
await typeAndWaitForResults('keyboard');
const resultEl = screen.getByRole('button', { name: /keyboard nav/i });
fireEvent.keyDown(resultEl, { key: 'Enter' });
expect(onNavigate).toHaveBeenCalled();
});
it('shows load more button when results fill a page', async () => {
const pageResults = Array.from({ length: 50 }, (_, i) =>
createSearchResult({ id: i + 1, text: `result ${i}` })
);
mockGetMessages.mockResolvedValueOnce(pageResults);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('result');
expect(screen.getByText('Load more results')).toBeInTheDocument();
});
it('does not show load more when results are less than page size', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ id: 1, text: 'only one' })]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('only');
const resultBtns = screen.getAllByRole('button');
expect(resultBtns.some((b) => b.textContent?.includes('one'))).toBe(true);
expect(screen.queryByText('Load more results')).not.toBeInTheDocument();
});
it('resolves channel name from channels prop', async () => {
mockGetMessages.mockResolvedValue([
createSearchResult({ id: 1, type: 'CHAN', conversation_key: 'ABC123', text: 'test' }),
]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('test');
expect(screen.getByText('Public')).toBeInTheDocument();
});
it('resolves contact name from contacts prop', async () => {
const contactKey = 'aa'.repeat(32);
mockGetMessages.mockResolvedValue([
createSearchResult({
id: 1,
type: 'PRIV',
conversation_key: contactKey,
text: 'dm test',
}),
]);
render(
<SearchView
{...defaultProps}
contacts={[
{
public_key: contactKey,
name: 'Bob',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
first_seen: null,
last_read_at: null,
},
]}
/>
);
await typeAndWaitForResults('dm');
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});
@@ -6,10 +6,12 @@ import { useConversationMessages } from '../hooks/useConversationMessages';
import type { Conversation, Message } from '../types';
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
const mockGetMessagesAround = vi.fn();
vi.mock('../api', () => ({
api: {
getMessages: (...args: unknown[]) => mockGetMessages(...args),
getMessagesAround: (...args: unknown[]) => mockGetMessagesAround(...args),
},
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
}));
@@ -35,6 +37,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
signature: null,
outgoing: true,
acked: 0,
sender_name: null,
...overrides,
};
}
@@ -219,3 +222,173 @@ describe('useConversationMessages conversation switch', () => {
expect(result.current.messages[0].conversation_key).toBe('conv_b');
});
});
describe('useConversationMessages forward pagination', () => {
beforeEach(() => {
mockGetMessages.mockReset();
mockGetMessagesAround.mockReset();
messageCache.clear();
});
it('fetchNewerMessages loads newer messages and appends them', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
// Initial load returns messages that indicate there are newer ones
// (we'll set hasNewerMessages via targetMessageId + getMessagesAround)
const initialMessages = Array.from({ length: 3 }, (_, i) =>
createMessage({
id: i + 1,
conversation_key: 'ch1',
text: `msg-${i}`,
sender_timestamp: 1700000000 + i,
received_at: 1700000000 + i,
})
);
mockGetMessagesAround.mockResolvedValueOnce({
messages: initialMessages,
has_older: false,
has_newer: true,
});
const { result } = renderHook(
({ conv, target }: { conv: Conversation; target: number | null }) =>
useConversationMessages(conv, target),
{ initialProps: { conv, target: 2 } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages).toHaveLength(3);
expect(result.current.hasNewerMessages).toBe(true);
// Now fetch newer messages
const newerMessages = [
createMessage({
id: 4,
conversation_key: 'ch1',
text: 'msg-3',
sender_timestamp: 1700000003,
received_at: 1700000003,
}),
];
mockGetMessages.mockResolvedValueOnce(newerMessages);
await act(async () => {
await result.current.fetchNewerMessages();
});
expect(result.current.messages).toHaveLength(4);
// Less than page size → no more newer messages
expect(result.current.hasNewerMessages).toBe(false);
});
it('fetchNewerMessages deduplicates against seen messages', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
const initialMessages = [
createMessage({
id: 1,
conversation_key: 'ch1',
text: 'msg-0',
sender_timestamp: 1700000000,
received_at: 1700000000,
}),
];
mockGetMessagesAround.mockResolvedValueOnce({
messages: initialMessages,
has_older: false,
has_newer: true,
});
const { result } = renderHook(
({ conv, target }: { conv: Conversation; target: number | null }) =>
useConversationMessages(conv, target),
{ initialProps: { conv, target: 1 } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
// Simulate WS adding a message with the same content key
act(() => {
result.current.addMessageIfNew(
createMessage({
id: 2,
conversation_key: 'ch1',
text: 'duplicate-content',
sender_timestamp: 1700000001,
received_at: 1700000001,
})
);
});
// fetchNewerMessages returns the same content (different id but same content key)
mockGetMessages.mockResolvedValueOnce([
createMessage({
id: 3,
conversation_key: 'ch1',
text: 'duplicate-content',
sender_timestamp: 1700000001,
received_at: 1700000001,
}),
]);
await act(async () => {
await result.current.fetchNewerMessages();
});
// Should not have a duplicate
const dupes = result.current.messages.filter((m) => m.text === 'duplicate-content');
expect(dupes).toHaveLength(1);
});
it('jumpToBottom clears hasNewerMessages and refetches latest', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
const aroundMessages = [
createMessage({
id: 5,
conversation_key: 'ch1',
text: 'around-msg',
sender_timestamp: 1700000005,
received_at: 1700000005,
}),
];
mockGetMessagesAround.mockResolvedValueOnce({
messages: aroundMessages,
has_older: true,
has_newer: true,
});
const { result } = renderHook(
({ conv, target }: { conv: Conversation; target: number | null }) =>
useConversationMessages(conv, target),
{ initialProps: { conv, target: 5 } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.hasNewerMessages).toBe(true);
// Jump to bottom
const latestMessages = [
createMessage({
id: 10,
conversation_key: 'ch1',
text: 'latest-msg',
sender_timestamp: 1700000010,
received_at: 1700000010,
}),
];
mockGetMessages.mockResolvedValueOnce(latestMessages);
act(() => {
result.current.jumpToBottom();
});
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.hasNewerMessages).toBe(false);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].text).toBe('latest-msg');
});
});
@@ -21,6 +21,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
signature: null,
outgoing: false,
acked: 0,
sender_name: null,
...overrides,
};
}
+8 -1
View File
@@ -154,9 +154,16 @@ export interface Message {
outgoing: boolean;
/** ACK count: 0 = not acked, 1+ = number of acks/flood echoes received */
acked: number;
sender_name: string | null;
}
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer';
export interface MessagesAroundResponse {
messages: Message[];
has_older: boolean;
has_newer: boolean;
}
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search';
export interface Conversation {
type: ConversationType;
+6 -1
View File
@@ -2,7 +2,7 @@ import type { Channel, Contact, Conversation } from '../types';
import { getContactDisplayName } from './pubkey';
interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer';
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
/** Conversation identity token (channel key or contact public key, or legacy name token) */
name: string;
/** Optional human-readable label segment (ignored for identity resolution) */
@@ -29,6 +29,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
return { type: 'visualizer', name: 'visualizer' };
}
if (hash === 'search') {
return { type: 'search', name: 'search' };
}
// Check for map with focus: #map/focus/{pubkey_prefix}
if (hash.startsWith('map/focus/')) {
const focusKey = hash.slice('map/focus/'.length);
@@ -107,6 +111,7 @@ function getConversationHash(conv: Conversation | null): string {
if (conv.type === 'raw') return '#raw';
if (conv.type === 'map') return '#map';
if (conv.type === 'visualizer') return '#visualizer';
if (conv.type === 'search') return '#search';
// Use immutable IDs for identity, append readable label for UX.
if (conv.type === 'channel') {