mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-04 12:33:04 +02:00
Add global message search and more e2e tests
This commit is contained in:
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,12 @@ interface MessageListProps {
|
||||
radioName?: string;
|
||||
config?: RadioConfig | null;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
targetMessageId?: number | null;
|
||||
onTargetReached?: () => void;
|
||||
hasNewerMessages?: boolean;
|
||||
loadingNewer?: boolean;
|
||||
onLoadNewer?: () => void;
|
||||
onJumpToBottom?: () => void;
|
||||
}
|
||||
|
||||
// URL regex for linkifying plain text
|
||||
@@ -154,6 +160,12 @@ export function MessageList({
|
||||
radioName,
|
||||
config,
|
||||
onOpenContactInfo,
|
||||
targetMessageId,
|
||||
onTargetReached,
|
||||
hasNewerMessages = false,
|
||||
loadingNewer = false,
|
||||
onLoadNewer,
|
||||
onJumpToBottom,
|
||||
}: MessageListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const prevMessagesLengthRef = useRef<number>(0);
|
||||
@@ -167,6 +179,8 @@ export function MessageList({
|
||||
} | null>(null);
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
||||
const targetScrolledRef = useRef(false);
|
||||
|
||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
||||
const scrollStateRef = useRef({
|
||||
@@ -205,8 +219,10 @@ export function MessageList({
|
||||
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
|
||||
// User was near top (loading older) - preserve position by adding the height diff
|
||||
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
|
||||
} else if (scrollStateRef.current.wasNearBottom) {
|
||||
// User was near bottom - scroll to bottom for new messages (including sent)
|
||||
} else if (scrollStateRef.current.wasNearBottom && !hasNewerMessagesRef.current) {
|
||||
// User was near bottom - scroll to bottom for new messages (including sent).
|
||||
// Skip when browsing mid-history (hasNewerMessages) so that forward-pagination
|
||||
// appends in place instead of chasing the bottom in an infinite load loop.
|
||||
list.scrollTop = list.scrollHeight;
|
||||
}
|
||||
}
|
||||
@@ -214,6 +230,25 @@ export function MessageList({
|
||||
prevMessagesLengthRef.current = messages.length;
|
||||
}, [messages]);
|
||||
|
||||
// Scroll to target message and highlight it
|
||||
useLayoutEffect(() => {
|
||||
if (!targetMessageId || targetScrolledRef.current || messages.length === 0) return;
|
||||
const el = listRef.current?.querySelector(`[data-message-id="${targetMessageId}"]`);
|
||||
if (!el) return;
|
||||
|
||||
// Prevent the initial-load layout effect from overriding our scroll
|
||||
isInitialLoadRef.current = false;
|
||||
el.scrollIntoView({ block: 'center' });
|
||||
setHighlightedMessageId(targetMessageId);
|
||||
targetScrolledRef.current = true;
|
||||
onTargetReached?.();
|
||||
}, [messages, targetMessageId, onTargetReached]);
|
||||
|
||||
// Reset target scroll tracking when targetMessageId changes
|
||||
useEffect(() => {
|
||||
targetScrolledRef.current = false;
|
||||
}, [targetMessageId]);
|
||||
|
||||
// Reset initial load flag when conversation changes (messages becomes empty then filled)
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) {
|
||||
@@ -271,9 +306,15 @@ export function MessageList({
|
||||
const onLoadOlderRef = useRef(onLoadOlder);
|
||||
const loadingOlderRef = useRef(loadingOlder);
|
||||
const hasOlderMessagesRef = useRef(hasOlderMessages);
|
||||
const onLoadNewerRef = useRef(onLoadNewer);
|
||||
const loadingNewerRef = useRef(loadingNewer);
|
||||
const hasNewerMessagesRef = useRef(hasNewerMessages);
|
||||
onLoadOlderRef.current = onLoadOlder;
|
||||
loadingOlderRef.current = loadingOlder;
|
||||
hasOlderMessagesRef.current = hasOlderMessages;
|
||||
onLoadNewerRef.current = onLoadNewer;
|
||||
loadingNewerRef.current = loadingNewer;
|
||||
hasNewerMessagesRef.current = hasNewerMessages;
|
||||
|
||||
// Handle scroll - capture state and detect when user is near top/bottom
|
||||
// Stable callback: reads changing values from refs, never recreated.
|
||||
@@ -295,20 +336,33 @@ export function MessageList({
|
||||
// Show scroll-to-bottom button when not near the bottom (more than 100px away)
|
||||
setShowScrollToBottom(distanceFromBottom > 100);
|
||||
|
||||
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) return;
|
||||
|
||||
// Trigger load when within 100px of top
|
||||
if (scrollTop < 100) {
|
||||
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) {
|
||||
// skip older load
|
||||
} else if (scrollTop < 100) {
|
||||
onLoadOlderRef.current();
|
||||
}
|
||||
|
||||
// Trigger load newer when within 100px of bottom
|
||||
if (
|
||||
onLoadNewerRef.current &&
|
||||
!loadingNewerRef.current &&
|
||||
hasNewerMessagesRef.current &&
|
||||
distanceFromBottom < 100
|
||||
) {
|
||||
onLoadNewerRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom handler
|
||||
// Scroll to bottom handler (or jump to bottom if viewing historical messages)
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (hasNewerMessages && onJumpToBottom) {
|
||||
onJumpToBottom();
|
||||
return;
|
||||
}
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
}, [hasNewerMessages, onJumpToBottom]);
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
|
||||
@@ -465,6 +519,7 @@ export function MessageList({
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
data-message-id={msg.id}
|
||||
className={cn(
|
||||
'flex items-start max-w-[85%]',
|
||||
msg.outgoing && 'flex-row-reverse self-end',
|
||||
@@ -503,7 +558,8 @@ export function MessageList({
|
||||
<div
|
||||
className={cn(
|
||||
'py-1.5 px-3 rounded-lg min-w-0',
|
||||
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming'
|
||||
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
|
||||
highlightedMessageId === msg.id && 'message-highlight'
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
@@ -618,6 +674,16 @@ export function MessageList({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{loadingNewer && (
|
||||
<div className="text-center py-2 text-muted-foreground text-sm" role="status">
|
||||
Loading newer messages...
|
||||
</div>
|
||||
)}
|
||||
{!loadingNewer && hasNewerMessages && (
|
||||
<div className="text-center py-2 text-muted-foreground text-xs">
|
||||
Scroll down for newer messages
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
|
||||
270
frontend/src/components/SearchView.tsx
Normal file
270
frontend/src/components/SearchView.tsx
Normal 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 “{debouncedQuery}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,19 @@
|
||||
scrollbar-color: hsl(224 11% 22%) transparent;
|
||||
}
|
||||
|
||||
/* Message highlight animation for jump-to-message */
|
||||
@keyframes message-highlight {
|
||||
0% {
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary));
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 2px transparent;
|
||||
}
|
||||
}
|
||||
.message-highlight {
|
||||
animation: message-highlight 2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Constrain CodeMirror editor width */
|
||||
.cm-editor {
|
||||
max-width: 100% !important;
|
||||
|
||||
259
frontend/src/test/appSearchJump.test.tsx
Normal file
259
frontend/src/test/appSearchJump.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
247
frontend/src/test/searchView.test.tsx
Normal file
247
frontend/src/test/searchView.test.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { fireEvent, render, screen, act } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getMessages: (...args: unknown[]) => mockGetMessages(...args),
|
||||
},
|
||||
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
|
||||
}));
|
||||
|
||||
import { SearchView } from '../components/SearchView';
|
||||
|
||||
function createSearchResult(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'ABC123',
|
||||
text: 'hello world',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000000,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: 'Alice',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
contacts: [],
|
||||
channels: [
|
||||
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
|
||||
],
|
||||
onNavigateToMessage: vi.fn(),
|
||||
};
|
||||
|
||||
/** Type the query into the search input and wait for debounced results to render. */
|
||||
async function typeAndWaitForResults(query: string) {
|
||||
const input = screen.getByLabelText('Search messages');
|
||||
// Use fake timers only for the debounce, then switch to real timers for
|
||||
// React's async state updates and waitFor polling.
|
||||
vi.useFakeTimers();
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: query } });
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
// Wait for the mock API promise to resolve and React to commit
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
|
||||
describe('SearchView', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders empty state with prompt text', () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses input on mount', () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
expect(screen.getByLabelText('Search messages')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('debounces search input', async () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
vi.useFakeTimers();
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
const input = screen.getByLabelText('Search messages');
|
||||
await act(async () => {
|
||||
fireEvent.change(input, { target: { value: 'hello' } });
|
||||
});
|
||||
|
||||
// Should not have called API yet (within debounce window)
|
||||
expect(mockGetMessages).not.toHaveBeenCalled();
|
||||
|
||||
// Advance past debounce timer
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(350);
|
||||
});
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(mockGetMessages).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ q: 'hello' }),
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
});
|
||||
|
||||
it('displays search results', async () => {
|
||||
mockGetMessages.mockResolvedValue([
|
||||
createSearchResult({ id: 1, text: 'hello world', sender_name: 'Alice' }),
|
||||
createSearchResult({ id: 2, text: 'hello there', sender_name: 'Bob' }),
|
||||
]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('hello');
|
||||
|
||||
// Text is split by highlightMatch into segments, so use container text content
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const texts = buttons.map((b) => b.textContent);
|
||||
expect(texts.some((t) => t?.includes('hello world') || t?.includes('world'))).toBe(true);
|
||||
expect(texts.some((t) => t?.includes('hello there') || t?.includes('there'))).toBe(true);
|
||||
});
|
||||
|
||||
it('shows no-results message when search returns empty', async () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('nonexistent');
|
||||
|
||||
expect(screen.getByText(/No messages found/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('navigates to message on click', async () => {
|
||||
const result = createSearchResult({
|
||||
id: 42,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'ABC123',
|
||||
text: 'click me',
|
||||
});
|
||||
mockGetMessages.mockResolvedValue([result]);
|
||||
const onNavigate = vi.fn();
|
||||
|
||||
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
|
||||
|
||||
await typeAndWaitForResults('click');
|
||||
|
||||
const resultBtn = screen.getAllByRole('button').find((b) => b.textContent?.includes('me'));
|
||||
expect(resultBtn).toBeDefined();
|
||||
|
||||
fireEvent.click(resultBtn!);
|
||||
|
||||
expect(onNavigate).toHaveBeenCalledWith({
|
||||
id: 42,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'ABC123',
|
||||
conversation_name: 'Public',
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates on Enter key', async () => {
|
||||
mockGetMessages.mockResolvedValue([createSearchResult({ id: 10, text: 'keyboard nav' })]);
|
||||
const onNavigate = vi.fn();
|
||||
render(<SearchView {...defaultProps} onNavigateToMessage={onNavigate} />);
|
||||
|
||||
await typeAndWaitForResults('keyboard');
|
||||
|
||||
const resultEl = screen.getByRole('button', { name: /keyboard nav/i });
|
||||
fireEvent.keyDown(resultEl, { key: 'Enter' });
|
||||
|
||||
expect(onNavigate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows load more button when results fill a page', async () => {
|
||||
const pageResults = Array.from({ length: 50 }, (_, i) =>
|
||||
createSearchResult({ id: i + 1, text: `result ${i}` })
|
||||
);
|
||||
mockGetMessages.mockResolvedValueOnce(pageResults);
|
||||
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('result');
|
||||
|
||||
expect(screen.getByText('Load more results')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show load more when results are less than page size', async () => {
|
||||
mockGetMessages.mockResolvedValue([createSearchResult({ id: 1, text: 'only one' })]);
|
||||
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('only');
|
||||
|
||||
const resultBtns = screen.getAllByRole('button');
|
||||
expect(resultBtns.some((b) => b.textContent?.includes('one'))).toBe(true);
|
||||
expect(screen.queryByText('Load more results')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resolves channel name from channels prop', async () => {
|
||||
mockGetMessages.mockResolvedValue([
|
||||
createSearchResult({ id: 1, type: 'CHAN', conversation_key: 'ABC123', text: 'test' }),
|
||||
]);
|
||||
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('test');
|
||||
|
||||
expect(screen.getByText('Public')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('resolves contact name from contacts prop', async () => {
|
||||
const contactKey = 'aa'.repeat(32);
|
||||
mockGetMessages.mockResolvedValue([
|
||||
createSearchResult({
|
||||
id: 1,
|
||||
type: 'PRIV',
|
||||
conversation_key: contactKey,
|
||||
text: 'dm test',
|
||||
}),
|
||||
]);
|
||||
|
||||
render(
|
||||
<SearchView
|
||||
{...defaultProps}
|
||||
contacts={[
|
||||
{
|
||||
public_key: contactKey,
|
||||
name: 'Bob',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
first_seen: null,
|
||||
last_read_at: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
await typeAndWaitForResults('dm');
|
||||
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -6,10 +6,12 @@ import { useConversationMessages } from '../hooks/useConversationMessages';
|
||||
import type { Conversation, Message } from '../types';
|
||||
|
||||
const mockGetMessages = vi.fn<(...args: unknown[]) => Promise<Message[]>>();
|
||||
const mockGetMessagesAround = vi.fn();
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getMessages: (...args: unknown[]) => mockGetMessages(...args),
|
||||
getMessagesAround: (...args: unknown[]) => mockGetMessagesAround(...args),
|
||||
},
|
||||
isAbortError: (err: unknown) => err instanceof DOMException && err.name === 'AbortError',
|
||||
}));
|
||||
@@ -35,6 +37,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
signature: null,
|
||||
outgoing: true,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -219,3 +222,173 @@ describe('useConversationMessages conversation switch', () => {
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useConversationMessages forward pagination', () => {
|
||||
beforeEach(() => {
|
||||
mockGetMessages.mockReset();
|
||||
mockGetMessagesAround.mockReset();
|
||||
messageCache.clear();
|
||||
});
|
||||
|
||||
it('fetchNewerMessages loads newer messages and appends them', async () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
|
||||
|
||||
// Initial load returns messages that indicate there are newer ones
|
||||
// (we'll set hasNewerMessages via targetMessageId + getMessagesAround)
|
||||
const initialMessages = Array.from({ length: 3 }, (_, i) =>
|
||||
createMessage({
|
||||
id: i + 1,
|
||||
conversation_key: 'ch1',
|
||||
text: `msg-${i}`,
|
||||
sender_timestamp: 1700000000 + i,
|
||||
received_at: 1700000000 + i,
|
||||
})
|
||||
);
|
||||
|
||||
mockGetMessagesAround.mockResolvedValueOnce({
|
||||
messages: initialMessages,
|
||||
has_older: false,
|
||||
has_newer: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
({ conv, target }: { conv: Conversation; target: number | null }) =>
|
||||
useConversationMessages(conv, target),
|
||||
{ initialProps: { conv, target: 2 } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.hasNewerMessages).toBe(true);
|
||||
|
||||
// Now fetch newer messages
|
||||
const newerMessages = [
|
||||
createMessage({
|
||||
id: 4,
|
||||
conversation_key: 'ch1',
|
||||
text: 'msg-3',
|
||||
sender_timestamp: 1700000003,
|
||||
received_at: 1700000003,
|
||||
}),
|
||||
];
|
||||
mockGetMessages.mockResolvedValueOnce(newerMessages);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNewerMessages();
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(4);
|
||||
// Less than page size → no more newer messages
|
||||
expect(result.current.hasNewerMessages).toBe(false);
|
||||
});
|
||||
|
||||
it('fetchNewerMessages deduplicates against seen messages', async () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
|
||||
|
||||
const initialMessages = [
|
||||
createMessage({
|
||||
id: 1,
|
||||
conversation_key: 'ch1',
|
||||
text: 'msg-0',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000000,
|
||||
}),
|
||||
];
|
||||
|
||||
mockGetMessagesAround.mockResolvedValueOnce({
|
||||
messages: initialMessages,
|
||||
has_older: false,
|
||||
has_newer: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
({ conv, target }: { conv: Conversation; target: number | null }) =>
|
||||
useConversationMessages(conv, target),
|
||||
{ initialProps: { conv, target: 1 } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
|
||||
// Simulate WS adding a message with the same content key
|
||||
act(() => {
|
||||
result.current.addMessageIfNew(
|
||||
createMessage({
|
||||
id: 2,
|
||||
conversation_key: 'ch1',
|
||||
text: 'duplicate-content',
|
||||
sender_timestamp: 1700000001,
|
||||
received_at: 1700000001,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// fetchNewerMessages returns the same content (different id but same content key)
|
||||
mockGetMessages.mockResolvedValueOnce([
|
||||
createMessage({
|
||||
id: 3,
|
||||
conversation_key: 'ch1',
|
||||
text: 'duplicate-content',
|
||||
sender_timestamp: 1700000001,
|
||||
received_at: 1700000001,
|
||||
}),
|
||||
]);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchNewerMessages();
|
||||
});
|
||||
|
||||
// Should not have a duplicate
|
||||
const dupes = result.current.messages.filter((m) => m.text === 'duplicate-content');
|
||||
expect(dupes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('jumpToBottom clears hasNewerMessages and refetches latest', async () => {
|
||||
const conv: Conversation = { type: 'channel', id: 'ch1', name: 'Channel' };
|
||||
|
||||
const aroundMessages = [
|
||||
createMessage({
|
||||
id: 5,
|
||||
conversation_key: 'ch1',
|
||||
text: 'around-msg',
|
||||
sender_timestamp: 1700000005,
|
||||
received_at: 1700000005,
|
||||
}),
|
||||
];
|
||||
|
||||
mockGetMessagesAround.mockResolvedValueOnce({
|
||||
messages: aroundMessages,
|
||||
has_older: true,
|
||||
has_newer: true,
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
({ conv, target }: { conv: Conversation; target: number | null }) =>
|
||||
useConversationMessages(conv, target),
|
||||
{ initialProps: { conv, target: 5 } }
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.hasNewerMessages).toBe(true);
|
||||
|
||||
// Jump to bottom
|
||||
const latestMessages = [
|
||||
createMessage({
|
||||
id: 10,
|
||||
conversation_key: 'ch1',
|
||||
text: 'latest-msg',
|
||||
sender_timestamp: 1700000010,
|
||||
received_at: 1700000010,
|
||||
}),
|
||||
];
|
||||
mockGetMessages.mockResolvedValueOnce(latestMessages);
|
||||
|
||||
act(() => {
|
||||
result.current.jumpToBottom();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.hasNewerMessages).toBe(false);
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].text).toBe('latest-msg');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
signature: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
147
tests/e2e/specs/channel-info-and-search.spec.ts
Normal file
147
tests/e2e/specs/channel-info-and-search.spec.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -322,6 +322,7 @@ class TestContactMessageCLIFiltering:
|
||||
"signature",
|
||||
"outgoing",
|
||||
"acked",
|
||||
"sender_name",
|
||||
}
|
||||
|
||||
with (
|
||||
|
||||
579
tests/test_messages_search.py
Normal file
579
tests/test_messages_search.py
Normal 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"]
|
||||
Reference in New Issue
Block a user