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
+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