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

View File

@@ -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) |

View File

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

View File

@@ -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):

View File

@@ -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:

View File

@@ -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,
)

View File

@@ -61,6 +61,7 @@ frontend/src/
│ ├── MessageList.tsx
│ ├── MessageInput.tsx
│ ├── NewMessageModal.tsx
│ ├── SearchView.tsx # Full-text message search pane
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
│ ├── RawPacketList.tsx
│ ├── MapView.tsx
@@ -186,6 +187,7 @@ Supported routes:
- `#map`
- `#map/focus/{pubkey_or_prefix}`
- `#visualizer`
- `#search`
- `#channel/{channelKey}`
- `#channel/{channelKey}/{label}`
- `#contact/{publicKey}`
@@ -285,6 +287,16 @@ For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
## Message Search Pane
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
- **State**: `targetMessageId` in `App.tsx` drives the jump-to-message flow. When a search result is clicked, `handleNavigateToMessage` sets `targetMessageId` and switches to the target conversation.
- **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results.
- **Jump-to-message**: `useConversationMessages` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
- **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling.
- **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
## Styling
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.

View File

@@ -51,6 +51,9 @@ const SettingsModal = lazy(() =>
const CrackerPanel = lazy(() =>
import('./components/CrackerPanel').then((m) => ({ default: m.CrackerPanel }))
);
const SearchView = lazy(() =>
import('./components/SearchView').then((m) => ({ default: m.SearchView }))
);
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
import { getStateKey } from './utils/conversationState';
@@ -59,6 +62,7 @@ import { messageContainsMention } from './utils/messageParser';
import { mergeContactIntoList } from './utils/contactMerge';
import { getLocalLabel, getContrastTextColor } from './utils/localLabel';
import { cn } from '@/lib/utils';
import type { SearchNavigateTarget } from './components/SearchView';
import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types';
const MAX_RAW_PACKETS = 500;
@@ -75,6 +79,7 @@ export function App() {
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
const [targetMessageId, setTargetMessageId] = useState<number | null>(null);
// Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state)
const crackerMounted = useRef(false);
@@ -166,17 +171,26 @@ export function App() {
// Wire up the ref bridge so useContactsAndChannels handlers reach the real setter
setActiveConversationRef.current = setActiveConversation;
// Keep SearchView mounted after first open to preserve search state
const searchMounted = useRef(false);
if (activeConversation?.type === 'search') searchMounted.current = true;
// Custom hooks for conversation-specific functionality
const {
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
} = useConversationMessages(activeConversation);
} = useConversationMessages(activeConversation, targetMessageId);
const {
unreadCounts,
@@ -257,7 +271,8 @@ export function App() {
})();
// Only add to message list if it's for the active conversation
if (isForActiveConversation) {
// and we're not viewing historical messages (hasNewerMessages means we jumped mid-history)
if (isForActiveConversation && !hasNewerMessagesRef.current) {
addMessageIfNew(msg);
}
@@ -315,6 +330,7 @@ export function App() {
setHealth,
setConfig,
activeConversationRef,
hasNewerMessagesRef,
setContacts,
setChannels,
triggerReconcile,
@@ -451,15 +467,45 @@ export function App() {
setInfoPaneChannelKey(null);
}, []);
const handleSelectConversationWithTargetReset = useCallback(
(conv: Conversation, options?: { preserveTarget?: boolean }) => {
if (conv.type !== 'search' && !options?.preserveTarget) {
setTargetMessageId(null);
}
handleSelectConversation(conv);
},
[handleSelectConversation]
);
const handleNavigateToChannel = useCallback(
(channelKey: string) => {
const channel = channels.find((c) => c.key === channelKey);
if (channel) {
handleSelectConversation({ type: 'channel', id: channel.key, name: channel.name });
handleSelectConversationWithTargetReset({
type: 'channel',
id: channel.key,
name: channel.name,
});
setInfoPaneContactKey(null);
}
},
[channels, handleSelectConversation]
[channels, handleSelectConversationWithTargetReset]
);
const handleNavigateToMessage = useCallback(
(target: SearchNavigateTarget) => {
const convType = target.type === 'CHAN' ? 'channel' : 'contact';
setTargetMessageId(target.id);
handleSelectConversationWithTargetReset(
{
type: convType,
id: target.conversation_key,
name: target.conversation_name,
},
{ preserveTarget: true }
);
},
[handleSelectConversationWithTargetReset]
);
// Sidebar content (shared between desktop and mobile)
@@ -468,7 +514,7 @@ export function App() {
contacts={contacts}
channels={channels}
activeConversation={activeConversation}
onSelectConversation={handleSelectConversation}
onSelectConversation={handleSelectConversationWithTargetReset}
onNewMessage={handleNewMessage}
lastMessageTimes={lastMessageTimes}
unreadCounts={unreadCounts}
@@ -559,7 +605,12 @@ export function App() {
</Sheet>
<main className="flex-1 flex flex-col bg-background min-w-0">
<div className={cn('flex-1 flex flex-col min-h-0', showSettings && 'hidden')}>
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(showSettings || activeConversation?.type === 'search') && 'hidden'
)}
>
{activeConversation ? (
activeConversation.type === 'map' ? (
<>
@@ -597,7 +648,7 @@ export function App() {
<RawPacketList packets={rawPackets} />
</div>
</>
) : activeContactIsRepeater ? (
) : activeConversation.type === 'search' ? null : activeContactIsRepeater ? (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -650,6 +701,12 @@ export function App() {
radioName={config?.name}
config={config}
onOpenContactInfo={handleOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={() => setTargetMessageId(null)}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={fetchNewerMessages}
onJumpToBottom={jumpToBottom}
/>
<MessageInput
ref={messageInputRef}
@@ -672,6 +729,29 @@ export function App() {
)}
</div>
{searchMounted.current && (
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(activeConversation?.type !== 'search' || showSettings) && 'hidden'
)}
>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading search...
</div>
}
>
<SearchView
contacts={contacts}
channels={channels}
onNavigateToMessage={handleNavigateToMessage}
/>
</Suspense>
</div>
)}
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
@@ -754,7 +834,7 @@ export function App() {
undecryptedCount={undecryptedCount}
onClose={() => setShowNewMessage(false)}
onSelectConversation={(conv) => {
setActiveConversation(conv);
handleSelectConversationWithTargetReset(conv);
setShowNewMessage(false);
}}
onCreateContact={handleCreateContact}

View File

@@ -12,6 +12,7 @@ import type {
HealthStatus,
MaintenanceResult,
Message,
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RadioConfig,
@@ -164,6 +165,9 @@ export const api = {
conversation_key?: string;
before?: number;
before_id?: number;
after?: number;
after_id?: number;
q?: string;
},
signal?: AbortSignal
) => {
@@ -174,9 +178,27 @@ export const api = {
if (params?.conversation_key) searchParams.set('conversation_key', params.conversation_key);
if (params?.before !== undefined) searchParams.set('before', params.before.toString());
if (params?.before_id !== undefined) searchParams.set('before_id', params.before_id.toString());
if (params?.after !== undefined) searchParams.set('after', params.after.toString());
if (params?.after_id !== undefined) searchParams.set('after_id', params.after_id.toString());
if (params?.q) searchParams.set('q', params.q);
const query = searchParams.toString();
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`, { signal });
},
getMessagesAround: (
messageId: number,
type?: 'PRIV' | 'CHAN',
conversationKey?: string,
signal?: AbortSignal
) => {
const searchParams = new URLSearchParams();
if (type) searchParams.set('type', type);
if (conversationKey) searchParams.set('conversation_key', conversationKey);
const query = searchParams.toString();
return fetchJson<MessagesAroundResponse>(
`/messages/around/${messageId}${query ? `?${query}` : ''}`,
{ signal }
);
},
sendDirectMessage: (destination: string, text: string) =>
fetchJson<Message>('/messages/direct', {
method: 'POST',

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

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 */}

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>
);
}

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

View File

@@ -62,20 +62,28 @@ interface UseConversationMessagesResult {
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
hasNewerMessagesRef: React.MutableRefObject<boolean>;
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
fetchOlderMessages: () => Promise<void>;
fetchNewerMessages: () => Promise<void>;
jumpToBottom: () => void;
addMessageIfNew: (msg: Message) => boolean;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
triggerReconcile: () => void;
}
export function useConversationMessages(
activeConversation: Conversation | null
activeConversation: Conversation | null,
targetMessageId?: number | null
): UseConversationMessagesResult {
const [messages, setMessages] = useState<Message[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasOlderMessages, setHasOlderMessages] = useState(false);
const [hasNewerMessages, setHasNewerMessages] = useState(false);
const [loadingNewer, setLoadingNewer] = useState(false);
// Track seen message content for deduplication
const seenMessageContent = useRef<Set<string>>(new Set());
@@ -94,6 +102,7 @@ export function useConversationMessages(
// Keep refs in sync with state so we can read current values in the switch effect
const messagesRef = useRef<Message[]>([]);
const hasOlderMessagesRef = useRef(false);
const hasNewerMessagesRef = useRef(false);
const prevConversationIdRef = useRef<string | null>(null);
useEffect(() => {
@@ -104,6 +113,10 @@ export function useConversationMessages(
hasOlderMessagesRef.current = hasOlderMessages;
}, [hasOlderMessages]);
useEffect(() => {
hasNewerMessagesRef.current = hasNewerMessages;
}, [hasNewerMessages]);
const setPendingAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
const existing = pendingAcksRef.current.get(messageId);
@@ -146,7 +159,8 @@ export function useConversationMessages(
!activeConversation ||
activeConversation.type === 'raw' ||
activeConversation.type === 'map' ||
activeConversation.type === 'visualizer'
activeConversation.type === 'visualizer' ||
activeConversation.type === 'search'
) {
setMessages([]);
setHasOlderMessages(false);
@@ -264,11 +278,86 @@ export function useConversationMessages(
}
}, [activeConversation, loadingOlder, hasOlderMessages, messages, applyPendingAck]);
// Fetch newer messages (forward cursor pagination)
const fetchNewerMessages = useCallback(async () => {
if (
!activeConversation ||
activeConversation.type === 'raw' ||
loadingNewer ||
!hasNewerMessages
)
return;
const conversationId = activeConversation.id;
// Get the newest message as forward cursor
const newestMessage = messages.reduce(
(newest, msg) => {
if (!newest) return msg;
if (msg.received_at > newest.received_at) return msg;
if (msg.received_at === newest.received_at && msg.id > newest.id) return msg;
return newest;
},
null as Message | null
);
if (!newestMessage) return;
setLoadingNewer(true);
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
after: newestMessage.received_at,
after_id: newestMessage.id,
});
if (fetchingConversationIdRef.current !== conversationId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
// Deduplicate against already-seen messages (WS race)
const newMessages = dataWithPendingAck.filter(
(msg) => !seenMessageContent.current.has(getMessageContentKey(msg))
);
if (newMessages.length > 0) {
setMessages((prev) => [...prev, ...newMessages]);
for (const msg of newMessages) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
}
setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch newer messages:', err);
toast.error('Failed to load newer messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
setLoadingNewer(false);
}
}, [activeConversation, loadingNewer, hasNewerMessages, messages, applyPendingAck]);
// Jump to bottom: re-fetch latest page, clear hasNewerMessages
const jumpToBottom = useCallback(() => {
if (!activeConversation) return;
setHasNewerMessages(false);
// Invalidate cache so fetchMessages does a fresh load
messageCache.remove(activeConversation.id);
fetchMessages(true);
}, [activeConversation, fetchMessages]);
// Trigger a background reconciliation for the current conversation.
// Used after WebSocket reconnects to silently recover any missed messages.
const triggerReconcile = useCallback(() => {
const conv = activeConversation;
if (!conv || conv.type === 'raw' || conv.type === 'map' || conv.type === 'visualizer') return;
if (
!conv ||
conv.type === 'raw' ||
conv.type === 'map' ||
conv.type === 'visualizer' ||
conv.type === 'search'
)
return;
const controller = new AbortController();
reconcileFromBackend(conv, controller.signal);
}, [activeConversation]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -318,9 +407,39 @@ export function useConversationMessages(
abortControllerRef.current.abort();
}
// Save outgoing conversation to cache (if it had messages loaded)
const prevId = prevConversationIdRef.current;
if (prevId && messagesRef.current.length > 0) {
// Track which conversation we're now on
const newId = activeConversation?.id ?? null;
const conversationChanged = prevId !== newId;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
// When targetMessageId goes from a value to null (onTargetReached cleared it)
// but the conversation hasn't changed, the around-loaded messages are already
// displayed — do nothing. Without this guard the effect would re-enter the
// normal fetch path and replace the mid-history view with the latest page.
if (!conversationChanged && !targetMessageId) {
return;
}
// Reset loadingOlder/loadingNewer — the previous conversation's in-flight
// fetch is irrelevant now (its stale-check will discard the response).
setLoadingOlder(false);
setLoadingNewer(false);
if (conversationChanged) {
setHasNewerMessages(false);
}
// Save outgoing conversation to cache only when actually leaving it, and
// only if we were on the latest page (mid-history views would restore stale
// partial data on switch-back).
if (
conversationChanged &&
prevId &&
messagesRef.current.length > 0 &&
!hasNewerMessagesRef.current
) {
messageCache.set(prevId, {
messages: messagesRef.current,
seenContent: new Set(seenMessageContent.current),
@@ -328,21 +447,13 @@ export function useConversationMessages(
});
}
// Track which conversation we're now on
const newId = activeConversation?.id ?? null;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
// Reset loadingOlder — the previous conversation's in-flight older-message
// fetch is irrelevant now (its stale-check will discard the response).
setLoadingOlder(false);
// Clear state for non-message views
if (
!activeConversation ||
activeConversation.type === 'raw' ||
activeConversation.type === 'map' ||
activeConversation.type === 'visualizer'
activeConversation.type === 'visualizer' ||
activeConversation.type === 'search'
) {
setMessages([]);
setHasOlderMessages(false);
@@ -353,19 +464,52 @@ export function useConversationMessages(
const controller = new AbortController();
abortControllerRef.current = controller;
// Check cache for the new conversation
const cached = messageCache.get(activeConversation.id);
if (cached) {
// Restore from cache instantly — no spinner
setMessages(cached.messages);
seenMessageContent.current = new Set(cached.seenContent);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
// Silently reconcile with backend in case we missed a WS message
reconcileFromBackend(activeConversation, controller.signal);
// Jump-to-message: skip cache and load messages around the target
if (targetMessageId) {
setMessagesLoading(true);
setMessages([]);
const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV';
api
.getMessagesAround(
targetMessageId,
msgType as 'PRIV' | 'CHAN',
activeConversation.id,
controller.signal
)
.then((response) => {
if (fetchingConversationIdRef.current !== activeConversation.id) return;
const withAcks = response.messages.map((msg) => applyPendingAck(msg));
setMessages(withAcks);
seenMessageContent.current.clear();
for (const msg of withAcks) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
setHasOlderMessages(response.has_older);
setHasNewerMessages(response.has_newer);
})
.catch((err) => {
if (isAbortError(err)) return;
console.error('Failed to fetch messages around target:', err);
toast.error('Failed to jump to message');
})
.finally(() => {
setMessagesLoading(false);
});
} else {
// Not cached — full fetch with spinner
fetchMessages(true, controller.signal);
// Check cache for the new conversation
const cached = messageCache.get(activeConversation.id);
if (cached) {
// Restore from cache instantly — no spinner
setMessages(cached.messages);
seenMessageContent.current = new Set(cached.seenContent);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
// Silently reconcile with backend in case we missed a WS message
reconcileFromBackend(activeConversation, controller.signal);
} else {
// Not cached — full fetch with spinner
fetchMessages(true, controller.signal);
}
}
// Cleanup: abort request if conversation changes or component unmounts
@@ -377,7 +521,7 @@ export function useConversationMessages(
// - activeConversation object identity changes on every render; we only care about id/type
// - We use fetchingConversationIdRef and AbortController to handle stale responses safely
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConversation?.id, activeConversation?.type]);
}, [activeConversation?.id, activeConversation?.type, targetMessageId]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate
@@ -456,8 +600,13 @@ export function useConversationMessages(
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
setMessages,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
addMessageIfNew,
updateMessageAck,
triggerReconcile,

View File

@@ -82,6 +82,11 @@ export function useConversationRouter({
hasSetDefaultConversation.current = true;
return;
}
if (hashConv?.type === 'search') {
setActiveConversationState({ type: 'search', id: 'search', name: 'Message Search' });
hasSetDefaultConversation.current = true;
return;
}
// Handle channel hash (ID-first with legacy-name fallback)
if (hashConv?.type === 'channel') {
@@ -202,7 +207,7 @@ export function useConversationRouter({
if (hashSyncEnabledRef.current) {
updateUrlHash(activeConversation);
}
if (getReopenLastConversationEnabled()) {
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation);
}
}

View File

@@ -71,6 +71,19 @@
scrollbar-color: hsl(224 11% 22%) transparent;
}
/* Message highlight animation for jump-to-message */
@keyframes message-highlight {
0% {
box-shadow: 0 0 0 2px hsl(var(--primary));
}
100% {
box-shadow: 0 0 0 2px transparent;
}
}
.message-highlight {
animation: message-highlight 2s ease-out forwards;
}
/* Constrain CodeMirror editor width */
.cm-editor {
max-width: 100% !important;

View File

@@ -0,0 +1,259 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
const mocks = vi.hoisted(() => ({
api: {
getRadioConfig: vi.fn(),
getSettings: vi.fn(),
getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(),
getContacts: vi.fn(),
migratePreferences: vi.fn(),
},
useConversationMessagesCalls: vi.fn(),
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
vi.mock('../useWebSocket', () => ({
useWebSocket: vi.fn(),
}));
vi.mock('../hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('../hooks')>();
return {
...actual,
useConversationMessages: (activeConversation: unknown, targetMessageId: number | null) => {
mocks.useConversationMessagesCalls(activeConversation, targetMessageId);
return {
messages: [],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
};
},
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
incrementUnread: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
vi.mock('../components/Sidebar', () => ({
Sidebar: ({
onSelectConversation,
activeConversation,
}: {
onSelectConversation: (conv: { type: 'search' | 'channel'; id: string; name: string }) => void;
activeConversation: { type: string; id: string } | null;
}) => (
<div>
<button
type="button"
onClick={() =>
onSelectConversation({
type: 'search',
id: 'search',
name: 'Message Search',
})
}
>
Open Search
</button>
<button
type="button"
onClick={() =>
onSelectConversation({
type: 'channel',
id: PUBLIC_CHANNEL_KEY,
name: 'Public',
})
}
>
Open Public
</button>
<div data-testid="active-conversation">
{activeConversation ? `${activeConversation.type}:${activeConversation.id}` : 'none'}
</div>
</div>
),
}));
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
}));
vi.mock('../components/MessageInput', () => ({
MessageInput: React.forwardRef((_props, ref) => {
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
return <div data-testid="message-input" />;
}),
}));
vi.mock('../components/NewMessageModal', () => ({
NewMessageModal: () => null,
}));
vi.mock('../components/SearchView', () => ({
SearchView: ({
onNavigateToMessage,
}: {
onNavigateToMessage: (target: {
id: number;
type: 'CHAN' | 'PRIV';
conversation_key: string;
conversation_name: string;
}) => void;
}) => (
<button
type="button"
onClick={() =>
onNavigateToMessage({
id: 321,
type: 'CHAN',
conversation_key: PUBLIC_CHANNEL_KEY,
conversation_name: 'Public',
})
}
>
Jump Result
</button>
),
}));
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
}));
vi.mock('../components/RawPacketList', () => ({
RawPacketList: () => null,
}));
vi.mock('../components/ContactInfoPane', () => ({
ContactInfoPane: () => null,
}));
vi.mock('../components/ChannelInfoPane', () => ({
ChannelInfoPane: () => null,
}));
vi.mock('../components/ui/sheet', () => ({
Sheet: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetHeader: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
SheetTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/ui/sonner', () => ({
Toaster: () => null,
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { App } from '../App';
describe('App search jump target handling', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.api.getRadioConfig.mockResolvedValue({
public_key: 'aa'.repeat(32),
name: 'TestNode',
lat: 0,
lon: 0,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
});
mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
bots: [],
});
mocks.api.getUndecryptedPacketCount.mockResolvedValue({ count: 0 });
mocks.api.getChannels.mockResolvedValue([
{
key: PUBLIC_CHANNEL_KEY,
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
},
]);
mocks.api.getContacts.mockResolvedValue([]);
});
it('clears jump target when user selects a non-search conversation', async () => {
render(<App />);
await waitFor(() => {
expect(screen.getAllByText('Open Search').length).toBeGreaterThan(0);
});
fireEvent.click(screen.getAllByText('Open Search')[0]);
await waitFor(() => {
expect(screen.getByText('Jump Result')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Jump Result'));
await waitFor(() => {
expect(mocks.useConversationMessagesCalls.mock.calls.some((call) => call[1] === 321)).toBe(
true
);
});
fireEvent.click(screen.getAllByText('Open Public')[0]);
await waitFor(() => {
const lastCall =
mocks.useConversationMessagesCalls.mock.calls[
mocks.useConversationMessagesCalls.mock.calls.length - 1
];
expect(lastCall?.[1]).toBeNull();
});
});
});

View File

@@ -195,6 +195,7 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
signature: null,
outgoing: false,
acked: 0,
sender_name: null,
};
handleMessageEvent(state, msg, 'other_active_conv');
}
@@ -215,6 +216,7 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
signature: null,
outgoing: false,
acked: 0,
sender_name: null,
};
const result = handleMessageEvent(state, echo, 'other_active_conv');
@@ -354,6 +356,7 @@ describe('Integration: ACK + messageCache propagation', () => {
signature: null,
outgoing: true,
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-100');
@@ -377,6 +380,7 @@ describe('Integration: ACK + messageCache propagation', () => {
signature: null,
outgoing: true,
acked: 1,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-101');
@@ -404,6 +408,7 @@ describe('Integration: ACK + messageCache propagation', () => {
signature: null,
outgoing: true,
acked: 5,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-102');
@@ -427,6 +432,7 @@ describe('Integration: ACK + messageCache propagation', () => {
signature: null,
outgoing: true,
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-103');

View File

@@ -20,6 +20,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
signature: null,
outgoing: false,
acked: 0,
sender_name: null,
...overrides,
};
}

View File

@@ -0,0 +1,247 @@
import { fireEvent, render, screen, act } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Message } from '../types';
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
vi.mock('../api', () => ({
api: {
getMessages: (...args: unknown[]) => mockGetMessages(...args),
},
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
}));
import { SearchView } from '../components/SearchView';
function createSearchResult(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'ABC123',
text: 'hello world',
sender_timestamp: 1700000000,
received_at: 1700000000,
paths: null,
txt_type: 0,
signature: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
...overrides,
};
}
const defaultProps = {
contacts: [],
channels: [
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
],
onNavigateToMessage: vi.fn(),
};
/** Type the query into the search input and wait for debounced results to render. */
async function typeAndWaitForResults(query: string) {
const input = screen.getByLabelText('Search messages');
// Use fake timers only for the debounce, then switch to real timers for
// React's async state updates and waitFor polling.
vi.useFakeTimers();
await act(async () => {
fireEvent.change(input, { target: { value: query } });
vi.advanceTimersByTime(350);
});
vi.useRealTimers();
// Wait for the mock API promise to resolve and React to commit
await act(async () => {
await new Promise((r) => setTimeout(r, 0));
});
}
describe('SearchView', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('renders empty state with prompt text', () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
});
it('focuses input on mount', () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
expect(screen.getByLabelText('Search messages')).toHaveFocus();
});
it('debounces search input', async () => {
mockGetMessages.mockResolvedValue([]);
vi.useFakeTimers();
render(<SearchView {...defaultProps} />);
const input = screen.getByLabelText('Search messages');
await act(async () => {
fireEvent.change(input, { target: { value: 'hello' } });
});
// Should not have called API yet (within debounce window)
expect(mockGetMessages).not.toHaveBeenCalled();
// Advance past debounce timer
await act(async () => {
vi.advanceTimersByTime(350);
});
vi.useRealTimers();
expect(mockGetMessages).toHaveBeenCalledTimes(1);
expect(mockGetMessages).toHaveBeenCalledWith(
expect.objectContaining({ q: 'hello' }),
expect.any(AbortSignal)
);
});
it('displays search results', async () => {
mockGetMessages.mockResolvedValue([
createSearchResult({ id: 1, text: 'hello world', sender_name: 'Alice' }),
createSearchResult({ id: 2, text: 'hello there', sender_name: 'Bob' }),
]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('hello');
// Text is split by highlightMatch into segments, so use container text content
const buttons = screen.getAllByRole('button');
const texts = buttons.map((b) => b.textContent);
expect(texts.some((t) => t?.includes('hello world') || t?.includes('world'))).toBe(true);
expect(texts.some((t) => t?.includes('hello there') || t?.includes('there'))).toBe(true);
});
it('shows no-results message when search returns empty', async () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('nonexistent');
expect(screen.getByText(/No messages found/)).toBeInTheDocument();
});
it('navigates to message on click', async () => {
const result = createSearchResult({
id: 42,
type: 'CHAN',
conversation_key: 'ABC123',
text: 'click me',
});
mockGetMessages.mockResolvedValue([result]);
const onNavigate = vi.fn();
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
await typeAndWaitForResults('click');
const resultBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('me'));
expect(resultBtn).toBeDefined();
fireEvent.click(resultBtn!);
expect(onNavigate).toHaveBeenCalledWith({
id: 42,
type: 'CHAN',
conversation_key: 'ABC123',
conversation_name: 'Public',
});
});
it('navigates on Enter key', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ id: 10, text: 'keyboard nav' })]);
const onNavigate = vi.fn();
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
await typeAndWaitForResults('keyboard');
const resultEl = screen.getByRole('button', { name: /keyboard nav/i });
fireEvent.keyDown(resultEl, { key: 'Enter' });
expect(onNavigate).toHaveBeenCalled();
});
it('shows load more button when results fill a page', async () => {
const pageResults = Array.from({ length: 50 }, (_, i) =>
createSearchResult({ id: i + 1, text: `result ${i}` })
);
mockGetMessages.mockResolvedValueOnce(pageResults);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('result');
expect(screen.getByText('Load more results')).toBeInTheDocument();
});
it('does not show load more when results are less than page size', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ id: 1, text: 'only one' })]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('only');
const resultBtns = screen.getAllByRole('button');
expect(resultBtns.some((b) => b.textContent?.includes('one'))).toBe(true);
expect(screen.queryByText('Load more results')).not.toBeInTheDocument();
});
it('resolves channel name from channels prop', async () => {
mockGetMessages.mockResolvedValue([
createSearchResult({ id: 1, type: 'CHAN', conversation_key: 'ABC123', text: 'test' }),
]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('test');
expect(screen.getByText('Public')).toBeInTheDocument();
});
it('resolves contact name from contacts prop', async () => {
const contactKey = 'aa'.repeat(32);
mockGetMessages.mockResolvedValue([
createSearchResult({
id: 1,
type: 'PRIV',
conversation_key: contactKey,
text: 'dm test',
}),
]);
render(
<SearchView
{...defaultProps}
contacts={[
{
public_key: contactKey,
name: 'Bob',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
first_seen: null,
last_read_at: null,
},
]}
/>
);
await typeAndWaitForResults('dm');
expect(screen.getByText('Bob')).toBeInTheDocument();
});
});

View File

@@ -6,10 +6,12 @@ import { useConversationMessages } from '../hooks/useConversationMessages';
import type { Conversation, Message } from '../types';
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
const mockGetMessagesAround = vi.fn();
vi.mock('../api', () => ({
api: {
getMessages: (...args: unknown[]) => mockGetMessages(...args),
getMessagesAround: (...args: unknown[]) => mockGetMessagesAround(...args),
},
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
}));
@@ -35,6 +37,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
signature: null,
outgoing: true,
acked: 0,
sender_name: null,
...overrides,
};
}
@@ -219,3 +222,173 @@ describe('useConversationMessages conversation switch', () => {
expect(result.current.messages[0].conversation_key).toBe('conv_b');
});
});
describe('useConversationMessages forward pagination', () => {
beforeEach(() => {
mockGetMessages.mockReset();
mockGetMessagesAround.mockReset();
messageCache.clear();
});
it('fetchNewerMessages loads newer messages and appends them', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
// Initial load returns messages that indicate there are newer ones
// (we'll set hasNewerMessages via targetMessageId + getMessagesAround)
const initialMessages = Array.from({ length: 3 }, (_, i) =>
createMessage({
id: i + 1,
conversation_key: 'ch1',
text: `msg-${i}`,
sender_timestamp: 1700000000 + i,
received_at: 1700000000 + i,
})
);
mockGetMessagesAround.mockResolvedValueOnce({
messages: initialMessages,
has_older: false,
has_newer: true,
});
const { result } = renderHook(
({ conv, target }: { conv: Conversation; target: number | null }) =>
useConversationMessages(conv, target),
{ initialProps: { conv, target: 2 } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.messages).toHaveLength(3);
expect(result.current.hasNewerMessages).toBe(true);
// Now fetch newer messages
const newerMessages = [
createMessage({
id: 4,
conversation_key: 'ch1',
text: 'msg-3',
sender_timestamp: 1700000003,
received_at: 1700000003,
}),
];
mockGetMessages.mockResolvedValueOnce(newerMessages);
await act(async () => {
await result.current.fetchNewerMessages();
});
expect(result.current.messages).toHaveLength(4);
// Less than page size → no more newer messages
expect(result.current.hasNewerMessages).toBe(false);
});
it('fetchNewerMessages deduplicates against seen messages', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
const initialMessages = [
createMessage({
id: 1,
conversation_key: 'ch1',
text: 'msg-0',
sender_timestamp: 1700000000,
received_at: 1700000000,
}),
];
mockGetMessagesAround.mockResolvedValueOnce({
messages: initialMessages,
has_older: false,
has_newer: true,
});
const { result } = renderHook(
({ conv, target }: { conv: Conversation; target: number | null }) =>
useConversationMessages(conv, target),
{ initialProps: { conv, target: 1 } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
// Simulate WS adding a message with the same content key
act(() => {
result.current.addMessageIfNew(
createMessage({
id: 2,
conversation_key: 'ch1',
text: 'duplicate-content',
sender_timestamp: 1700000001,
received_at: 1700000001,
})
);
});
// fetchNewerMessages returns the same content (different id but same content key)
mockGetMessages.mockResolvedValueOnce([
createMessage({
id: 3,
conversation_key: 'ch1',
text: 'duplicate-content',
sender_timestamp: 1700000001,
received_at: 1700000001,
}),
]);
await act(async () => {
await result.current.fetchNewerMessages();
});
// Should not have a duplicate
const dupes = result.current.messages.filter((m) => m.text === 'duplicate-content');
expect(dupes).toHaveLength(1);
});
it('jumpToBottom clears hasNewerMessages and refetches latest', async () => {
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
const aroundMessages = [
createMessage({
id: 5,
conversation_key: 'ch1',
text: 'around-msg',
sender_timestamp: 1700000005,
received_at: 1700000005,
}),
];
mockGetMessagesAround.mockResolvedValueOnce({
messages: aroundMessages,
has_older: true,
has_newer: true,
});
const { result } = renderHook(
({ conv, target }: { conv: Conversation; target: number | null }) =>
useConversationMessages(conv, target),
{ initialProps: { conv, target: 5 } }
);
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.hasNewerMessages).toBe(true);
// Jump to bottom
const latestMessages = [
createMessage({
id: 10,
conversation_key: 'ch1',
text: 'latest-msg',
sender_timestamp: 1700000010,
received_at: 1700000010,
}),
];
mockGetMessages.mockResolvedValueOnce(latestMessages);
act(() => {
result.current.jumpToBottom();
});
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
expect(result.current.hasNewerMessages).toBe(false);
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0].text).toBe('latest-msg');
});
});

View File

@@ -21,6 +21,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
signature: null,
outgoing: false,
acked: 0,
sender_name: null,
...overrides,
};
}

View File

@@ -154,9 +154,16 @@ export interface Message {
outgoing: boolean;
/** ACK count: 0 = not acked, 1+ = number of acks/flood echoes received */
acked: number;
sender_name: string | null;
}
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer';
export interface MessagesAroundResponse {
messages: Message[];
has_older: boolean;
has_newer: boolean;
}
type ConversationType = 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search';
export interface Conversation {
type: ConversationType;

View File

@@ -2,7 +2,7 @@ import type { Channel, Contact, Conversation } from '../types';
import { getContactDisplayName } from './pubkey';
interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer';
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
/** Conversation identity token (channel key or contact public key, or legacy name token) */
name: string;
/** Optional human-readable label segment (ignored for identity resolution) */
@@ -29,6 +29,10 @@ export function parseHashConversation(): ParsedHashConversation | null {
return { type: 'visualizer', name: 'visualizer' };
}
if (hash === 'search') {
return { type: 'search', name: 'search' };
}
// Check for map with focus: #map/focus/{pubkey_prefix}
if (hash.startsWith('map/focus/')) {
const focusKey = hash.slice('map/focus/'.length);
@@ -107,6 +111,7 @@ function getConversationHash(conv: Conversation | null): string {
if (conv.type === 'raw') return '#raw';
if (conv.type === 'map') return '#map';
if (conv.type === 'visualizer') return '#visualizer';
if (conv.type === 'search') return '#search';
// Use immutable IDs for identity, append readable label for UX.
if (conv.type === 'channel') {

View File

@@ -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: <span>All Time</span><p>VALUE</p> — 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 <mark> 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 });
});
});

View File

@@ -322,6 +322,7 @@ class TestContactMessageCLIFiltering:
"signature",
"outgoing",
"acked",
"sender_name",
}
with (

View File

@@ -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"]