Search Engine: Improved UI/UX

pull/1154/head
NanoAim 2025-08-08 21:42:26 +08:00
parent e7acff0696
commit ea40cb82a9
2 changed files with 380 additions and 89 deletions

View File

@ -8,6 +8,8 @@ import { useSearchAutoComplete, transformSearchQuery, UserMapping } from "../../
import SearchAutoComplete from "./SearchAutoComplete";
import SearchDatePicker from "./SearchDatePicker";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../mobx/State";
import { SIDEBAR_MEMBERS } from "../../mobx/stores/Layout";
const Container = styled.div`
position: relative;
@ -201,6 +203,11 @@ const searchOptions: SearchOption[] = [
description: "user",
tooltip: "Find messages mentioning a user"
},
{
label: "has:",
description: "video, image, link, audio, file",
tooltip: "Messages with specific attachment types"
},
{
label: "before:",
description: "specific date",
@ -225,15 +232,11 @@ const searchOptions: SearchOption[] = [
label: "server-wide",
description: "Entire server",
tooltip: "Search in entire server instead of just this channel"
},
{
label: "has:",
description: "video, image, link, audio, file",
tooltip: "Messages with specific attachment types"
}
];
export function SearchBar() {
const layout = useApplicationState().layout;
const [query, setQuery] = useState("");
const [showOptions, setShowOptions] = useState(false);
const [isSearching, setIsSearching] = useState(false);
@ -246,28 +249,72 @@ export function SearchBar() {
startDisplay: string;
endDisplay: string;
} | null>(null);
const [showServerWideError, setShowServerWideError] = useState(false);
const [showDateRangeError, setShowDateRangeError] = useState(false);
const [showMultipleHasError, setShowMultipleHasError] = useState(false);
const [showDuplicateFilterError, setShowDuplicateFilterError] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const skipNextFocus = useRef(false);
// Setup autocomplete
const {
state: autocompleteState,
setState: setAutocompleteState,
onKeyUp,
onKeyDown: onAutocompleteKeyDown,
onKeyDown: originalOnAutocompleteKeyDown,
onChange: onAutocompleteChange,
onClick: onAutocompleteClick,
onClick: originalOnAutocompleteClick,
onFocus: onAutocompleteFocus,
onBlur: onAutocompleteBlur,
} = useSearchAutoComplete(setQuery, userMappings, setUserMappings);
// Wrap the autocomplete click to show options menu after selection
const onAutocompleteClick = (userId: string, username: string) => {
originalOnAutocompleteClick(userId, username);
// Show options menu after user selection
setTimeout(() => {
setShowOptions(true);
}, 100);
};
// Wrap the onKeyDown handler to show options menu after Enter selection
const onAutocompleteKeyDown = (e: KeyboardEvent) => {
const handled = originalOnAutocompleteKeyDown(e);
// If Enter or Tab was pressed and autocomplete was active, show options menu
if (handled && (e.key === "Enter" || e.key === "Tab") && autocompleteState.type !== "none") {
setTimeout(() => {
setShowOptions(true);
}, 100);
}
return handled;
};
const handleFocus = () => {
// Check if we should skip showing options this time
if (skipNextFocus.current) {
skipNextFocus.current = false;
onAutocompleteFocus();
return;
}
onAutocompleteFocus();
setShowOptions(true);
// Don't show options if we're in the middle of typing a filter
const hasIncompleteFilter = query.match(/\b(from:|mentions:|has:|before:|after:|during:|between:)\s*$/);
if (!hasIncompleteFilter && !showDatePicker && !showAttachmentTypes && autocompleteState.type === "none") {
setShowOptions(true);
}
};
const handleClick = () => {
// Show options when clicking on the input, even if already focused
if (!showOptions && autocompleteState.type === "none" && !showDatePicker) {
// Check if we're currently editing a filter
const cursorPos = inputRef.current?.selectionStart || 0;
const beforeCursor = query.slice(0, cursorPos);
// Check if cursor is within a filter
const isInFilter = beforeCursor.match(/\b(from:|mentions:|has:|before:|after:|during:|between:)[^\\s]*$/);
// Show options when clicking on the input, if not editing a filter
if (!isInFilter && !showOptions && autocompleteState.type === "none" && !showDatePicker && !showAttachmentTypes) {
setShowOptions(true);
}
};
@ -276,11 +323,12 @@ export function SearchBar() {
onAutocompleteBlur();
// Delay to allow clicking on options
setTimeout(() => {
// Check if we have an incomplete filter
const hasIncompleteFilter = query.match(/\b(from:|mentions:)\s*$/);
// Check if we have an incomplete filter that should keep autocomplete open
const hasUserFilter = query.match(/\b(from:|mentions:)[\s\S]*$/);
const hasIncompleteDateFilter = query.match(/\b(before:|after:|during:)\s*$/);
if (!hasIncompleteFilter && !hasIncompleteDateFilter && !showDatePicker) {
// Don't close options/autocomplete if we have a user filter
if (!hasUserFilter && !hasIncompleteDateFilter && !showDatePicker) {
setShowOptions(false);
if (autocompleteState.type === "none") {
setAutocompleteState({ type: "none" });
@ -304,6 +352,22 @@ export function SearchBar() {
// Check for filters
const beforeCursor = value.slice(0, cursorPos);
// Check for user filters (from: and mentions:) - these should trigger autocomplete
const fromMatch = beforeCursor.match(/\bfrom:[\s\S]*$/);
const mentionsMatch = beforeCursor.match(/\bmentions:[\s\S]*$/);
if (fromMatch || mentionsMatch) {
// Close other dropdowns
setShowOptions(false);
setShowDatePicker(null);
setShowAttachmentTypes(false);
// Trigger autocomplete immediately for user filters
onAutocompleteChange(e);
return;
}
// Check for date filters
const beforeMatch = beforeCursor.match(/\bbefore:\s*$/);
const afterMatch = beforeCursor.match(/\bafter:\s*$/);
const duringMatch = beforeCursor.match(/\bduring:\s*$/);
@ -342,7 +406,7 @@ export function SearchBar() {
setShowAttachmentTypes(false);
}
// Only trigger autocomplete if no filter is active
// Only trigger autocomplete if no date/has filter is active
const filterActive = value.match(/\b(before:|after:|during:|between:|has:)\s*$/);
if (!filterActive) {
onAutocompleteChange(e);
@ -366,8 +430,47 @@ export function SearchBar() {
return;
}
// Check for duplicate filters (only one of each type allowed)
const checkDuplicates = (pattern: RegExp, filterName: string): boolean => {
const matches = trimmedQuery.match(pattern) || [];
if (matches.length > 1) {
setShowDuplicateFilterError(true);
setTimeout(() => setShowDuplicateFilterError(false), 3000);
return true;
}
return false;
};
// Check each filter type for duplicates
if (checkDuplicates(/\bfrom:@?[\w-]+/gi, "from")) return;
if (checkDuplicates(/\bmentions:@?[\w-]+/gi, "mentions")) return;
if (checkDuplicates(/\bbefore:\d{4}-\d{2}-\d{2}/gi, "before")) return;
if (checkDuplicates(/\bafter:\d{4}-\d{2}-\d{2}/gi, "after")) return;
if (checkDuplicates(/\bduring:\d{4}-\d{2}-\d{2}/gi, "during")) return;
if (checkDuplicates(/\bbetween:\d{4}-\d{2}-\d{2}\.\.\d{4}-\d{2}-\d{2}/gi, "between")) return;
// Check for multiple has: filters (only one attachment type filter allowed)
const hasFilterMatches = trimmedQuery.match(/\bhas:(video|image|link|audio|file)/gi) || [];
if (hasFilterMatches.length > 1) {
// Show tooltip error message - only one attachment type filter is allowed
setShowMultipleHasError(true);
// Auto-hide after 3 seconds
setTimeout(() => setShowMultipleHasError(false), 3000);
return;
}
// Check for multiple date-range occurrences
const dateRangeCount = (trimmedQuery.match(/\bdate-range\b/g) || []).length;
if (dateRangeCount > 1) {
// Show tooltip error message - only one date range is allowed
setShowDateRangeError(true);
// Auto-hide after 3 seconds
setTimeout(() => setShowDateRangeError(false), 3000);
return;
}
// Check if we have "date-range" in the query and replace it with actual dates
if (trimmedQuery.includes("date-range") && activeDateRange) {
if (dateRangeCount === 1 && activeDateRange) {
// Replace "date-range" with the actual between filter for processing
trimmedQuery = trimmedQuery.replace(/\bdate-range\b/, `between:${activeDateRange.start}..${activeDateRange.end}`);
}
@ -375,6 +478,22 @@ export function SearchBar() {
// Transform query to use user IDs
const searchParams = transformSearchQuery(trimmedQuery, userMappings);
// Check if only server-wide is present without other filters
if (searchParams.server_wide &&
!searchParams.query &&
!searchParams.author &&
!searchParams.mention &&
!searchParams.date_start &&
!searchParams.date_end &&
!searchParams.has) {
// Show tooltip error message - server-wide requires other filters
setShowServerWideError(true);
// Auto-hide after 3 seconds
setTimeout(() => setShowServerWideError(false), 3000);
return;
}
// Check if we have any search criteria (query text or filters)
// Allow empty query string if filters are present
const hasFilters = searchParams.author ||
@ -387,8 +506,18 @@ export function SearchBar() {
const hasSearchCriteria = (searchParams.query && searchParams.query.trim() !== "") || hasFilters;
if (hasSearchCriteria) {
// Open search in right sidebar with transformed query
internalEmit("RightSidebar", "open", "search", searchParams);
// Ensure the sidebar container is visible for desktop users
if (!isTouchscreenDevice && !layout.getSectionState(SIDEBAR_MEMBERS, true)) {
layout.setSectionState(SIDEBAR_MEMBERS, true, true);
// Small delay to ensure the sidebar container is rendered first
setTimeout(() => {
internalEmit("RightSidebar", "open", "search", searchParams);
}, 50);
} else {
// Sidebar is already visible, emit immediately
internalEmit("RightSidebar", "open", "search", searchParams);
}
setShowOptions(false);
setIsSearching(true);
setAutocompleteState({ type: "none" });
@ -700,12 +829,16 @@ export function SearchBar() {
if (option.label === "before:" || option.label === "after:" || option.label === "during:" || option.label === "between:") {
setShowDatePicker(option.label.slice(0, -1) as "before" | "after" | "during" | "between");
setShowOptions(false);
// Ensure autocomplete is hidden
setAutocompleteState({ type: "none" });
} else if (option.label === "has:") {
// For has: filter, add it and show attachment type options
const newQuery = query + (query ? " " : "") + "has:";
setQuery(newQuery);
setShowOptions(false);
setShowAttachmentTypes(true);
// Ensure autocomplete is hidden
setAutocompleteState({ type: "none" });
// Move cursor after "has:"
setTimeout(() => {
@ -720,6 +853,11 @@ export function SearchBar() {
const newQuery = query + (query ? " " : "") + "server-wide ";
setQuery(newQuery);
setShowOptions(false);
// Ensure autocomplete is hidden
setAutocompleteState({ type: "none" });
// Set flag to skip showing options on the next focus
skipNextFocus.current = true;
// Move cursor to end after the space
setTimeout(() => {
@ -729,10 +867,26 @@ export function SearchBar() {
inputRef.current.focus();
}
}, 0);
} else if (option.label === "from:" || option.label === "mentions:") {
// For user filters, add the text and let the user type to trigger autocomplete
const newQuery = query + (query ? " " : "") + option.label;
setQuery(newQuery);
setShowOptions(false);
// Focus and position cursor
setTimeout(() => {
if (inputRef.current) {
const endPos = newQuery.length;
inputRef.current.setSelectionRange(endPos, endPos);
inputRef.current.focus();
}
}, 0);
} else {
// For other filters, add the text immediately
const newQuery = query + (query ? " " : "") + option.label;
setQuery(newQuery);
// Ensure autocomplete is hidden
setAutocompleteState({ type: "none" });
inputRef.current?.focus();
}
};
@ -854,15 +1008,31 @@ export function SearchBar() {
onKeyDown={handleKeyDown}
onKeyUp={onKeyUp}
/>
{isSearching ? (
<IconButton onClick={handleClear}>
<X size={18} />
</IconButton>
) : (
<IconButton onClick={handleSearch}>
<Search size={18} />
</IconButton>
)}
<Tooltip
content={
showServerWideError
? "Server-wide search requires at least one other filter or search term"
: showDateRangeError
? "Only one date range filter is allowed"
: showMultipleHasError
? "Only one attachment type filter is allowed"
: showDuplicateFilterError
? "Only one of each filter type is allowed"
: ""
}
visible={!isSearching && (showServerWideError || showDateRangeError || showMultipleHasError || showDuplicateFilterError)}
placement="bottom"
>
{isSearching ? (
<IconButton onClick={handleClear}>
<X size={18} />
</IconButton>
) : (
<IconButton onClick={handleSearch}>
<Search size={18} />
</IconButton>
)}
</Tooltip>
{autocompleteState.type !== "none" && (
<SearchAutoComplete
state={autocompleteState}

View File

@ -21,7 +21,10 @@ type SearchState =
}
| {
type: "results";
results: MessageI[];
pages: Map<number, MessageI[]>;
currentPage: number;
hasMore: boolean;
isLoadingPage?: boolean;
};
// Custom wider sidebar for search results
@ -128,19 +131,48 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
const [state, setState] = useState<SearchState>({ type: "waiting" });
const [savedSearchParams, setSavedSearchParams] = useState(searchParams);
const [pageLastMessageIds, setPageLastMessageIds] = useState<Map<number, string>>(new Map());
const MESSAGES_PER_PAGE = 50;
async function search() {
async function searchPage(pageNumber: number) {
const searchQuery = searchParams?.query || query;
if (!searchQuery && !searchParams?.author && !searchParams?.mention &&
!searchParams?.date_start && !searchParams?.date_end &&
!searchParams?.has && !searchParams?.server_wide) return;
setState({ type: "loading" });
// Check if we already have this page cached
if (state.type === "results" && state.pages.has(pageNumber) && pageNumber !== state.currentPage) {
setState({ ...state, currentPage: pageNumber });
return;
}
// Mark as loading page
if (state.type === "results") {
setState({ ...state, isLoadingPage: true });
} else {
setState({ type: "loading" });
}
const searchOptions: any = {
query: searchQuery,
sort
sort,
limit: MESSAGES_PER_PAGE
};
// Add pagination cursor for pages after the first
if (pageNumber > 1) {
const previousPageLastId = pageLastMessageIds.get(pageNumber - 1);
if (previousPageLastId) {
searchOptions.before = previousPageLastId;
} else {
// If we don't have the previous page, we need to load pages sequentially
// This shouldn't happen in normal navigation
console.warn("Previous page not loaded, loading sequentially");
return;
}
}
// Add user filters if provided
if (searchParams?.author) {
searchOptions.author = searchParams.author;
@ -168,17 +200,72 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
}
const data = await channel.searchWithUsers(searchOptions);
setState({ type: "results", results: data.messages });
// Store the last message ID for this page
if (data.messages.length > 0) {
const newPageLastIds = new Map(pageLastMessageIds);
newPageLastIds.set(pageNumber, data.messages[data.messages.length - 1]._id);
setPageLastMessageIds(newPageLastIds);
}
if (state.type === "results") {
// Add this page to the cache
const newPages = new Map(state.pages);
newPages.set(pageNumber, data.messages);
setState({
type: "results",
pages: newPages,
currentPage: pageNumber,
hasMore: data.messages.length === MESSAGES_PER_PAGE,
isLoadingPage: false
});
} else {
// First page load
const newPages = new Map<number, MessageI[]>();
newPages.set(1, data.messages);
setState({
type: "results",
pages: newPages,
currentPage: 1,
hasMore: data.messages.length === MESSAGES_PER_PAGE,
isLoadingPage: false
});
}
}
function goToNextPage() {
if (state.type === "results" && state.hasMore && !state.isLoadingPage) {
searchPage(state.currentPage + 1);
}
}
function goToPreviousPage() {
if (state.type === "results" && state.currentPage > 1 && !state.isLoadingPage) {
searchPage(state.currentPage - 1);
}
}
useEffect(() => {
search();
// Reset to page 1 when search params change
searchPage(1);
// Clear cached pages when search params change
setPageLastMessageIds(new Map());
// Save search params when they change
if (searchParams) {
setSavedSearchParams(searchParams);
}
// eslint-disable-next-line
}, [sort, query, searchParams]);
}, [
sort,
query,
searchParams?.query,
searchParams?.author,
searchParams?.mention,
searchParams?.date_start,
searchParams?.date_end,
searchParams?.has,
searchParams?.server_wide
]);
return (
<SearchSidebarBase>
@ -201,38 +288,31 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
))}
</div>
{state.type === "loading" && <Preloader type="ring" />}
{state.type === "results" && (
<>
<Overline type="subtle" block style={{ textAlign: 'center', marginTop: '12px' }}>
{state.results.length > 0
? `${state.results.length} Result${state.results.length === 1 ? '' : 's'}`
: 'No Results'
}
</Overline>
<div className="list">
{(() => {
// Group messages by channel
const groupedMessages = state.results.reduce((acc, message) => {
const channelId = message.channel_id;
if (!acc[channelId]) {
acc[channelId] = [];
}
acc[channelId].push(message);
return acc;
}, {} as Record<string, MessageI[]>);
const client = useClient();
return Object.entries(groupedMessages).map(([channelId, messages]) => {
const messageChannel = client.channels.get(channelId);
const channelName = messageChannel?.name || "Unknown Channel";
{state.type === "results" && (() => {
const currentPageMessages = state.pages.get(state.currentPage) || [];
return (
<>
<Overline type="subtle" block style={{ textAlign: 'center', marginTop: '12px' }}>
{currentPageMessages.length > 0
? currentPageMessages.length === 1 ? 'Result' : 'Results'
: 'No Results'
}
</Overline>
return (
<div key={channelId}>
<Overline type="subtle" block>
# {channelName}
</Overline>
{messages.map((message) => {
{state.isLoadingPage ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Preloader type="ring" />
</div>
) : (
<div className="list">
{currentPageMessages.map((message, index) => {
const messageChannel = client.channels.get(message.channel_id);
const channelName = messageChannel?.name || "Unknown Channel";
// Check if this is the first message or if the channel changed from the previous message
const showChannelIndicator = index === 0 ||
message.channel_id !== currentPageMessages[index - 1].channel_id;
let href = "";
if (messageChannel?.channel_type === "TextChannel") {
href += `/server/${messageChannel.server_id}`;
@ -240,35 +320,76 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
href += `/channel/${message.channel_id}/${message._id}`;
return (
<div
key={message._id}
className="message"
style={{ cursor: 'pointer' }}
onClick={(e) => {
e.preventDefault();
// Navigate to the message
history.push(href);
// Re-emit the search sidebar with the same params to keep it open
setTimeout(() => {
internalEmit("RightSidebar", "open", "search", savedSearchParams || searchParams);
}, 100);
}}
>
<Message
message={message}
head
hideReply
/>
<div key={message._id}>
{showChannelIndicator && (
<Overline type="subtle" block>
# {channelName}
</Overline>
)}
<div
className="message"
style={{ cursor: 'pointer' }}
onClick={(e) => {
e.preventDefault();
// Navigate to the message
history.push(href);
// Re-emit the search sidebar with the same params to keep it open
setTimeout(() => {
internalEmit("RightSidebar", "open", "search", savedSearchParams || searchParams);
}, 100);
}}
>
<Message
message={message}
head
hideReply
/>
</div>
</div>
);
})}
</div>
);
});
})()}
</div>
</>
)}
)}
{/* Navigation with page count at the bottom - only show if there are results */}
{currentPageMessages.length > 0 && (
<div style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
justifyContent: 'center',
margin: '16px 0 8px 0'
}}>
<Button
compact
palette="secondary"
disabled={state.currentPage === 1 || state.isLoadingPage}
onClick={goToPreviousPage}
>
Back
</Button>
<span style={{
color: 'var(--tertiary-foreground)',
fontSize: '13px',
fontWeight: '500'
}}>
Page {state.currentPage}
</span>
<Button
compact
palette="secondary"
disabled={!state.hasMore || state.isLoadingPage}
onClick={goToNextPage}
>
Next
</Button>
</div>
)}
</>
);
})()}
</SearchBase>
</GenericSidebarList>
</SearchSidebarBase>