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
+13
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 ?? [],
+31 -1
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">
+52 -3
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>
@@ -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,
};
}
+46 -14
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);
});
});
});
@@ -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);
});
});
+34
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)
);
});
});