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