diff --git a/src/components/navigation/SearchBar.tsx b/src/components/navigation/SearchBar.tsx index 7e0b6659..9954c2fe 100644 --- a/src/components/navigation/SearchBar.tsx +++ b/src/components/navigation/SearchBar.tsx @@ -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(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 ? ( - - - - ) : ( - - - - )} + + {isSearching ? ( + + + + ) : ( + + + + )} + {autocompleteState.type !== "none" && ( ; + 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({ type: "waiting" }); const [savedSearchParams, setSavedSearchParams] = useState(searchParams); + const [pageLastMessageIds, setPageLastMessageIds] = useState>(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(); + 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 ( @@ -201,38 +288,31 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props) ))} {state.type === "loading" && } - {state.type === "results" && ( - <> - - {state.results.length > 0 - ? `${state.results.length} Result${state.results.length === 1 ? '' : 's'}` - : 'No Results' - } - -
- {(() => { - // 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); - - 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 ( + <> + + {currentPageMessages.length > 0 + ? currentPageMessages.length === 1 ? 'Result' : 'Results' + : 'No Results' + } + - return ( -
- - # {channelName} - - {messages.map((message) => { + {state.isLoadingPage ? ( +
+ +
+ ) : ( +
+ {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 ( -
{ - 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); - }} - > - +
+ {showChannelIndicator && ( + + # {channelName} + + )} +
{ + 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); + }} + > + +
); })}
- ); - }); - })()} -
- - )} + )} + + {/* Navigation with page count at the bottom - only show if there are results */} + {currentPageMessages.length > 0 && ( +
+ + + + Page {state.currentPage} + + + +
+ )} + + ); + })()}