mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Add better search management and operators + contact search quick link
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
@@ -12,6 +14,17 @@ from app.models import (
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@dataclass
|
||||
class _SearchQuery:
|
||||
free_text: str
|
||||
user_terms: list[str]
|
||||
channel_terms: list[str]
|
||||
|
||||
_SEARCH_OPERATOR_RE = re.compile(
|
||||
r'(?<!\S)(user|channel):(?:"((?:[^"\\]|\\.)*)"|(\S+))',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
|
||||
lower_key = public_key.lower()
|
||||
@@ -185,6 +198,92 @@ class MessageRepository:
|
||||
else:
|
||||
return "AND conversation_key LIKE ?", f"{conversation_key}%"
|
||||
|
||||
@staticmethod
|
||||
def _unescape_search_quoted_value(value: str) -> str:
|
||||
return value.replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
@staticmethod
|
||||
def _parse_search_query(q: str) -> _SearchQuery:
|
||||
user_terms: list[str] = []
|
||||
channel_terms: list[str] = []
|
||||
fragments: list[str] = []
|
||||
last_end = 0
|
||||
|
||||
for match in MessageRepository._SEARCH_OPERATOR_RE.finditer(q):
|
||||
fragments.append(q[last_end : match.start()])
|
||||
raw_value = match.group(2) if match.group(2) is not None else match.group(3) or ""
|
||||
value = MessageRepository._unescape_search_quoted_value(raw_value)
|
||||
if match.group(1).lower() == "user":
|
||||
user_terms.append(value)
|
||||
else:
|
||||
channel_terms.append(value)
|
||||
last_end = match.end()
|
||||
|
||||
if not user_terms and not channel_terms:
|
||||
return MessageRepository._SearchQuery(free_text=q, user_terms=[], channel_terms=[])
|
||||
|
||||
fragments.append(q[last_end:])
|
||||
free_text = " ".join(fragment.strip() for fragment in fragments if fragment.strip())
|
||||
return MessageRepository._SearchQuery(
|
||||
free_text=free_text,
|
||||
user_terms=user_terms,
|
||||
channel_terms=channel_terms,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _escape_like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_hex_prefix(value: str) -> bool:
|
||||
return bool(value) and all(ch in "0123456789abcdefABCDEF" for ch in value)
|
||||
|
||||
@staticmethod
|
||||
def _build_channel_scope_clause(value: str) -> tuple[str, list[Any]]:
|
||||
params: list[Any] = [value]
|
||||
clause = "(messages.type = 'CHAN' AND (channels.name = ? COLLATE NOCASE"
|
||||
|
||||
if MessageRepository._looks_like_hex_prefix(value):
|
||||
if len(value) == 32:
|
||||
clause += " OR UPPER(messages.conversation_key) = ?"
|
||||
params.append(value.upper())
|
||||
else:
|
||||
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
||||
|
||||
clause += "))"
|
||||
return clause, params
|
||||
|
||||
@staticmethod
|
||||
def _build_user_scope_clause(value: str) -> tuple[str, list[Any]]:
|
||||
params: list[Any] = [value, value]
|
||||
clause = (
|
||||
"((messages.type = 'PRIV' AND contacts.name = ? COLLATE NOCASE)"
|
||||
" OR (messages.type = 'CHAN' AND sender_name = ? COLLATE NOCASE)"
|
||||
)
|
||||
|
||||
if MessageRepository._looks_like_hex_prefix(value):
|
||||
lower_value = value.lower()
|
||||
priv_key_clause: str
|
||||
chan_key_clause: str
|
||||
if len(value) == 64:
|
||||
priv_key_clause = "LOWER(messages.conversation_key) = ?"
|
||||
chan_key_clause = "LOWER(sender_key) = ?"
|
||||
params.extend([lower_value, lower_value])
|
||||
else:
|
||||
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
||||
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
|
||||
params.extend([escaped_prefix, escaped_prefix])
|
||||
|
||||
clause += (
|
||||
f" OR (messages.type = 'PRIV' AND {priv_key_clause})"
|
||||
f" OR (messages.type = 'CHAN' AND sender_key IS NOT NULL AND {chan_key_clause})"
|
||||
)
|
||||
|
||||
clause += ")"
|
||||
return clause, params
|
||||
|
||||
@staticmethod
|
||||
def _row_to_message(row: Any) -> Message:
|
||||
"""Convert a database row to a Message model."""
|
||||
@@ -218,15 +317,24 @@ class MessageRepository:
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> list[Message]:
|
||||
query = "SELECT * FROM messages WHERE 1=1"
|
||||
search_query = MessageRepository._parse_search_query(q) if q else None
|
||||
query = (
|
||||
"SELECT messages.* FROM messages "
|
||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
|
||||
"WHERE 1=1"
|
||||
)
|
||||
params: list[Any] = []
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND ("
|
||||
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
|
||||
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
|
||||
f" AND NOT (messages.outgoing=0 AND ("
|
||||
f"(messages.type='PRIV' AND LOWER(messages.conversation_key) IN ({placeholders}))"
|
||||
f" OR (messages.type='CHAN' AND messages.sender_key IS NOT NULL"
|
||||
f" AND LOWER(messages.sender_key) IN ({placeholders}))"
|
||||
f"))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
@@ -235,36 +343,57 @@ class MessageRepository:
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND sender_name IS NOT NULL"
|
||||
f" AND sender_name IN ({placeholders}))"
|
||||
f" AND NOT (messages.outgoing=0 AND messages.sender_name IS NOT NULL"
|
||||
f" AND messages.sender_name IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_names)
|
||||
|
||||
if msg_type:
|
||||
query += " AND type = ?"
|
||||
query += " AND messages.type = ?"
|
||||
params.append(msg_type)
|
||||
if conversation_key:
|
||||
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
|
||||
query += f" {clause}"
|
||||
query += f" {clause.replace('conversation_key', 'messages.conversation_key')}"
|
||||
params.append(norm_key)
|
||||
|
||||
if q:
|
||||
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
if search_query and search_query.user_terms:
|
||||
scope_clauses: list[str] = []
|
||||
for term in search_query.user_terms:
|
||||
clause, clause_params = MessageRepository._build_user_scope_clause(term)
|
||||
scope_clauses.append(clause)
|
||||
params.extend(clause_params)
|
||||
query += f" AND ({' OR '.join(scope_clauses)})"
|
||||
|
||||
if search_query and search_query.channel_terms:
|
||||
scope_clauses = []
|
||||
for term in search_query.channel_terms:
|
||||
clause, clause_params = MessageRepository._build_channel_scope_clause(term)
|
||||
scope_clauses.append(clause)
|
||||
params.extend(clause_params)
|
||||
query += f" AND ({' OR '.join(scope_clauses)})"
|
||||
|
||||
if search_query and search_query.free_text:
|
||||
escaped_q = MessageRepository._escape_like(search_query.free_text)
|
||||
query += " AND messages.text LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
params.append(f"%{escaped_q}%")
|
||||
|
||||
# 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 > ?))"
|
||||
query += (
|
||||
" AND (messages.received_at > ? OR (messages.received_at = ? AND messages.id > ?))"
|
||||
)
|
||||
params.extend([after, after, after_id])
|
||||
query += " ORDER BY received_at ASC, id ASC LIMIT ?"
|
||||
query += " ORDER BY messages.received_at ASC, messages.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 < ?))"
|
||||
query += (
|
||||
" AND (messages.received_at < ?"
|
||||
" OR (messages.received_at = ? AND messages.id < ?))"
|
||||
)
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
query += " ORDER BY messages.received_at DESC, messages.id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
|
||||
@@ -21,6 +21,10 @@ import { messageContainsMention } from './utils/messageParser';
|
||||
import type { Conversation, RawPacket } from './types';
|
||||
|
||||
export function App() {
|
||||
const quoteSearchOperatorValue = useCallback((value: string) => {
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}, []);
|
||||
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const {
|
||||
@@ -150,6 +154,7 @@ export function App() {
|
||||
infoPaneContactKey,
|
||||
infoPaneFromChannel,
|
||||
infoPaneChannelKey,
|
||||
searchPrefillRequest,
|
||||
handleOpenContactInfo,
|
||||
handleCloseContactInfo,
|
||||
handleOpenChannelInfo,
|
||||
@@ -157,6 +162,7 @@ export function App() {
|
||||
handleSelectConversationWithTargetReset,
|
||||
handleNavigateToChannel,
|
||||
handleNavigateToMessage,
|
||||
handleOpenSearchWithQuery,
|
||||
} = useConversationNavigation({
|
||||
channels,
|
||||
handleSelectConversation,
|
||||
@@ -322,6 +328,7 @@ export function App() {
|
||||
contacts,
|
||||
channels,
|
||||
onNavigateToMessage: handleNavigateToMessage,
|
||||
prefillRequest: searchPrefillRequest,
|
||||
};
|
||||
const settingsProps = {
|
||||
config,
|
||||
@@ -361,6 +368,12 @@ export function App() {
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onNavigateToChannel: handleNavigateToChannel,
|
||||
onSearchMessagesByKey: (publicKey: string) => {
|
||||
handleOpenSearchWithQuery(`user:${publicKey}`);
|
||||
},
|
||||
onSearchMessagesByName: (name: string) => {
|
||||
handleOpenSearchWithQuery(`user:${quoteSearchOperatorValue(name)}`);
|
||||
},
|
||||
onToggleBlockedKey: handleBlockKey,
|
||||
onToggleBlockedName: handleBlockName,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Ban, Star } from 'lucide-react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
@@ -51,6 +51,8 @@ interface ContactInfoPaneProps {
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
onSearchMessagesByKey?: (publicKey: string) => void;
|
||||
onSearchMessagesByName?: (name: string) => void;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
@@ -66,6 +68,8 @@ export function ContactInfoPane({
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
onSearchMessagesByKey,
|
||||
onSearchMessagesByName,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
@@ -183,6 +187,19 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSearchMessagesByName && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onSearchMessagesByName(nameOnlyValue)}
|
||||
>
|
||||
<Search className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Search user's messages by name</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fromChannel && (
|
||||
<ChannelAttributionWarning
|
||||
nameOnly
|
||||
@@ -387,6 +404,19 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSearchMessagesByKey && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onSearchMessagesByKey(contact.public_key)}
|
||||
>
|
||||
<Search className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Search user's messages by key</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest Repeaters */}
|
||||
{analytics && analytics.nearest_repeaters.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
|
||||
@@ -19,6 +19,8 @@ interface SearchResult {
|
||||
sender_name: string | null;
|
||||
}
|
||||
|
||||
const SEARCH_OPERATOR_RE = /(?<!\S)(user|channel):(?:"((?:[^"\\]|\\.)*)"|(\S+))/gi;
|
||||
|
||||
export interface SearchNavigateTarget {
|
||||
id: number;
|
||||
type: 'PRIV' | 'CHAN';
|
||||
@@ -30,6 +32,10 @@ export interface SearchViewProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
onNavigateToMessage: (target: SearchNavigateTarget) => void;
|
||||
prefillRequest?: {
|
||||
query: string;
|
||||
nonce: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode[] {
|
||||
@@ -53,7 +59,34 @@ function highlightMatch(text: string, query: string): React.ReactNode[] {
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function SearchView({ contacts, channels, onNavigateToMessage }: SearchViewProps) {
|
||||
function getHighlightQuery(query: string): string {
|
||||
const fragments: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let foundOperator = false;
|
||||
|
||||
for (const match of query.matchAll(SEARCH_OPERATOR_RE)) {
|
||||
foundOperator = true;
|
||||
fragments.push(query.slice(lastIndex, match.index));
|
||||
lastIndex = (match.index ?? 0) + match[0].length;
|
||||
}
|
||||
|
||||
if (!foundOperator) {
|
||||
return query;
|
||||
}
|
||||
|
||||
fragments.push(query.slice(lastIndex));
|
||||
return fragments
|
||||
.map((fragment) => fragment.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function SearchView({
|
||||
contacts,
|
||||
channels,
|
||||
onNavigateToMessage,
|
||||
prefillRequest = null,
|
||||
}: SearchViewProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
@@ -62,6 +95,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
const [offset, setOffset] = useState(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const highlightQuery = getHighlightQuery(debouncedQuery);
|
||||
|
||||
// Debounce query
|
||||
useEffect(() => {
|
||||
@@ -78,6 +112,17 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
setHasMore(false);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefillRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextQuery = prefillRequest.query.trim();
|
||||
setQuery(nextQuery);
|
||||
setDebouncedQuery(nextQuery);
|
||||
inputRef.current?.focus();
|
||||
}, [prefillRequest]);
|
||||
|
||||
// Fetch search results
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery) {
|
||||
@@ -193,7 +238,11 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
<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
|
||||
<p>Type to search across all messages</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Tip: use <code>user:</code> or <code>channel:</code> for keys or names, and wrap names
|
||||
with spaces in them in quotes.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -246,7 +295,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
result.sender_name && result.text.startsWith(`${result.sender_name}: `)
|
||||
? result.text.slice(result.sender_name.length + 2)
|
||||
: result.text,
|
||||
debouncedQuery
|
||||
highlightQuery
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ interface UseConversationNavigationResult {
|
||||
infoPaneContactKey: string | null;
|
||||
infoPaneFromChannel: boolean;
|
||||
infoPaneChannelKey: string | null;
|
||||
searchPrefillRequest: { query: string; nonce: number } | null;
|
||||
handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
handleCloseContactInfo: () => void;
|
||||
handleOpenChannelInfo: (channelKey: string) => void;
|
||||
@@ -24,6 +25,7 @@ interface UseConversationNavigationResult {
|
||||
) => void;
|
||||
handleNavigateToChannel: (channelKey: string) => void;
|
||||
handleNavigateToMessage: (target: SearchNavigateTarget) => void;
|
||||
handleOpenSearchWithQuery: (query: string) => void;
|
||||
}
|
||||
|
||||
export function useConversationNavigation({
|
||||
@@ -34,6 +36,10 @@ export function useConversationNavigation({
|
||||
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
|
||||
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
|
||||
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
|
||||
const [searchPrefillRequest, setSearchPrefillRequest] = useState<{
|
||||
query: string;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => {
|
||||
setInfoPaneContactKey(publicKey);
|
||||
@@ -95,12 +101,30 @@ export function useConversationNavigation({
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
const handleOpenSearchWithQuery = useCallback(
|
||||
(query: string) => {
|
||||
setTargetMessageId(null);
|
||||
setInfoPaneContactKey(null);
|
||||
handleSelectConversationWithTargetReset({
|
||||
type: 'search',
|
||||
id: 'search',
|
||||
name: 'Message Search',
|
||||
});
|
||||
setSearchPrefillRequest((prev) => ({
|
||||
query,
|
||||
nonce: (prev?.nonce ?? 0) + 1,
|
||||
}));
|
||||
},
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
return {
|
||||
targetMessageId,
|
||||
setTargetMessageId,
|
||||
infoPaneContactKey,
|
||||
infoPaneFromChannel,
|
||||
infoPaneChannelKey,
|
||||
searchPrefillRequest,
|
||||
handleOpenContactInfo,
|
||||
handleCloseContactInfo,
|
||||
handleOpenChannelInfo,
|
||||
@@ -108,5 +132,6 @@ export function useConversationNavigation({
|
||||
handleSelectConversationWithTargetReset,
|
||||
handleNavigateToChannel,
|
||||
handleNavigateToMessage,
|
||||
handleOpenSearchWithQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ vi.mock('../components/NewMessageModal', () => ({
|
||||
vi.mock('../components/SearchView', () => ({
|
||||
SearchView: ({
|
||||
onNavigateToMessage,
|
||||
prefillRequest,
|
||||
}: {
|
||||
onNavigateToMessage: (target: {
|
||||
id: number;
|
||||
@@ -139,20 +140,24 @@ vi.mock('../components/SearchView', () => ({
|
||||
conversation_key: string;
|
||||
conversation_name: string;
|
||||
}) => void;
|
||||
prefillRequest?: { query: string; nonce: number } | null;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onNavigateToMessage({
|
||||
id: 321,
|
||||
type: 'CHAN',
|
||||
conversation_key: PUBLIC_CHANNEL_KEY,
|
||||
conversation_name: 'Public',
|
||||
})
|
||||
}
|
||||
>
|
||||
Jump Result
|
||||
</button>
|
||||
<div>
|
||||
<div data-testid="search-prefill">{prefillRequest?.query ?? ''}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onNavigateToMessage({
|
||||
id: 321,
|
||||
type: 'CHAN',
|
||||
conversation_key: PUBLIC_CHANNEL_KEY,
|
||||
conversation_name: 'Public',
|
||||
})
|
||||
}
|
||||
>
|
||||
Jump Result
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -165,7 +170,15 @@ vi.mock('../components/RawPacketList', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../components/ContactInfoPane', () => ({
|
||||
ContactInfoPane: () => null,
|
||||
ContactInfoPane: ({
|
||||
onSearchMessagesByKey,
|
||||
}: {
|
||||
onSearchMessagesByKey?: (publicKey: string) => void;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onSearchMessagesByKey?.('aa'.repeat(32))}>
|
||||
Search Contact By Key
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ChannelInfoPane', () => ({
|
||||
@@ -258,4 +271,23 @@ describe('App search jump target handling', () => {
|
||||
expect(lastCall?.[1]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens search with a prefilled query from the contact pane', async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Search Contact By Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Search Contact By Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('search-prefill')).toHaveTextContent(`user:${'aa'.repeat(32)}`);
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId('active-conversation')
|
||||
.some((node) => node.textContent === 'search:search')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -92,11 +92,15 @@ const baseProps = {
|
||||
config: null,
|
||||
favorites: [],
|
||||
onToggleFavorite: () => {},
|
||||
onSearchMessagesByKey: vi.fn(),
|
||||
onSearchMessagesByName: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ContactInfoPane', () => {
|
||||
beforeEach(() => {
|
||||
getContactAnalytics.mockReset();
|
||||
baseProps.onSearchMessagesByKey = vi.fn();
|
||||
baseProps.onSearchMessagesByName = vi.fn();
|
||||
});
|
||||
|
||||
it('shows hop width when contact has a stored path hash mode', async () => {
|
||||
@@ -190,9 +194,23 @@ describe('ContactInfoPane', () => {
|
||||
screen.getByText(/Name-only analytics include channel messages only/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/same sender name/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Search user's messages by name")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fires the name search callback from the name-only pane', async () => {
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(null, { lookup_type: 'name', name: 'Mystery' })
|
||||
);
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey="name:Mystery" fromChannel />);
|
||||
|
||||
const button = await screen.findByRole('button', { name: "Search user's messages by name" });
|
||||
button.click();
|
||||
|
||||
expect(baseProps.onSearchMessagesByName).toHaveBeenCalledWith('Mystery');
|
||||
});
|
||||
|
||||
it('shows alias note in the channel attribution warning for keyed contacts', async () => {
|
||||
const contact = createContact();
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
@@ -214,6 +232,19 @@ describe('ContactInfoPane', () => {
|
||||
/may include messages previously attributed under names shown in Also Known As/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Search user's messages by key")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fires the key search callback from the keyed pane', async () => {
|
||||
const contact = createContact();
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
const button = await screen.findByRole('button', { name: "Search user's messages by key" });
|
||||
button.click();
|
||||
|
||||
expect(baseProps.onSearchMessagesByKey).toHaveBeenCalledWith(contact.public_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('SearchView', () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Tip: use/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses input on mount', () => {
|
||||
@@ -246,4 +247,37 @@ describe('SearchView', () => {
|
||||
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes raw operator queries to the API and highlights only free text', async () => {
|
||||
mockGetMessages.mockResolvedValue([createSearchResult({ text: 'hello world' })]);
|
||||
|
||||
render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('user:Alice hello');
|
||||
|
||||
expect(mockGetMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ q: 'user:Alice hello' }),
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
expect(screen.getByText('hello', { selector: 'mark' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('user:Alice', { selector: 'mark' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('runs a prefilled search immediately', async () => {
|
||||
mockGetMessages.mockResolvedValue([createSearchResult({ text: 'prefilled result' })]);
|
||||
|
||||
render(
|
||||
<SearchView {...defaultProps} prefillRequest={{ query: 'user:"Alice Smith"', nonce: 1 }} />
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText('Search messages')).toHaveValue('user:"Alice Smith"');
|
||||
expect(mockGetMessages).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ q: 'user:"Alice Smith"' }),
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FullConfig } from '@playwright/test';
|
||||
|
||||
const BASE_URL = 'http://localhost:8000';
|
||||
const BASE_URL = 'http://localhost:8001';
|
||||
const MAX_RETRIES = 10;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* These bypass the UI to set up preconditions and verify backend state.
|
||||
*/
|
||||
|
||||
const BASE_URL = 'http://localhost:8000/api';
|
||||
const BASE_URL = 'http://localhost:8001/api';
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:8000',
|
||||
baseURL: 'http://localhost:8001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
@@ -45,10 +45,10 @@ export default defineConfig({
|
||||
echo "[e2e] $(date +%T.%3N) frontend/dist exists — skipping build"
|
||||
fi
|
||||
echo "[e2e] $(date +%T.%3N) Launching uvicorn..."
|
||||
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
uv run uvicorn app.main:app --host 127.0.0.1 --port 8001
|
||||
'`,
|
||||
cwd: projectRoot,
|
||||
port: 8000,
|
||||
port: 8001,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
env: {
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createChannel, deleteChannel, getChannels } from '../helpers/api';
|
||||
import { createChannel, createContact, deleteChannel, deleteContact } from '../helpers/api';
|
||||
|
||||
test.describe('Sidebar search/filter', () => {
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const nameA = `#alpha${suffix}`;
|
||||
const nameB = `#bravo${suffix}`;
|
||||
const contactName = `Search Contact ${suffix}`;
|
||||
const contactKey = `feed${suffix.padStart(8, '0')}${'ab'.repeat(26)}`;
|
||||
let keyA = '';
|
||||
let keyB = '';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const chA = await createChannel(nameA);
|
||||
const chB = await createChannel(nameB);
|
||||
await createContact(contactKey, contactName);
|
||||
keyA = chA.key;
|
||||
keyB = chB.key;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
await deleteContact(contactKey);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
for (const key of [keyA, keyB]) {
|
||||
try {
|
||||
await deleteChannel(key);
|
||||
@@ -25,27 +33,40 @@ test.describe('Sidebar search/filter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('search filters conversations by name', async ({ page }) => {
|
||||
test('search filters channel and contact conversations by name and key prefix', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Both channels should be visible
|
||||
// Seeded conversations should be visible.
|
||||
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameB, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(contactName, { exact: true })).toBeVisible();
|
||||
|
||||
// Type partial name to filter
|
||||
const searchInput = page.getByLabel('Search conversations');
|
||||
await searchInput.fill(`alpha${suffix}`);
|
||||
|
||||
// Only nameA should be visible
|
||||
// Channel name query should filter to the matching channel only.
|
||||
await searchInput.fill(`alpha${suffix}`);
|
||||
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameB, { exact: true })).not.toBeVisible();
|
||||
await expect(page.getByText(contactName, { exact: true })).not.toBeVisible();
|
||||
|
||||
// Clear search
|
||||
// Contact name query should filter to the matching contact.
|
||||
await searchInput.fill(`contact ${suffix}`);
|
||||
await expect(page.getByText(contactName, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameA, { exact: true })).not.toBeVisible();
|
||||
await expect(page.getByText(nameB, { exact: true })).not.toBeVisible();
|
||||
|
||||
// Contact key prefix query should also match that contact.
|
||||
await searchInput.fill(contactKey.slice(0, 12));
|
||||
await expect(page.getByText(contactName, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameA, { exact: true })).not.toBeVisible();
|
||||
|
||||
// Clear search should restore the full conversation list.
|
||||
await page.getByTitle('Clear search').click();
|
||||
|
||||
// Both should return
|
||||
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameB, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(contactName, { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import pytest
|
||||
|
||||
from app.radio import radio_manager
|
||||
from app.repository import MessageRepository
|
||||
from app.repository import ChannelRepository, ContactRepository, MessageRepository
|
||||
|
||||
CHAN_KEY = "ABC123DEF456ABC123DEF456ABC12345"
|
||||
DM_KEY = "aa" * 32
|
||||
@@ -136,6 +136,181 @@ class TestMessageSearch:
|
||||
assert len(results) == 1
|
||||
assert results[0].sender_name == "Alice"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_user_operator_matches_channel_sender_name(self, test_db):
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="hello from alice",
|
||||
conversation_key=CHAN_KEY,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
sender_name="Alice",
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="hello from bob",
|
||||
conversation_key=CHAN_KEY,
|
||||
sender_timestamp=101,
|
||||
received_at=101,
|
||||
sender_name="Bob",
|
||||
)
|
||||
|
||||
results = await MessageRepository.get_all(q='user:"Alice"')
|
||||
assert [message.text for message in results] == ["hello from alice"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_user_operator_matches_dm_contact_name(self, test_db):
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": DM_KEY,
|
||||
"name": "Alice Smith",
|
||||
"type": 1,
|
||||
}
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="hello from dm",
|
||||
conversation_key=DM_KEY,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="hello from other dm",
|
||||
conversation_key=("bb" * 32),
|
||||
sender_timestamp=101,
|
||||
received_at=101,
|
||||
)
|
||||
|
||||
results = await MessageRepository.get_all(q='user:"Alice Smith"')
|
||||
assert [message.text for message in results] == ["hello from dm"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_user_operator_matches_key_prefix(self, test_db):
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="dm by key prefix",
|
||||
conversation_key=DM_KEY,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="chan by key prefix",
|
||||
conversation_key=CHAN_KEY,
|
||||
sender_timestamp=101,
|
||||
received_at=101,
|
||||
sender_key=DM_KEY,
|
||||
sender_name="Alice",
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="other dm",
|
||||
conversation_key=("bb" * 32),
|
||||
sender_timestamp=102,
|
||||
received_at=102,
|
||||
)
|
||||
|
||||
results = await MessageRepository.get_all(q=f"user:{DM_KEY[:12]}")
|
||||
assert [message.text for message in results] == ["chan by key prefix", "dm by key prefix"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_channel_operator_matches_channel_name(self, test_db):
|
||||
await ChannelRepository.upsert(key=CHAN_KEY, name="#flightless", is_hashtag=True)
|
||||
await ChannelRepository.upsert(key=OTHER_CHAN_KEY, name="#other", is_hashtag=True)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="hello flightless",
|
||||
conversation_key=CHAN_KEY,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="hello elsewhere",
|
||||
conversation_key=OTHER_CHAN_KEY,
|
||||
sender_timestamp=101,
|
||||
received_at=101,
|
||||
)
|
||||
|
||||
results = await MessageRepository.get_all(q='channel:"#flightless"')
|
||||
assert [message.text for message in results] == ["hello flightless"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_channel_operator_matches_quoted_name_with_spaces(self, test_db):
|
||||
await ChannelRepository.upsert(key=CHAN_KEY, name="#Ops Room", is_hashtag=True)
|
||||
await ChannelRepository.upsert(key=OTHER_CHAN_KEY, name="#Other Room", is_hashtag=True)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="hello ops room",
|
||||
conversation_key=CHAN_KEY,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="hello other room",
|
||||
conversation_key=OTHER_CHAN_KEY,
|
||||
sender_timestamp=101,
|
||||
received_at=101,
|
||||
)
|
||||
|
||||
results = await MessageRepository.get_all(q='channel:"#Ops Room"')
|
||||
assert [message.text for message in results] == ["hello ops room"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_channel_operator_matches_channel_key_prefix(self, test_db):
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="chan by key",
|
||||
conversation_key=CHAN_KEY,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="other channel",
|
||||
conversation_key=OTHER_CHAN_KEY,
|
||||
sender_timestamp=101,
|
||||
received_at=101,
|
||||
)
|
||||
|
||||
results = await MessageRepository.get_all(q=f"channel:{CHAN_KEY[:8]}")
|
||||
assert [message.text for message in results] == ["chan by key"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_scope_operators_and_free_text_are_combined(self, test_db):
|
||||
await ChannelRepository.upsert(key=CHAN_KEY, name="#flightless", is_hashtag=True)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="hello operator",
|
||||
conversation_key=CHAN_KEY,
|
||||
sender_timestamp=100,
|
||||
received_at=100,
|
||||
sender_name="Alice",
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="goodbye operator",
|
||||
conversation_key=CHAN_KEY,
|
||||
sender_timestamp=101,
|
||||
received_at=101,
|
||||
sender_name="Alice",
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="hello operator",
|
||||
conversation_key=OTHER_CHAN_KEY,
|
||||
sender_timestamp=102,
|
||||
received_at=102,
|
||||
sender_name="Bob",
|
||||
)
|
||||
|
||||
results = await MessageRepository.get_all(
|
||||
q='user:Alice channel:"#flightless" hello operator'
|
||||
)
|
||||
assert [message.text for message in results] == ["hello operator"]
|
||||
|
||||
|
||||
class TestMessagesAround:
|
||||
"""Tests for get_around()."""
|
||||
|
||||
Reference in New Issue
Block a user