Add better search management and operators + contact search quick link

This commit is contained in:
Jack Kingsman
2026-03-11 16:56:09 -07:00
parent ce9bbd1059
commit ad7028e508
13 changed files with 587 additions and 48 deletions

View File

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

View File

@@ -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 ?? [],

View File

@@ -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&apos;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&apos;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">

View File

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

View File

@@ -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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}`, {

View File

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

View File

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

View File

@@ -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()."""