mirror of
https://github.com/stoatchat/for-legacy-web.git
synced 2026-03-06 08:38:37 +00:00
Search Engine: Improved UI/UX
This commit is contained in:
@@ -8,6 +8,8 @@ import { useSearchAutoComplete, transformSearchQuery, UserMapping } from "../../
|
|||||||
import SearchAutoComplete from "./SearchAutoComplete";
|
import SearchAutoComplete from "./SearchAutoComplete";
|
||||||
import SearchDatePicker from "./SearchDatePicker";
|
import SearchDatePicker from "./SearchDatePicker";
|
||||||
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
||||||
|
import { useApplicationState } from "../../mobx/State";
|
||||||
|
import { SIDEBAR_MEMBERS } from "../../mobx/stores/Layout";
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -201,6 +203,11 @@ const searchOptions: SearchOption[] = [
|
|||||||
description: "user",
|
description: "user",
|
||||||
tooltip: "Find messages mentioning a user"
|
tooltip: "Find messages mentioning a user"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "has:",
|
||||||
|
description: "video, image, link, audio, file",
|
||||||
|
tooltip: "Messages with specific attachment types"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "before:",
|
label: "before:",
|
||||||
description: "specific date",
|
description: "specific date",
|
||||||
@@ -225,15 +232,11 @@ const searchOptions: SearchOption[] = [
|
|||||||
label: "server-wide",
|
label: "server-wide",
|
||||||
description: "Entire server",
|
description: "Entire server",
|
||||||
tooltip: "Search in entire server instead of just this channel"
|
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() {
|
export function SearchBar() {
|
||||||
|
const layout = useApplicationState().layout;
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [showOptions, setShowOptions] = useState(false);
|
const [showOptions, setShowOptions] = useState(false);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
@@ -246,28 +249,72 @@ export function SearchBar() {
|
|||||||
startDisplay: string;
|
startDisplay: string;
|
||||||
endDisplay: string;
|
endDisplay: string;
|
||||||
} | null>(null);
|
} | 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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const skipNextFocus = useRef(false);
|
||||||
|
|
||||||
// Setup autocomplete
|
// Setup autocomplete
|
||||||
const {
|
const {
|
||||||
state: autocompleteState,
|
state: autocompleteState,
|
||||||
setState: setAutocompleteState,
|
setState: setAutocompleteState,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
onKeyDown: onAutocompleteKeyDown,
|
onKeyDown: originalOnAutocompleteKeyDown,
|
||||||
onChange: onAutocompleteChange,
|
onChange: onAutocompleteChange,
|
||||||
onClick: onAutocompleteClick,
|
onClick: originalOnAutocompleteClick,
|
||||||
onFocus: onAutocompleteFocus,
|
onFocus: onAutocompleteFocus,
|
||||||
onBlur: onAutocompleteBlur,
|
onBlur: onAutocompleteBlur,
|
||||||
} = useSearchAutoComplete(setQuery, userMappings, setUserMappings);
|
} = 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 = () => {
|
const handleFocus = () => {
|
||||||
|
// Check if we should skip showing options this time
|
||||||
|
if (skipNextFocus.current) {
|
||||||
|
skipNextFocus.current = false;
|
||||||
|
onAutocompleteFocus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
onAutocompleteFocus();
|
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 = () => {
|
const handleClick = () => {
|
||||||
// Show options when clicking on the input, even if already focused
|
// Check if we're currently editing a filter
|
||||||
if (!showOptions && autocompleteState.type === "none" && !showDatePicker) {
|
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);
|
setShowOptions(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -276,11 +323,12 @@ export function SearchBar() {
|
|||||||
onAutocompleteBlur();
|
onAutocompleteBlur();
|
||||||
// Delay to allow clicking on options
|
// Delay to allow clicking on options
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// Check if we have an incomplete filter
|
// Check if we have an incomplete filter that should keep autocomplete open
|
||||||
const hasIncompleteFilter = query.match(/\b(from:|mentions:)\s*$/);
|
const hasUserFilter = query.match(/\b(from:|mentions:)[\s\S]*$/);
|
||||||
const hasIncompleteDateFilter = query.match(/\b(before:|after:|during:)\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);
|
setShowOptions(false);
|
||||||
if (autocompleteState.type === "none") {
|
if (autocompleteState.type === "none") {
|
||||||
setAutocompleteState({ type: "none" });
|
setAutocompleteState({ type: "none" });
|
||||||
@@ -304,6 +352,22 @@ export function SearchBar() {
|
|||||||
|
|
||||||
// Check for filters
|
// Check for filters
|
||||||
const beforeCursor = value.slice(0, cursorPos);
|
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 beforeMatch = beforeCursor.match(/\bbefore:\s*$/);
|
||||||
const afterMatch = beforeCursor.match(/\bafter:\s*$/);
|
const afterMatch = beforeCursor.match(/\bafter:\s*$/);
|
||||||
const duringMatch = beforeCursor.match(/\bduring:\s*$/);
|
const duringMatch = beforeCursor.match(/\bduring:\s*$/);
|
||||||
@@ -342,7 +406,7 @@ export function SearchBar() {
|
|||||||
setShowAttachmentTypes(false);
|
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*$/);
|
const filterActive = value.match(/\b(before:|after:|during:|between:|has:)\s*$/);
|
||||||
if (!filterActive) {
|
if (!filterActive) {
|
||||||
onAutocompleteChange(e);
|
onAutocompleteChange(e);
|
||||||
@@ -366,8 +430,47 @@ export function SearchBar() {
|
|||||||
return;
|
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
|
// 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
|
// Replace "date-range" with the actual between filter for processing
|
||||||
trimmedQuery = trimmedQuery.replace(/\bdate-range\b/, `between:${activeDateRange.start}..${activeDateRange.end}`);
|
trimmedQuery = trimmedQuery.replace(/\bdate-range\b/, `between:${activeDateRange.start}..${activeDateRange.end}`);
|
||||||
}
|
}
|
||||||
@@ -375,6 +478,22 @@ export function SearchBar() {
|
|||||||
// Transform query to use user IDs
|
// Transform query to use user IDs
|
||||||
const searchParams = transformSearchQuery(trimmedQuery, userMappings);
|
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)
|
// Check if we have any search criteria (query text or filters)
|
||||||
// Allow empty query string if filters are present
|
// Allow empty query string if filters are present
|
||||||
const hasFilters = searchParams.author ||
|
const hasFilters = searchParams.author ||
|
||||||
@@ -387,8 +506,18 @@ export function SearchBar() {
|
|||||||
const hasSearchCriteria = (searchParams.query && searchParams.query.trim() !== "") || hasFilters;
|
const hasSearchCriteria = (searchParams.query && searchParams.query.trim() !== "") || hasFilters;
|
||||||
|
|
||||||
if (hasSearchCriteria) {
|
if (hasSearchCriteria) {
|
||||||
// Open search in right sidebar with transformed query
|
// Ensure the sidebar container is visible for desktop users
|
||||||
internalEmit("RightSidebar", "open", "search", searchParams);
|
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);
|
setShowOptions(false);
|
||||||
setIsSearching(true);
|
setIsSearching(true);
|
||||||
setAutocompleteState({ type: "none" });
|
setAutocompleteState({ type: "none" });
|
||||||
@@ -700,12 +829,16 @@ export function SearchBar() {
|
|||||||
if (option.label === "before:" || option.label === "after:" || option.label === "during:" || option.label === "between:") {
|
if (option.label === "before:" || option.label === "after:" || option.label === "during:" || option.label === "between:") {
|
||||||
setShowDatePicker(option.label.slice(0, -1) as "before" | "after" | "during" | "between");
|
setShowDatePicker(option.label.slice(0, -1) as "before" | "after" | "during" | "between");
|
||||||
setShowOptions(false);
|
setShowOptions(false);
|
||||||
|
// Ensure autocomplete is hidden
|
||||||
|
setAutocompleteState({ type: "none" });
|
||||||
} else if (option.label === "has:") {
|
} else if (option.label === "has:") {
|
||||||
// For has: filter, add it and show attachment type options
|
// For has: filter, add it and show attachment type options
|
||||||
const newQuery = query + (query ? " " : "") + "has:";
|
const newQuery = query + (query ? " " : "") + "has:";
|
||||||
setQuery(newQuery);
|
setQuery(newQuery);
|
||||||
setShowOptions(false);
|
setShowOptions(false);
|
||||||
setShowAttachmentTypes(true);
|
setShowAttachmentTypes(true);
|
||||||
|
// Ensure autocomplete is hidden
|
||||||
|
setAutocompleteState({ type: "none" });
|
||||||
|
|
||||||
// Move cursor after "has:"
|
// Move cursor after "has:"
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -720,6 +853,11 @@ export function SearchBar() {
|
|||||||
const newQuery = query + (query ? " " : "") + "server-wide ";
|
const newQuery = query + (query ? " " : "") + "server-wide ";
|
||||||
setQuery(newQuery);
|
setQuery(newQuery);
|
||||||
setShowOptions(false);
|
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
|
// Move cursor to end after the space
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -729,10 +867,26 @@ export function SearchBar() {
|
|||||||
inputRef.current.focus();
|
inputRef.current.focus();
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 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 {
|
} else {
|
||||||
// For other filters, add the text immediately
|
// For other filters, add the text immediately
|
||||||
const newQuery = query + (query ? " " : "") + option.label;
|
const newQuery = query + (query ? " " : "") + option.label;
|
||||||
setQuery(newQuery);
|
setQuery(newQuery);
|
||||||
|
// Ensure autocomplete is hidden
|
||||||
|
setAutocompleteState({ type: "none" });
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -854,15 +1008,31 @@ export function SearchBar() {
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
/>
|
/>
|
||||||
{isSearching ? (
|
<Tooltip
|
||||||
<IconButton onClick={handleClear}>
|
content={
|
||||||
<X size={18} />
|
showServerWideError
|
||||||
</IconButton>
|
? "Server-wide search requires at least one other filter or search term"
|
||||||
) : (
|
: showDateRangeError
|
||||||
<IconButton onClick={handleSearch}>
|
? "Only one date range filter is allowed"
|
||||||
<Search size={18} />
|
: showMultipleHasError
|
||||||
</IconButton>
|
? "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" && (
|
{autocompleteState.type !== "none" && (
|
||||||
<SearchAutoComplete
|
<SearchAutoComplete
|
||||||
state={autocompleteState}
|
state={autocompleteState}
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ type SearchState =
|
|||||||
}
|
}
|
||||||
| {
|
| {
|
||||||
type: "results";
|
type: "results";
|
||||||
results: MessageI[];
|
pages: Map<number, MessageI[]>;
|
||||||
|
currentPage: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
isLoadingPage?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Custom wider sidebar for search results
|
// 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 [state, setState] = useState<SearchState>({ type: "waiting" });
|
||||||
const [savedSearchParams, setSavedSearchParams] = useState(searchParams);
|
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;
|
const searchQuery = searchParams?.query || query;
|
||||||
if (!searchQuery && !searchParams?.author && !searchParams?.mention &&
|
if (!searchQuery && !searchParams?.author && !searchParams?.mention &&
|
||||||
!searchParams?.date_start && !searchParams?.date_end &&
|
!searchParams?.date_start && !searchParams?.date_end &&
|
||||||
!searchParams?.has && !searchParams?.server_wide) return;
|
!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 = {
|
const searchOptions: any = {
|
||||||
query: searchQuery,
|
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
|
// Add user filters if provided
|
||||||
if (searchParams?.author) {
|
if (searchParams?.author) {
|
||||||
searchOptions.author = searchParams.author;
|
searchOptions.author = searchParams.author;
|
||||||
@@ -168,17 +200,72 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await channel.searchWithUsers(searchOptions);
|
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(() => {
|
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
|
// Save search params when they change
|
||||||
if (searchParams) {
|
if (searchParams) {
|
||||||
setSavedSearchParams(searchParams);
|
setSavedSearchParams(searchParams);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line
|
// 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 (
|
return (
|
||||||
<SearchSidebarBase>
|
<SearchSidebarBase>
|
||||||
@@ -201,38 +288,31 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{state.type === "loading" && <Preloader type="ring" />}
|
{state.type === "loading" && <Preloader type="ring" />}
|
||||||
{state.type === "results" && (
|
{state.type === "results" && (() => {
|
||||||
<>
|
const currentPageMessages = state.pages.get(state.currentPage) || [];
|
||||||
<Overline type="subtle" block style={{ textAlign: 'center', marginTop: '12px' }}>
|
return (
|
||||||
{state.results.length > 0
|
<>
|
||||||
? `${state.results.length} Result${state.results.length === 1 ? '' : 's'}`
|
<Overline type="subtle" block style={{ textAlign: 'center', marginTop: '12px' }}>
|
||||||
: 'No Results'
|
{currentPageMessages.length > 0
|
||||||
}
|
? currentPageMessages.length === 1 ? 'Result' : 'Results'
|
||||||
</Overline>
|
: 'No Results'
|
||||||
<div className="list">
|
}
|
||||||
{(() => {
|
</Overline>
|
||||||
// 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";
|
|
||||||
|
|
||||||
return (
|
{state.isLoadingPage ? (
|
||||||
<div key={channelId}>
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
<Overline type="subtle" block>
|
<Preloader type="ring" />
|
||||||
# {channelName}
|
</div>
|
||||||
</Overline>
|
) : (
|
||||||
{messages.map((message) => {
|
<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 = "";
|
let href = "";
|
||||||
if (messageChannel?.channel_type === "TextChannel") {
|
if (messageChannel?.channel_type === "TextChannel") {
|
||||||
href += `/server/${messageChannel.server_id}`;
|
href += `/server/${messageChannel.server_id}`;
|
||||||
@@ -240,35 +320,76 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
|
|||||||
href += `/channel/${message.channel_id}/${message._id}`;
|
href += `/channel/${message.channel_id}/${message._id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={message._id}>
|
||||||
key={message._id}
|
{showChannelIndicator && (
|
||||||
className="message"
|
<Overline type="subtle" block>
|
||||||
style={{ cursor: 'pointer' }}
|
# {channelName}
|
||||||
onClick={(e) => {
|
</Overline>
|
||||||
e.preventDefault();
|
)}
|
||||||
// Navigate to the message
|
<div
|
||||||
history.push(href);
|
className="message"
|
||||||
// Re-emit the search sidebar with the same params to keep it open
|
style={{ cursor: 'pointer' }}
|
||||||
setTimeout(() => {
|
onClick={(e) => {
|
||||||
internalEmit("RightSidebar", "open", "search", savedSearchParams || searchParams);
|
e.preventDefault();
|
||||||
}, 100);
|
// Navigate to the message
|
||||||
}}
|
history.push(href);
|
||||||
>
|
// Re-emit the search sidebar with the same params to keep it open
|
||||||
<Message
|
setTimeout(() => {
|
||||||
message={message}
|
internalEmit("RightSidebar", "open", "search", savedSearchParams || searchParams);
|
||||||
head
|
}, 100);
|
||||||
hideReply
|
}}
|
||||||
/>
|
>
|
||||||
|
<Message
|
||||||
|
message={message}
|
||||||
|
head
|
||||||
|
hideReply
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
)}
|
||||||
});
|
|
||||||
})()}
|
{/* Navigation with page count at the bottom - only show if there are results */}
|
||||||
</div>
|
{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>
|
</SearchBase>
|
||||||
</GenericSidebarList>
|
</GenericSidebarList>
|
||||||
</SearchSidebarBase>
|
</SearchSidebarBase>
|
||||||
|
|||||||
Reference in New Issue
Block a user