diff --git a/AGENTS.md b/AGENTS.md index d2a1133..422a55e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -236,6 +236,7 @@ Key test files: - `tests/test_api.py` - API endpoints, read state tracking - `tests/test_migrations.py` - Database migration system - `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling) +- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination - `tests/test_rx_log_data.py` - on_rx_log_data event handler integration - `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring - `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field @@ -296,7 +297,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | DELETE | `/api/channels/{key}` | Delete channel | | POST | `/api/channels/sync` | Pull from radio | | POST | `/api/channels/{key}/mark-read` | Mark channel as read | -| GET | `/api/messages` | List with filters | +| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) | +| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) | | POST | `/api/messages/direct` | Send direct message | | POST | `/api/messages/channel` | Send channel message | | POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) | diff --git a/app/AGENTS.md b/app/AGENTS.md index c512a76..09d6f6f 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -178,7 +178,8 @@ app/ - `POST /channels/{key}/mark-read` ### Messages -- `GET /messages` +- `GET /messages` — list with filters; supports `q` (full-text search), `after`/`after_id` (forward cursor) +- `GET /messages/around/{message_id}` — context messages around a target (for jump-to-message navigation) - `POST /messages/direct` - `POST /messages/channel` - `POST /messages/channel/{message_id}/resend` @@ -291,6 +292,7 @@ tests/ ├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints ├── test_repository.py # Data access layer ├── test_rx_log_data.py # on_rx_log_data event handler integration +├── test_messages_search.py # Message search, around, forward pagination ├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends ├── test_settings_router.py # Settings endpoints, advert validation ├── test_statistics.py # Statistics aggregation diff --git a/app/models.py b/app/models.py index 8a08c4b..6ea44f3 100644 --- a/app/models.py +++ b/app/models.py @@ -194,6 +194,13 @@ class Message(BaseModel): signature: str | None = None outgoing: bool = False acked: int = 0 + sender_name: str | None = None + + +class MessagesAroundResponse(BaseModel): + messages: list[Message] + has_older: bool + has_newer: bool class RawPacketDecryptedInfo(BaseModel): diff --git a/app/repository/messages.py b/app/repository/messages.py index 5198547..cdba75a 100644 --- a/app/repository/messages.py +++ b/app/repository/messages.py @@ -15,7 +15,7 @@ class MessageRepository: try: paths_data = json.loads(paths_json) return [MessagePath(**p) for p in paths_data] - except (json.JSONDecodeError, TypeError, KeyError): + except (json.JSONDecodeError, TypeError, KeyError, ValueError): return None @staticmethod @@ -128,6 +128,37 @@ class MessageRepository: await db.conn.commit() return cursor.rowcount + @staticmethod + def _normalize_conversation_key(conversation_key: str) -> tuple[str, str]: + """Normalize a conversation key and return (sql_clause, normalized_key). + + Returns the WHERE clause fragment and the normalized key value. + """ + if len(conversation_key) == 64: + return "AND conversation_key = ?", conversation_key.lower() + elif len(conversation_key) == 32: + return "AND conversation_key = ?", conversation_key.upper() + else: + return "AND conversation_key LIKE ?", f"{conversation_key}%" + + @staticmethod + def _row_to_message(row: Any) -> Message: + """Convert a database row to a Message model.""" + return Message( + id=row["id"], + type=row["type"], + conversation_key=row["conversation_key"], + text=row["text"], + sender_timestamp=row["sender_timestamp"], + received_at=row["received_at"], + paths=MessageRepository._parse_paths(row["paths"]), + txt_type=row["txt_type"], + signature=row["signature"], + outgoing=bool(row["outgoing"]), + acked=row["acked"], + sender_name=row["sender_name"], + ) + @staticmethod async def get_all( limit: int = 100, @@ -136,6 +167,9 @@ class MessageRepository: conversation_key: str | None = None, before: int | None = None, before_id: int | None = None, + after: int | None = None, + after_id: int | None = None, + q: str | None = None, ) -> list[Message]: query = "SELECT * FROM messages WHERE 1=1" params: list[Any] = [] @@ -144,49 +178,113 @@ class MessageRepository: query += " AND type = ?" params.append(msg_type) if conversation_key: - normalized_key = conversation_key - # Prefer exact matching for full keys. - if len(conversation_key) == 64: - normalized_key = conversation_key.lower() - query += " AND conversation_key = ?" - params.append(normalized_key) - elif len(conversation_key) == 32: - normalized_key = conversation_key.upper() - query += " AND conversation_key = ?" - params.append(normalized_key) - else: - # Prefix match is only for legacy/partial key callers. - query += " AND conversation_key LIKE ?" - params.append(f"{conversation_key}%") + clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key) + query += f" {clause}" + params.append(norm_key) - if before is not None and before_id is not None: - query += " AND (received_at < ? OR (received_at = ? AND id < ?))" - params.extend([before, before, before_id]) + if q: + escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE" + params.append(f"%{escaped_q}%") - query += " ORDER BY received_at DESC, id DESC LIMIT ?" - params.append(limit) - if before is None or before_id is None: - query += " OFFSET ?" - params.append(offset) + # Forward cursor (after/after_id) — mutually exclusive with before/before_id + if after is not None and after_id is not None: + query += " AND (received_at > ? OR (received_at = ? AND id > ?))" + params.extend([after, after, after_id]) + query += " ORDER BY received_at ASC, id ASC LIMIT ?" + params.append(limit) + else: + if before is not None and before_id is not None: + query += " AND (received_at < ? OR (received_at = ? AND id < ?))" + params.extend([before, before, before_id]) + + query += " ORDER BY received_at DESC, id DESC LIMIT ?" + params.append(limit) + if before is None or before_id is None: + query += " OFFSET ?" + params.append(offset) cursor = await db.conn.execute(query, params) rows = await cursor.fetchall() - return [ - Message( - id=row["id"], - type=row["type"], - conversation_key=row["conversation_key"], - text=row["text"], - sender_timestamp=row["sender_timestamp"], - received_at=row["received_at"], - paths=MessageRepository._parse_paths(row["paths"]), - txt_type=row["txt_type"], - signature=row["signature"], - outgoing=bool(row["outgoing"]), - acked=row["acked"], - ) - for row in rows + return [MessageRepository._row_to_message(row) for row in rows] + + @staticmethod + async def get_around( + message_id: int, + msg_type: str | None = None, + conversation_key: str | None = None, + context_size: int = 100, + ) -> tuple[list[Message], bool, bool]: + """Get messages around a target message. + + Returns (messages, has_older, has_newer). + """ + # Build common WHERE clause for optional conversation/type filtering. + # If the target message doesn't match filters, return an empty result. + where_parts: list[str] = [] + base_params: list[Any] = [] + if msg_type: + where_parts.append("type = ?") + base_params.append(msg_type) + if conversation_key: + clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key) + where_parts.append(clause.removeprefix("AND ")) + base_params.append(norm_key) + + where_sql = " AND ".join(["1=1", *where_parts]) + + # 1. Get the target message (must satisfy filters if provided) + target_cursor = await db.conn.execute( + f"SELECT * FROM messages WHERE id = ? AND {where_sql}", + (message_id, *base_params), + ) + target_row = await target_cursor.fetchone() + if not target_row: + return [], False, False + + target = MessageRepository._row_to_message(target_row) + + # 2. Get context_size+1 messages before target (DESC) + before_query = f""" + SELECT * FROM messages WHERE {where_sql} + AND (received_at < ? OR (received_at = ? AND id < ?)) + ORDER BY received_at DESC, id DESC LIMIT ? + """ + before_params = [ + *base_params, + target.received_at, + target.received_at, + target.id, + context_size + 1, ] + before_cursor = await db.conn.execute(before_query, before_params) + before_rows = list(await before_cursor.fetchall()) + + has_older = len(before_rows) > context_size + before_messages = [MessageRepository._row_to_message(r) for r in before_rows[:context_size]] + + # 3. Get context_size+1 messages after target (ASC) + after_query = f""" + SELECT * FROM messages WHERE {where_sql} + AND (received_at > ? OR (received_at = ? AND id > ?)) + ORDER BY received_at ASC, id ASC LIMIT ? + """ + after_params = [ + *base_params, + target.received_at, + target.received_at, + target.id, + context_size + 1, + ] + after_cursor = await db.conn.execute(after_query, after_params) + after_rows = list(await after_cursor.fetchall()) + + has_newer = len(after_rows) > context_size + after_messages = [MessageRepository._row_to_message(r) for r in after_rows[:context_size]] + + # Combine: before (reversed to ASC) + target + after + all_messages = list(reversed(before_messages)) + [target] + after_messages + return all_messages, has_older, has_newer @staticmethod async def increment_ack_count(message_id: int) -> int: @@ -212,31 +310,14 @@ class MessageRepository: async def get_by_id(message_id: int) -> "Message | None": """Look up a message by its ID.""" cursor = await db.conn.execute( - """ - SELECT id, type, conversation_key, text, sender_timestamp, received_at, - paths, txt_type, signature, outgoing, acked - FROM messages - WHERE id = ? - """, + "SELECT * FROM messages WHERE id = ?", (message_id,), ) row = await cursor.fetchone() if not row: return None - return Message( - id=row["id"], - type=row["type"], - conversation_key=row["conversation_key"], - text=row["text"], - sender_timestamp=row["sender_timestamp"], - received_at=row["received_at"], - paths=MessageRepository._parse_paths(row["paths"]), - txt_type=row["txt_type"], - signature=row["signature"], - outgoing=bool(row["outgoing"]), - acked=row["acked"], - ) + return MessageRepository._row_to_message(row) @staticmethod async def get_by_content( @@ -248,9 +329,7 @@ class MessageRepository: """Look up a message by its unique content fields.""" cursor = await db.conn.execute( """ - SELECT id, type, conversation_key, text, sender_timestamp, received_at, - paths, txt_type, signature, outgoing, acked - FROM messages + SELECT * FROM messages WHERE type = ? AND conversation_key = ? AND text = ? AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL)) """, @@ -260,29 +339,7 @@ class MessageRepository: if not row: return None - paths = None - if row["paths"]: - try: - paths_data = json.loads(row["paths"]) - paths = [ - MessagePath(path=p["path"], received_at=p["received_at"]) for p in paths_data - ] - except (json.JSONDecodeError, KeyError): - pass - - return Message( - id=row["id"], - type=row["type"], - conversation_key=row["conversation_key"], - text=row["text"], - sender_timestamp=row["sender_timestamp"], - received_at=row["received_at"], - paths=paths, - txt_type=row["txt_type"], - signature=row["signature"], - outgoing=bool(row["outgoing"]), - acked=row["acked"], - ) + return MessageRepository._row_to_message(row) @staticmethod async def get_unread_counts(name: str | None = None) -> dict: diff --git a/app/routers/messages.py b/app/routers/messages.py index 72bf7b2..5c77c8e 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -7,7 +7,12 @@ from meshcore import EventType from app.dependencies import require_connected from app.event_handlers import track_pending_ack -from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest +from app.models import ( + Message, + MessagesAroundResponse, + SendChannelMessageRequest, + SendDirectMessageRequest, +) from app.radio import radio_manager from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository from app.websocket import broadcast_event @@ -16,6 +21,23 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/messages", tags=["messages"]) +@router.get("/around/{message_id}", response_model=MessagesAroundResponse) +async def get_messages_around( + message_id: int, + type: str | None = Query(default=None, description="Filter by type: PRIV or CHAN"), + conversation_key: str | None = Query(default=None, description="Filter by conversation key"), + context: int = Query(default=100, ge=1, le=500, description="Number of messages before/after"), +) -> MessagesAroundResponse: + """Get messages around a specific message for jump-to-message navigation.""" + messages, has_older, has_newer = await MessageRepository.get_around( + message_id=message_id, + msg_type=type, + conversation_key=conversation_key, + context_size=context, + ) + return MessagesAroundResponse(messages=messages, has_older=has_older, has_newer=has_newer) + + @router.get("", response_model=list[Message]) async def list_messages( limit: int = Query(default=100, ge=1, le=1000), @@ -28,6 +50,13 @@ async def list_messages( default=None, description="Cursor: received_at of last seen message" ), before_id: int | None = Query(default=None, description="Cursor: id of last seen message"), + after: int | None = Query( + default=None, description="Forward cursor: received_at of last seen message" + ), + after_id: int | None = Query( + default=None, description="Forward cursor: id of last seen message" + ), + q: str | None = Query(default=None, description="Full-text search query"), ) -> list[Message]: """List messages from the database.""" return await MessageRepository.get_all( @@ -37,6 +66,9 @@ async def list_messages( conversation_key=conversation_key, before=before, before_id=before_id, + after=after, + after_id=after_id, + q=q, ) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 255f41a..209e18e 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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`. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 68b9fed..d99c363 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(null); const [infoPaneChannelKey, setInfoPaneChannelKey] = useState(null); + const [targetMessageId, setTargetMessageId] = useState(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() {
-
+
{activeConversation ? ( activeConversation.type === 'map' ? ( <> @@ -597,7 +648,7 @@ export function App() {
- ) : activeContactIsRepeater ? ( + ) : activeConversation.type === 'search' ? null : activeContactIsRepeater ? ( @@ -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} /> + {searchMounted.current && ( +
+ + Loading search... +
+ } + > + +
+
+ )} + {showSettings && (
@@ -754,7 +834,7 @@ export function App() { undecryptedCount={undecryptedCount} onClose={() => setShowNewMessage(false)} onSelectConversation={(conv) => { - setActiveConversation(conv); + handleSelectConversationWithTargetReset(conv); setShowNewMessage(false); }} onCreateContact={handleCreateContact} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 8b2e8c0..d060199 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -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(`/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( + `/messages/around/${messageId}${query ? `?${query}` : ''}`, + { signal } + ); + }, sendDirectMessage: (destination: string, text: string) => fetchJson('/messages/direct', { method: 'POST', diff --git a/frontend/src/components/ChatHeader.tsx b/frontend/src/components/ChatHeader.tsx index cf81dec..6f1be29 100644 --- a/frontend/src/components/ChatHeader.tsx +++ b/frontend/src/components/ChatHeader.tsx @@ -46,6 +46,7 @@ export function ChatHeader({ onKeyDown={handleKeyboardActivate} onClick={() => onOpenContactInfo(conversation.id)} title="View contact info" + aria-label={`View info for ${conversation.name}`} > 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(null); const prevMessagesLengthRef = useRef(0); @@ -167,6 +179,8 @@ export function MessageList({ } | null>(null); const [resendableIds, setResendableIds] = useState>(new Set()); const resendTimersRef = useRef>>(new Map()); + const [highlightedMessageId, setHighlightedMessageId] = useState(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 (
{showAvatar && ( @@ -618,6 +674,16 @@ export function MessageList({
); })} + {loadingNewer && ( +
+ Loading newer messages... +
+ )} + {!loadingNewer && hasNewerMessages && ( +
+ Scroll down for newer messages +
+ )}
{/* Scroll to bottom button */} diff --git a/frontend/src/components/SearchView.tsx b/frontend/src/components/SearchView.tsx new file mode 100644 index 0000000..17981d9 --- /dev/null +++ b/frontend/src/components/SearchView.tsx @@ -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( + + {segments[i]} + + ); + } 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([]); + const [loading, setLoading] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [offset, setOffset] = useState(0); + const abortRef = useRef(null); + const inputRef = useRef(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 ( +
+ {/* Header */} +
+ Message Search +
+ + {/* Search input */} +
+ setQuery(e.target.value)} + className="h-9 text-sm" + aria-label="Search messages" + /> +
+ + {/* Results */} +
+ {!debouncedQuery && ( +
+ Type to search across all messages +
+ )} + + {debouncedQuery && results.length === 0 && !loading && ( +
+ No messages found for “{debouncedQuery}” +
+ )} + + {results.map((result) => { + const convName = getConversationName(result); + const typeBadge = result.type === 'CHAN' ? 'Channel' : 'DM'; + + return ( +
handleResultClick(result)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleResultClick(result); + } + }} + > +
+ + {typeBadge} + + {convName} + + {formatTime(result.received_at)} + +
+
+ {result.sender_name && !result.outgoing && ( + {result.sender_name}: + )} + {result.outgoing && You: } + {highlightMatch( + result.sender_name && result.text.startsWith(`${result.sender_name}: `) + ? result.text.slice(result.sender_name.length + 2) + : result.text, + debouncedQuery + )} +
+
+ ); + })} + + {loading && ( +
Searching...
+ )} + + {hasMore && !loading && ( +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9a336fa..376c9ff 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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({
)} + {/* Message Search */} + {!query && ( +
+ handleSelectConversation({ + type: 'search', + id: 'search', + name: 'Message Search', + }) + } + > + + Message Search +
+ )} + {/* Cracker Toggle */} {!query && (
; setMessages: React.Dispatch>; fetchOlderMessages: () => Promise; + fetchNewerMessages: () => Promise; + 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([]); const [messagesLoading, setMessagesLoading] = useState(false); const [loadingOlder, setLoadingOlder] = useState(false); const [hasOlderMessages, setHasOlderMessages] = useState(false); + const [hasNewerMessages, setHasNewerMessages] = useState(false); + const [loadingNewer, setLoadingNewer] = useState(false); // Track seen message content for deduplication const seenMessageContent = useRef>(new Set()); @@ -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([]); const hasOlderMessagesRef = useRef(false); + const hasNewerMessagesRef = useRef(false); const prevConversationIdRef = useRef(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, diff --git a/frontend/src/hooks/useConversationRouter.ts b/frontend/src/hooks/useConversationRouter.ts index 94d3c0e..2bd2500 100644 --- a/frontend/src/hooks/useConversationRouter.ts +++ b/frontend/src/hooks/useConversationRouter.ts @@ -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); } } diff --git a/frontend/src/index.css b/frontend/src/index.css index 379fb47..8acd741 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; diff --git a/frontend/src/test/appSearchJump.test.tsx b/frontend/src/test/appSearchJump.test.tsx new file mode 100644 index 0000000..77ef0cb --- /dev/null +++ b/frontend/src/test/appSearchJump.test.tsx @@ -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(); + 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: () =>
, +})); + +vi.mock('../components/Sidebar', () => ({ + Sidebar: ({ + onSelectConversation, + activeConversation, + }: { + onSelectConversation: (conv: { type: 'search' | 'channel'; id: string; name: string }) => void; + activeConversation: { type: string; id: string } | null; + }) => ( +
+ + +
+ {activeConversation ? `${activeConversation.type}:${activeConversation.id}` : 'none'} +
+
+ ), +})); + +vi.mock('../components/ChatHeader', () => ({ + ChatHeader: () =>
, +})); + +vi.mock('../components/MessageList', () => ({ + MessageList: () =>
, +})); + +vi.mock('../components/MessageInput', () => ({ + MessageInput: React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ appendText: vi.fn() })); + return
; + }), +})); + +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; + }) => ( + + ), +})); + +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 }) =>
{children}
, + SheetContent: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetHeader: ({ children }: { children: React.ReactNode }) =>
{children}
, + SheetTitle: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +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(); + + 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(); + }); + }); +}); diff --git a/frontend/src/test/integration.test.ts b/frontend/src/test/integration.test.ts index 842f69e..032d316 100644 --- a/frontend/src/test/integration.test.ts +++ b/frontend/src/test/integration.test.ts @@ -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'); diff --git a/frontend/src/test/messageCache.test.ts b/frontend/src/test/messageCache.test.ts index a7be93a..89e5cea 100644 --- a/frontend/src/test/messageCache.test.ts +++ b/frontend/src/test/messageCache.test.ts @@ -20,6 +20,7 @@ function createMessage(overrides: Partial = {}): Message { signature: null, outgoing: false, acked: 0, + sender_name: null, ...overrides, }; } diff --git a/frontend/src/test/searchView.test.tsx b/frontend/src/test/searchView.test.tsx new file mode 100644 index 0000000..ad5fcb5 --- /dev/null +++ b/frontend/src/test/searchView.test.tsx @@ -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>(); + +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 { + 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(); + expect(screen.getByText('Type to search across all messages')).toBeInTheDocument(); + }); + + it('focuses input on mount', () => { + mockGetMessages.mockResolvedValue([]); + render(); + expect(screen.getByLabelText('Search messages')).toHaveFocus(); + }); + + it('debounces search input', async () => { + mockGetMessages.mockResolvedValue([]); + vi.useFakeTimers(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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( + + ); + + await typeAndWaitForResults('dm'); + + expect(screen.getByText('Bob')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/test/useConversationMessages.race.test.ts b/frontend/src/test/useConversationMessages.race.test.ts index 0c61898..88a8953 100644 --- a/frontend/src/test/useConversationMessages.race.test.ts +++ b/frontend/src/test/useConversationMessages.race.test.ts @@ -6,10 +6,12 @@ import { useConversationMessages } from '../hooks/useConversationMessages'; import type { Conversation, Message } from '../types'; const mockGetMessages = vi.fn<(...args: unknown[]) => Promise>(); +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 { 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'); + }); +}); diff --git a/frontend/src/test/useConversationMessages.test.ts b/frontend/src/test/useConversationMessages.test.ts index 883054d..efe85e6 100644 --- a/frontend/src/test/useConversationMessages.test.ts +++ b/frontend/src/test/useConversationMessages.test.ts @@ -21,6 +21,7 @@ function createMessage(overrides: Partial = {}): Message { signature: null, outgoing: false, acked: 0, + sender_name: null, ...overrides, }; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 7f6b654..24c84d7 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; diff --git a/frontend/src/utils/urlHash.ts b/frontend/src/utils/urlHash.ts index 667cd45..6cd9bb4 100644 --- a/frontend/src/utils/urlHash.ts +++ b/frontend/src/utils/urlHash.ts @@ -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') { diff --git a/tests/e2e/specs/channel-info-and-search.spec.ts b/tests/e2e/specs/channel-info-and-search.spec.ts new file mode 100644 index 0000000..adccab2 --- /dev/null +++ b/tests/e2e/specs/channel-info-and-search.spec.ts @@ -0,0 +1,147 @@ +import { test, expect } from '@playwright/test'; +import { seedChannelMessages } from '../helpers/seed'; +import { ensureFlightlessChannel } from '../helpers/api'; + +const CHANNEL_NAME = '#flightless'; +const SEED_COUNT = 30; + +/** + * Seed #flightless with unique, searchable messages. + * Returns the channel key and a unique search token. + */ +function seedFlightlessMessages() { + const token = `e2e-search-${Date.now()}`; + const seeded = seedChannelMessages({ + channelName: CHANNEL_NAME, + count: SEED_COUNT, + // Use unique text so search can find them reliably + // Note: seedChannelMessages uses "seed-N" as text by default, + // but we need our unique token in there. We'll search for "seed-" instead + // since that's what the seed helper generates. + outgoingEvery: 5, + includePaths: true, + }); + return { key: seeded.key, token }; +} + +test.describe('Channel info pane', () => { + let channelKey: string; + + test.beforeAll(async () => { + await ensureFlightlessChannel(); + const seeded = seedFlightlessMessages(); + channelKey = seeded.key; + }); + + test('opens channel info pane and shows message activity', async ({ page }) => { + await page.goto(`/#channel/${channelKey}/flightless`); + await expect(page.getByText('Connected')).toBeVisible(); + + // Wait for messages to load + await expect(page.getByText('seed-0')).toBeVisible({ timeout: 15_000 }); + + // Click the channel name in the header to open info pane + const headerTitle = page.locator('h2').filter({ hasText: '#flightless' }); + await headerTitle.click(); + + // Channel info pane should open as a sheet + const infoPane = page.getByRole('dialog'); + await expect(infoPane).toBeVisible({ timeout: 10_000 }); + + // Should show channel name + await expect(infoPane.getByText('#flightless')).toBeVisible(); + + // Should show channel key + await expect(infoPane.getByText(channelKey.toLowerCase())).toBeVisible(); + + // Should show "Hashtag" badge + await expect(infoPane.getByText('Hashtag')).toBeVisible(); + + // Should show "Message Activity" section with counts + await expect(infoPane.getByText('Message Activity')).toBeVisible(); + await expect(infoPane.getByText('All Time')).toBeVisible(); + + // All Time count should be non-zero (our seeded messages) + // InfoItem renders: All Time

VALUE

— use CSS sibling selector + const allTimeValue = infoPane.locator('span:text-is("All Time") + p'); + const count = await allTimeValue.textContent(); + expect(Number(count?.replace(/,/g, ''))).toBeGreaterThanOrEqual(SEED_COUNT); + + // Should show "First Message" section + await expect(infoPane.getByText('First Message')).toBeVisible(); + }); +}); + +test.describe('Message search and jump-to-message', () => { + let channelKey: string; + + test.beforeAll(async () => { + await ensureFlightlessChannel(); + const seeded = seedFlightlessMessages(); + channelKey = seeded.key; + }); + + test('search finds seeded messages', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Connected')).toBeVisible(); + + // Open search view via sidebar + await page.getByText('Message Search').click(); + + // Should show search input + const searchInput = page.getByPlaceholder('Search all messages...'); + await expect(searchInput).toBeVisible(); + + // Search for seeded messages (seed helper creates "seed-N" text) + await searchInput.fill('seed-1'); + + // Wait for search results to appear + await expect(page.getByText('seed-1', { exact: false }).first()).toBeVisible({ + timeout: 10_000, + }); + + // Results should show the channel name + await expect(page.getByText('#flightless').first()).toBeVisible(); + + // Results should show "Channel" badge + await expect(page.getByText('Channel').first()).toBeVisible(); + }); + + test('clicking a search result jumps to the message in conversation', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Connected')).toBeVisible(); + + // Open search + await page.getByText('Message Search').click(); + + const searchInput = page.getByPlaceholder('Search all messages...'); + await searchInput.fill('seed-15'); + + // Wait for results + const result = page.getByText('seed-15', { exact: false }).first(); + await expect(result).toBeVisible({ timeout: 10_000 }); + + // Click the search result to jump to it + await result.click(); + + // Should navigate to the #flightless conversation + await expect(page.getByPlaceholder(/message #flightless/i)).toBeVisible({ timeout: 15_000 }); + + // The target message should be visible in the conversation (not search results) + // Scope to [data-message-id] to avoid matching leftover search elements + const messageContainer = page.locator('[data-message-id]').filter({ hasText: 'seed-15' }); + await expect(messageContainer.first()).toBeVisible({ timeout: 10_000 }); + }); + + test('search returns no results for nonsense query', async ({ page }) => { + await page.goto('/'); + await expect(page.getByText('Connected')).toBeVisible(); + + await page.getByText('Message Search').click(); + + const searchInput = page.getByPlaceholder('Search all messages...'); + await searchInput.fill('zzz-nonexistent-query-zzz'); + + await expect(page.getByText(/No messages found/)).toBeVisible({ timeout: 10_000 }); + }); +}); diff --git a/tests/test_event_handlers.py b/tests/test_event_handlers.py index 8da5a61..25b5c80 100644 --- a/tests/test_event_handlers.py +++ b/tests/test_event_handlers.py @@ -322,6 +322,7 @@ class TestContactMessageCLIFiltering: "signature", "outgoing", "acked", + "sender_name", } with ( diff --git a/tests/test_messages_search.py b/tests/test_messages_search.py new file mode 100644 index 0000000..08d578d --- /dev/null +++ b/tests/test_messages_search.py @@ -0,0 +1,579 @@ +"""Tests for message search, around, and forward pagination.""" + +import pytest + +from app.radio import radio_manager +from app.repository import MessageRepository + +CHAN_KEY = "ABC123DEF456ABC123DEF456ABC12345" +DM_KEY = "aa" * 32 +OTHER_CHAN_KEY = "FF" * 16 + + +class TestMessageSearch: + """Tests for the q (search) parameter on get_all.""" + + @pytest.mark.asyncio + async def test_basic_search(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="hello world", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="goodbye moon", + conversation_key=CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q="hello") + assert len(results) == 1 + assert results[0].text == "hello world" + + @pytest.mark.asyncio + async def test_search_case_insensitive(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="Hello World", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + + results = await MessageRepository.get_all(q="hello") + assert len(results) == 1 + + results = await MessageRepository.get_all(q="HELLO") + assert len(results) == 1 + + @pytest.mark.asyncio + async def test_search_pagination(self, test_db): + for i in range(5): + await MessageRepository.create( + msg_type="CHAN", + text=f"test message {i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + + results = await MessageRepository.get_all(q="test message", limit=2) + assert len(results) == 2 + + results = await MessageRepository.get_all(q="test message", limit=2, offset=2) + assert len(results) == 2 + + @pytest.mark.asyncio + async def test_search_within_conversation(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="hello from channel", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="hello from other", + conversation_key=OTHER_CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q="hello", conversation_key=CHAN_KEY) + assert len(results) == 1 + assert results[0].text == "hello from channel" + + @pytest.mark.asyncio + async def test_search_no_results(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="hello world", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + + results = await MessageRepository.get_all(q="nonexistent") + assert len(results) == 0 + + @pytest.mark.asyncio + async def test_search_across_types(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="search target in chan", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="PRIV", + text="search target in dm", + conversation_key=DM_KEY, + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q="search target") + assert len(results) == 2 + + @pytest.mark.asyncio + async def test_search_returns_sender_name(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="Alice: hello world", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + sender_name="Alice", + ) + + results = await MessageRepository.get_all(q="hello") + assert len(results) == 1 + assert results[0].sender_name == "Alice" + + +class TestMessagesAround: + """Tests for get_around().""" + + @pytest.mark.asyncio + async def test_returns_context(self, test_db): + ids = [] + for i in range(10): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=f"msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + ids.append(msg_id) + + # Get around the middle message (index 5) + messages, has_older, has_newer = await MessageRepository.get_around( + message_id=ids[5], + msg_type="CHAN", + conversation_key=CHAN_KEY, + ) + + assert len(messages) == 10 + assert not has_older # Only 5 before, context_size defaults to 100 + assert not has_newer # Only 4 after + + @pytest.mark.asyncio + async def test_has_older_has_newer(self, test_db): + ids = [] + for i in range(20): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=f"msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + ids.append(msg_id) + + messages, has_older, has_newer = await MessageRepository.get_around( + message_id=ids[10], + msg_type="CHAN", + conversation_key=CHAN_KEY, + context_size=3, + ) + + # 3 before + target + 3 after = 7 + assert len(messages) == 7 + assert has_older # 10 messages before, context_size=3 + assert has_newer # 9 messages after, context_size=3 + + @pytest.mark.asyncio + async def test_nonexistent_message(self, test_db): + messages, has_older, has_newer = await MessageRepository.get_around( + message_id=99999, + ) + assert messages == [] + assert not has_older + assert not has_newer + + @pytest.mark.asyncio + async def test_conversation_key_filter(self, test_db): + # Create messages in two channels + for i in range(5): + await MessageRepository.create( + msg_type="CHAN", + text=f"chan1 msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + for i in range(5): + await MessageRepository.create( + msg_type="CHAN", + text=f"chan2 msg{i}", + conversation_key=OTHER_CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + + # Get the target from channel 1 + all_chan1 = await MessageRepository.get_all(conversation_key=CHAN_KEY) + target_id = all_chan1[2].id + + messages, _, _ = await MessageRepository.get_around( + message_id=target_id, + msg_type="CHAN", + conversation_key=CHAN_KEY, + ) + + # All returned messages should be from channel 1 + for msg in messages: + assert msg.conversation_key == CHAN_KEY + + @pytest.mark.asyncio + async def test_context_size(self, test_db): + ids = [] + for i in range(10): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=f"msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + ids.append(msg_id) + + messages, has_older, has_newer = await MessageRepository.get_around( + message_id=ids[5], + msg_type="CHAN", + conversation_key=CHAN_KEY, + context_size=2, + ) + + # 2 before + target + 2 after = 5 + assert len(messages) == 5 + assert has_older # 5 before, context=2 + assert has_newer # 4 after, context=2 + + @pytest.mark.asyncio + async def test_target_not_in_filtered_conversation_returns_empty(self, test_db): + target_id = await MessageRepository.create( + msg_type="CHAN", + text="target in channel 1", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="message in channel 2", + conversation_key=OTHER_CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + messages, has_older, has_newer = await MessageRepository.get_around( + message_id=target_id, + msg_type="CHAN", + conversation_key=OTHER_CHAN_KEY, + ) + + assert messages == [] + assert not has_older + assert not has_newer + + +class TestForwardPagination: + """Tests for the after/after_id forward cursor on get_all.""" + + @pytest.mark.asyncio + async def test_forward_pagination(self, test_db): + ids = [] + for i in range(5): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=f"msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + ids.append(msg_id) + + # Get first page (DESC order) + page1 = await MessageRepository.get_all( + msg_type="CHAN", + conversation_key=CHAN_KEY, + limit=3, + ) + assert len(page1) == 3 + # Page 1 is DESC: msg4, msg3, msg2 + + # Get forward from msg2 (oldest in page1) + newest = page1[0] # msg4 + forward = await MessageRepository.get_all( + msg_type="CHAN", + conversation_key=CHAN_KEY, + after=newest.received_at, + after_id=newest.id, + limit=10, + ) + # Nothing newer than msg4 + assert len(forward) == 0 + + @pytest.mark.asyncio + async def test_forward_pagination_returns_asc(self, test_db): + ids = [] + for i in range(5): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=f"msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + ids.append(msg_id) + + # Forward from the first message + forward = await MessageRepository.get_all( + msg_type="CHAN", + conversation_key=CHAN_KEY, + after=100, + after_id=ids[0], + limit=10, + ) + assert len(forward) == 4 # msg1, msg2, msg3, msg4 + # Should be ASC order + for i in range(len(forward) - 1): + assert forward[i].received_at <= forward[i + 1].received_at + + @pytest.mark.asyncio + async def test_forward_with_conversation_key(self, test_db): + for i in range(3): + await MessageRepository.create( + msg_type="CHAN", + text=f"chan1 msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + for i in range(3): + await MessageRepository.create( + msg_type="CHAN", + text=f"chan2 msg{i}", + conversation_key=OTHER_CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + + chan1_msgs = await MessageRepository.get_all(conversation_key=CHAN_KEY) + oldest = chan1_msgs[-1] + + forward = await MessageRepository.get_all( + conversation_key=CHAN_KEY, + after=oldest.received_at, + after_id=oldest.id, + limit=10, + ) + for msg in forward: + assert msg.conversation_key == CHAN_KEY + + +class TestSearchLikeEscaping: + """Tests for LIKE wildcard escaping in search.""" + + @pytest.mark.asyncio + async def test_percent_in_query_is_literal(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="100% done", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="100 items done", + conversation_key=CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q="100%") + assert len(results) == 1 + assert results[0].text == "100% done" + + @pytest.mark.asyncio + async def test_underscore_in_query_is_literal(self, test_db): + await MessageRepository.create( + msg_type="CHAN", + text="hello_world", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="helloXworld", + conversation_key=CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + results = await MessageRepository.get_all(q="hello_world") + assert len(results) == 1 + assert results[0].text == "hello_world" + + +@pytest.fixture(autouse=True) +def _reset_radio_state(): + """Save/restore radio_manager state so tests don't leak.""" + prev = radio_manager._meshcore + prev_lock = radio_manager._operation_lock + yield + radio_manager._meshcore = prev + radio_manager._operation_lock = prev_lock + + +class TestMessagesAroundEndpoint: + """HTTP-level tests for GET /api/messages/around/{id}.""" + + @pytest.mark.asyncio + async def test_around_returns_context(self, test_db, client): + ids = [] + for i in range(10): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=f"msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + ids.append(msg_id) + + response = await client.get( + f"/api/messages/around/{ids[5]}", + params={"type": "CHAN", "conversation_key": CHAN_KEY}, + ) + + assert response.status_code == 200 + body = response.json() + assert "messages" in body + assert "has_older" in body + assert "has_newer" in body + assert len(body["messages"]) == 10 + assert not body["has_older"] + assert not body["has_newer"] + + @pytest.mark.asyncio + async def test_around_nonexistent_returns_empty(self, test_db, client): + response = await client.get("/api/messages/around/99999") + + assert response.status_code == 200 + body = response.json() + assert body["messages"] == [] + assert not body["has_older"] + assert not body["has_newer"] + + @pytest.mark.asyncio + async def test_around_respects_context_param(self, test_db, client): + ids = [] + for i in range(20): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=f"msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + ids.append(msg_id) + + response = await client.get( + f"/api/messages/around/{ids[10]}", + params={"type": "CHAN", "conversation_key": CHAN_KEY, "context": 3}, + ) + + assert response.status_code == 200 + body = response.json() + # 3 before + target + 3 after = 7 + assert len(body["messages"]) == 7 + assert body["has_older"] + assert body["has_newer"] + + @pytest.mark.asyncio + async def test_around_message_fields_serialized(self, test_db, client): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text="Alice: test message", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + sender_name="Alice", + ) + + response = await client.get(f"/api/messages/around/{msg_id}") + assert response.status_code == 200 + body = response.json() + assert len(body["messages"]) == 1 + msg = body["messages"][0] + assert msg["id"] == msg_id + assert msg["type"] == "CHAN" + assert msg["text"] == "Alice: test message" + assert msg["sender_name"] == "Alice" + + +class TestSearchEndpoint: + """HTTP-level tests for GET /api/messages?q=...""" + + @pytest.mark.asyncio + async def test_search_via_endpoint(self, test_db, client): + await MessageRepository.create( + msg_type="CHAN", + text="hello world", + conversation_key=CHAN_KEY, + sender_timestamp=100, + received_at=100, + ) + await MessageRepository.create( + msg_type="CHAN", + text="goodbye moon", + conversation_key=CHAN_KEY, + sender_timestamp=101, + received_at=101, + ) + + response = await client.get("/api/messages", params={"q": "hello"}) + assert response.status_code == 200 + results = response.json() + assert len(results) == 1 + assert results[0]["text"] == "hello world" + + @pytest.mark.asyncio + async def test_forward_pagination_via_endpoint(self, test_db, client): + ids = [] + for i in range(5): + msg_id = await MessageRepository.create( + msg_type="CHAN", + text=f"msg{i}", + conversation_key=CHAN_KEY, + sender_timestamp=100 + i, + received_at=100 + i, + ) + ids.append(msg_id) + + response = await client.get( + "/api/messages", + params={ + "type": "CHAN", + "conversation_key": CHAN_KEY, + "after": 100, + "after_id": ids[0], + }, + ) + assert response.status_code == 200 + results = response.json() + assert len(results) == 4 + # Forward results should be ASC + for i in range(len(results) - 1): + assert results[i]["received_at"] <= results[i + 1]["received_at"]