mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Add better search management and operators + contact search quick link
This commit is contained in:
@@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user