diff --git a/external/revolt.js b/external/revolt.js index 62d4a668..31c167ed 160000 --- a/external/revolt.js +++ b/external/revolt.js @@ -1 +1 @@ -Subproject commit 62d4a668b2115227b7d13e5551923b676d1d8adf +Subproject commit 31c167ed78cc9187ecb324eba90f059fafcc28a9 diff --git a/src/components/navigation/RightSidebar.tsx b/src/components/navigation/RightSidebar.tsx index c43a64d5..c772335b 100644 --- a/src/components/navigation/RightSidebar.tsx +++ b/src/components/navigation/RightSidebar.tsx @@ -10,21 +10,48 @@ import { SearchSidebar } from "./right/Search"; export default function RightSidebar() { const [sidebar, setSidebar] = useState<"search" | undefined>(); - const close = () => setSidebar(undefined); + const [searchQuery, setSearchQuery] = useState(""); + const [searchParams, setSearchParams] = useState(null); + const close = () => { + setSidebar(undefined); + setSearchQuery(""); + setSearchParams(null); + }; useEffect( () => - internalSubscribe( - "RightSidebar", - "open", - setSidebar as (...args: unknown[]) => void, - ), - [setSidebar], + internalSubscribe("RightSidebar", "open", (type: string, data?: any) => { + setSidebar(type as "search" | undefined); + if (type === "search") { + if (typeof data === "string") { + // Legacy support for string queries + setSearchQuery(data); + setSearchParams(null); + } else if (data?.query !== undefined) { + // New format with search parameters + setSearchQuery(data.query); + setSearchParams(data); + } + } + }), + [], + ); + + useEffect( + () => + internalSubscribe("RightSidebar", "close", () => { + close(); + }), + [], ); const content = sidebar === "search" ? ( - + ) : ( ); diff --git a/src/components/navigation/SearchAutoComplete.tsx b/src/components/navigation/SearchAutoComplete.tsx new file mode 100644 index 00000000..bf8195b9 --- /dev/null +++ b/src/components/navigation/SearchAutoComplete.tsx @@ -0,0 +1,123 @@ +import styled from "styled-components/macro"; +import { User } from "revolt.js"; +import { AutoCompleteState } from "../common/AutoComplete"; +import UserIcon from "../common/user/UserIcon"; + +const Base = styled.div` + position: absolute; + top: 100%; + left: 0; + right: 0; + margin-top: 4px; + background: var(--primary-background); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1001; + overflow: hidden; + max-height: 200px; + overflow-y: auto; + + @media (max-width: 768px) { + margin-top: 8px; + max-height: 250px; + border-radius: 10px; + } + + button { + width: 100%; + gap: 8px; + padding: 8px 12px; + border: none; + display: flex; + font-size: 14px; + cursor: pointer; + align-items: center; + flex-direction: row; + font-family: inherit; + background: transparent; + color: var(--foreground); + text-align: left; + transition: background 0.15s ease; + + &:hover, + &.active { + background: var(--secondary-background); + } + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + @media (max-width: 768px) { + padding: 14px 16px; + font-size: 15px; + gap: 12px; + + /* Add touch feedback for mobile */ + &:active { + background: var(--secondary-background); + transform: scale(0.98); + } + } + } +`; + +interface Props { + state: AutoCompleteState; + setState: (state: AutoCompleteState) => void; + onClick: (userId: string, username: string) => void; +} + +export default function SearchAutoComplete({ state, setState, onClick }: Props) { + if (state.type !== "user") return null; + + // Detect if we're on mobile + const isMobile = window.innerWidth <= 768; + const iconSize = isMobile ? 24 : 20; + + return ( + + {state.matches.length > 0 ? ( + state.matches.map((user, i) => ( + + )) + ) : ( +
+ No users found +
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/navigation/SearchBar.tsx b/src/components/navigation/SearchBar.tsx new file mode 100644 index 00000000..df32643d --- /dev/null +++ b/src/components/navigation/SearchBar.tsx @@ -0,0 +1,1099 @@ +import { Search, X } from "@styled-icons/boxicons-regular"; +import { HelpCircle } from "@styled-icons/boxicons-solid"; +import styled from "styled-components/macro"; +import { useEffect, useRef, useState } from "preact/hooks"; +import { internalEmit } from "../../lib/eventEmitter"; +import { useSearchAutoComplete, transformSearchQuery, UserMapping } from "../../lib/hooks/useSearchAutoComplete"; +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"; +import Tooltip from "../common/Tooltip"; + +const Container = styled.div` + position: relative; + display: flex; + align-items: center; + background: var(--primary-header); + border-radius: var(--border-radius); + width: 220px; + height: 32px; + + @media (max-width: 768px) { + width: 180px; + } +`; + +const Input = styled.input` + flex: 1; + border: none; + background: transparent; + color: var(--foreground); + font-size: 14px; + padding: 6px 2px 6px 8px; /* Reduced left padding for more space */ + outline: none; + min-width: 0; /* Allow input to shrink properly */ + + &::placeholder { + color: var(--tertiary-foreground); + font-size: 13px; /* Slightly smaller placeholder on mobile */ + } + + @media (max-width: 768px) { + font-size: 13px; + padding: 6px 2px 6px 6px; + } +`; + +const IconButton = styled.div` + display: flex; + align-items: center; + padding: 0 8px; /* Symmetrical padding */ + color: var(--tertiary-foreground); + cursor: pointer; + transition: color 0.1s ease; + flex-shrink: 0; /* Prevent icon from shrinking */ + + &:hover { + color: var(--foreground); + } + + @media (max-width: 768px) { + padding: 0 6px; /* Less padding on mobile */ + + svg { + width: 16px; + height: 16px; + } + } +`; + +const OptionsDropdown = styled.div` + position: absolute; + top: 100%; + right: 0; + margin-top: 8px; + background: var(--primary-background); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1000; + overflow: hidden; + padding: 8px; + min-width: 300px; + + @media (max-width: 768px) { + padding: 12px; + min-width: 320px; + max-width: calc(100vw - 20px); + right: -10px; /* Adjust position to prevent edge cutoff */ + max-height: 40vh; /* Limit height when keyboard is up */ + overflow-y: auto; /* Make it scrollable */ + + /* Add scrollbar styles for mobile */ + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: 2px; + } + } +`; + +const OptionsHeader = styled.div` + padding: 0 8px 8px 8px; + font-size: 12px; + color: var(--tertiary-foreground); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + + @media (max-width: 768px) { + font-size: 11px; + padding: 0 8px 8px 8px; + } +`; + +const Option = styled.div` + display: flex; + align-items: center; + padding: 8px; + border-radius: 4px; + cursor: pointer; + transition: background 0.15s ease; + margin-bottom: 2px; + + &:hover { + background: var(--secondary-background); + } + + &:last-child { + margin-bottom: 0; + } + + @media (max-width: 768px) { + padding: 10px 10px; + margin-bottom: 3px; + border-radius: 6px; + + /* Add touch feedback for mobile */ + &:active { + background: var(--secondary-background); + transform: scale(0.98); + } + } +`; + +const OptionLabel = styled.span` + color: var(--foreground); + font-weight: 500; + margin-right: 8px; + font-family: var(--monospace-font), monospace; + font-size: 13px; + white-space: nowrap; + + @media (max-width: 768px) { + font-size: 13px; + margin-right: 10px; + } +`; + +const OptionDesc = styled.span` + color: var(--tertiary-foreground); + font-size: 13px; + flex: 1; + + @media (max-width: 768px) { + font-size: 12px; + } +`; + +const HelpIcon = styled(HelpCircle)` + color: var(--tertiary-foreground); + margin-left: auto; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.15s ease; + + ${Option}:hover & { + opacity: 0.7; + } +`; + +interface SearchOption { + label: string; + description: string; + tooltip: string; +} + +const searchOptions: SearchOption[] = [ + { + label: "from:", + description: "user", + tooltip: "Filter messages by author" + }, + { + label: "mentions:", + 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", + tooltip: "Messages before this date" + }, + { + label: "during:", + description: "specific date", + tooltip: "Messages on this date" + }, + { + label: "after:", + description: "specific date", + tooltip: "Messages after this date" + }, + { + label: "between:", + description: "date range", + tooltip: "Messages between two dates" + }, + { + label: "server-wide", + description: "Entire server", + tooltip: "Search in entire server instead of just this channel" + } +]; + +export function SearchBar() { + const layout = useApplicationState().layout; + const [query, setQuery] = useState(""); + const [showOptions, setShowOptions] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [userMappings, setUserMappings] = useState({}); + const [showDatePicker, setShowDatePicker] = useState<"before" | "after" | "during" | "between" | null>(null); + const [showAttachmentTypes, setShowAttachmentTypes] = useState(false); + const [activeDateRange, setActiveDateRange] = useState<{ + start: string; + end: string; + 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: originalOnAutocompleteKeyDown, + onChange: onAutocompleteChange, + 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(); + // 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 = () => { + // 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); + } + }; + + const handleBlur = () => { + onAutocompleteBlur(); + // Delay to allow clicking on options + setTimeout(() => { + // 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*$/); + + // Don't close options/autocomplete if we have a user filter + if (!hasUserFilter && !hasIncompleteDateFilter && !showDatePicker) { + setShowOptions(false); + if (autocompleteState.type === "none") { + setAutocompleteState({ type: "none" }); + } + } + }, 200); + }; + + const handleInput = (e: Event) => { + const value = (e.target as HTMLInputElement).value; + const cursorPos = (e.target as HTMLInputElement).selectionStart || 0; + + // Check if user is trying to add space after incomplete filter + const incompleteFilterWithSpace = value.match(/\b(from:|mentions:)\s+$/); + if (incompleteFilterWithSpace && autocompleteState.type === "user") { + // Don't allow space after filter unless user was selected + return; + } + + setQuery(value); + + // Clear user mappings and date range when query becomes empty + if (value.trim() === "") { + setUserMappings({}); + setActiveDateRange(null); + // Reset search state and close sidebar when input is completely cleared + if (isSearching) { + setIsSearching(false); + internalEmit("RightSidebar", "close"); + } + } + + // 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*$/); + const betweenMatch = beforeCursor.match(/\bbetween:\s*$/); + const hasMatch = beforeCursor.match(/\bhas:\s*$/); + + if (beforeMatch) { + setShowDatePicker("before"); + setShowOptions(false); + setShowAttachmentTypes(false); + setAutocompleteState({ type: "none" }); + } else if (afterMatch) { + setShowDatePicker("after"); + setShowOptions(false); + setShowAttachmentTypes(false); + setAutocompleteState({ type: "none" }); + } else if (duringMatch) { + setShowDatePicker("during"); + setShowOptions(false); + setShowAttachmentTypes(false); + setAutocompleteState({ type: "none" }); + } else if (betweenMatch) { + setShowDatePicker("between"); + setShowOptions(false); + setShowAttachmentTypes(false); + setAutocompleteState({ type: "none" }); + } else if (hasMatch) { + // Show attachment type options + setShowAttachmentTypes(true); + setShowOptions(false); + setShowDatePicker(null); + setAutocompleteState({ type: "none" }); + } else { + // Check if "has:" was removed and close attachment types dropdown + if (showAttachmentTypes && !value.includes("has:")) { + setShowAttachmentTypes(false); + } + + // 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); + if (showDatePicker) { + setShowDatePicker(null); + } + } + } + }; + + const handleSearch = () => { + let trimmedQuery = query.trim(); + + // Check for incomplete filters + const hasIncompleteUserFilter = trimmedQuery.match(/\b(from:|mentions:)\s*$/); + const hasIncompleteDateFilter = trimmedQuery.match(/\b(before:|after:|during:|between:)\s*$/); + const hasIncompleteHasFilter = trimmedQuery.match(/\bhas:\s*$/); + + if (hasIncompleteUserFilter || hasIncompleteDateFilter || hasIncompleteHasFilter) { + // Don't search if there's an incomplete filter + 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 (dateRangeCount === 1 && activeDateRange) { + // Replace "date-range" with the actual between filter for processing + trimmedQuery = trimmedQuery.replace(/\bdate-range\b/, `between:${activeDateRange.start}..${activeDateRange.end}`); + } + + // 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 || + searchParams.mention || + searchParams.date_start || + searchParams.date_end || + searchParams.has || + searchParams.server_wide; + + const hasSearchCriteria = (searchParams.query && searchParams.query.trim() !== "") || hasFilters; + + if (hasSearchCriteria) { + // 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" }); + inputRef.current?.blur(); + + // On mobile, automatically slide to show search results + if (isTouchscreenDevice) { + setTimeout(() => { + const panels = document.querySelector("#app > div > div > div"); + panels?.scrollTo({ + behavior: "smooth", + left: panels.clientWidth * 3, + }); + }, 100); // Small delay to ensure sidebar is opened first + } + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const currentValue = (e.currentTarget as HTMLInputElement).value; + const cursorPos = (e.currentTarget as HTMLInputElement).selectionStart || 0; + + // Handle backspace/delete for date filters and server-wide + if (e.key === "Backspace" || e.key === "Delete") { + const beforeCursor = currentValue.slice(0, cursorPos); + const afterCursor = currentValue.slice(cursorPos); + + // Check for date filters with backspace + if (e.key === "Backspace") { + // Handle single date filters (before:, after:, during:) + const singleDateMatch = beforeCursor.match(/\b(before|after|during):(\d{4}-\d{2}-\d{2})\s*$/); + if (singleDateMatch) { + e.preventDefault(); + const filterStart = singleDateMatch.index!; + + // Remove the entire filter and date + const newValue = currentValue.slice(0, filterStart) + afterCursor; + setQuery(newValue); + + // Position cursor where filter was + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(filterStart, filterStart); + } + }, 0); + return; + } + + // Handle filter-only patterns (e.g., "before:" without date) + const filterOnlyMatch = beforeCursor.match(/\b(before|after|during|between):\s*$/); + if (filterOnlyMatch) { + e.preventDefault(); + const filterStart = filterOnlyMatch.index!; + + // Remove the entire filter + const newValue = currentValue.slice(0, filterStart) + afterCursor; + setQuery(newValue); + + // Close date picker if open + setShowDatePicker(null); + + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(filterStart, filterStart); + } + }, 0); + return; + } + + // Handle between date range filter + const betweenMatch = beforeCursor.match(/\bbetween:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})\s*$/); + if (betweenMatch) { + e.preventDefault(); + const filterStart = betweenMatch.index!; + const startDate = betweenMatch[1]; + + // Remove end date but keep "between:YYYY-MM-DD.." + const newValue = currentValue.slice(0, filterStart) + "between:" + startDate + ".." + afterCursor; + setQuery(newValue); + + const newCursorPos = filterStart + "between:".length + startDate.length + 2; + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + return; + } + + // Handle partial between filter (between:YYYY-MM-DD..) + const partialBetweenMatch = beforeCursor.match(/\bbetween:(\d{4}-\d{2}-\d{2})\.\.\s*$/); + if (partialBetweenMatch) { + e.preventDefault(); + const filterStart = partialBetweenMatch.index!; + const startDate = partialBetweenMatch[1]; + + // Remove ".." to get "between:YYYY-MM-DD" + const newValue = currentValue.slice(0, filterStart) + "between:" + startDate + afterCursor; + setQuery(newValue); + + const newCursorPos = filterStart + "between:".length + startDate.length; + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(newCursorPos, newCursorPos); + } + }, 0); + return; + } + + // Handle between with only start date (between:YYYY-MM-DD) + const betweenStartOnlyMatch = beforeCursor.match(/\bbetween:(\d{4}-\d{2}-\d{2})\s*$/); + if (betweenStartOnlyMatch) { + e.preventDefault(); + const filterStart = betweenStartOnlyMatch.index!; + + // Remove date but keep "between:" + const newValue = currentValue.slice(0, filterStart) + "between:" + afterCursor; + setQuery(newValue); + + const newCursorPos = filterStart + "between:".length; + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(newCursorPos, newCursorPos); + // Open the date picker in between mode + setShowDatePicker("between"); + setShowOptions(false); + } + }, 0); + return; + } + + // Handle date-range keyword removal + const dateRangeMatch = beforeCursor.match(/\bdate-range\s*$/); + if (dateRangeMatch) { + e.preventDefault(); + const filterStart = dateRangeMatch.index!; + + // Remove the keyword and clear the stored date range + const newValue = currentValue.slice(0, filterStart) + afterCursor; + setQuery(newValue); + setActiveDateRange(null); + + // Position cursor where filter was + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(filterStart, filterStart); + } + }, 0); + return; + } + + // Handle has: filter removal + const hasMatch = beforeCursor.match(/\bhas:(video|image|link|audio|file)\s*$/); + if (hasMatch) { + e.preventDefault(); + const filterStart = hasMatch.index!; + + // Remove the entire filter + const newValue = currentValue.slice(0, filterStart) + afterCursor; + setQuery(newValue); + + // Position cursor where filter was + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(filterStart, filterStart); + } + }, 0); + return; + } + + // Handle has: without value + const hasOnlyMatch = beforeCursor.match(/\bhas:\s*$/); + if (hasOnlyMatch) { + e.preventDefault(); + const filterStart = hasOnlyMatch.index!; + + // Remove the entire filter + const newValue = currentValue.slice(0, filterStart) + afterCursor; + setQuery(newValue); + + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(filterStart, filterStart); + } + }, 0); + return; + } + + // Original server-wide handling + const serverWideMatch = beforeCursor.match(/\bserver-wide\s*$/); + if (serverWideMatch) { + e.preventDefault(); + const newValue = currentValue.slice(0, serverWideMatch.index) + afterCursor; + setQuery(newValue); + // Set cursor position to where server-wide started + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(serverWideMatch.index, serverWideMatch.index); + } + }, 0); + return; + } + } else if (e.key === "Delete") { + // Handle date filters with delete key + // Check if we're at a filter: position + const filterMatch = afterCursor.match(/^(before|after|during|between):(\d{4}-\d{2}-\d{2})?/); + if (filterMatch && beforeCursor.match(/\s$|^$/)) { + e.preventDefault(); + const filterType = filterMatch[1]; + const hasDate = filterMatch[2]; + + if (hasDate) { + // Remove entire filter and date + const newValue = beforeCursor + afterCursor.slice(filterMatch[0].length).trimStart(); + setQuery(newValue); + } else { + // Just filter: without date, remove it + const newValue = beforeCursor + afterCursor.slice(filterType.length + 1).trimStart(); + setQuery(newValue); + } + + setTimeout(() => { + if (inputRef.current) { + inputRef.current.setSelectionRange(beforeCursor.length, beforeCursor.length); + } + }, 0); + return; + } + + // Handle has: filter with delete key + const hasAfter = afterCursor.match(/^has:(video|image|link|audio|file)?\s*/); + if (hasAfter) { + e.preventDefault(); + const newValue = beforeCursor + afterCursor.slice(hasAfter[0].length); + setQuery(newValue); + return; + } + + // Original server-wide handling + const serverWideAfter = afterCursor.match(/^server-wide\s*/); + if (serverWideAfter) { + e.preventDefault(); + const newValue = beforeCursor + afterCursor.slice(serverWideAfter[0].length); + setQuery(newValue); + return; + } + } + } + + // Check if user is trying to type space when there's an incomplete filter + if (e.key === " " || e.key === "Spacebar") { + const beforeCursor = currentValue.slice(0, cursorPos); + const afterCursor = currentValue.slice(cursorPos); + + // Check if cursor is within or right after a filter + const lastFromIndex = beforeCursor.lastIndexOf("from:"); + const lastMentionsIndex = beforeCursor.lastIndexOf("mentions:"); + + if (lastFromIndex !== -1 || lastMentionsIndex !== -1) { + const filterIndex = Math.max(lastFromIndex, lastMentionsIndex); + const afterFilter = beforeCursor.slice(filterIndex); + + // Check if we're still within the filter (no space after it in the part before cursor) + if (!afterFilter.includes(" ")) { + // Also check if there's no space immediately after cursor (editing in middle of username) + const hasSpaceAfterCursor = afterCursor.startsWith(" "); + + if (!hasSpaceAfterCursor) { + // We're within a filter and trying to add space - always prevent + e.preventDefault(); + return; + } + } + } + } + + // Let autocomplete handle key events first + if (onAutocompleteKeyDown(e)) { + return; + } + + if (e.key === "Enter") { + // Don't search if autocomplete is showing + if (autocompleteState.type === "none") { + handleSearch(); + } + } else if (e.key === "Escape") { + if (query) { + setQuery(""); + setUserMappings({}); + setActiveDateRange(null); + // Reset search state and close sidebar when query is cleared + if (isSearching) { + setIsSearching(false); + internalEmit("RightSidebar", "close"); + } + } else { + inputRef.current?.blur(); + } + if (isSearching && !query) { + internalEmit("RightSidebar", "close"); + setIsSearching(false); + } + } + }; + + const handleClear = () => { + setQuery(""); + setIsSearching(false); + setUserMappings({}); + setActiveDateRange(null); + inputRef.current?.focus(); + internalEmit("RightSidebar", "close"); + }; + + const handleOptionClick = (option: SearchOption) => { + // If it's a date filter, just show the date picker without adding text + 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(() => { + if (inputRef.current) { + const endPos = newQuery.length; + inputRef.current.setSelectionRange(endPos, endPos); + inputRef.current.focus(); + } + }, 0); + } else if (option.label === "server-wide") { + // For server-wide, add it as a standalone filter with auto-space + 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(() => { + if (inputRef.current) { + const endPos = newQuery.length; + inputRef.current.setSelectionRange(endPos, endPos); + 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(); + } + }; + + const handleAttachmentTypeClick = (type: string) => { + // Add the attachment type to the query + const newQuery = query + type + " "; + setQuery(newQuery); + setShowAttachmentTypes(false); + + // Move cursor to end and focus + setTimeout(() => { + if (inputRef.current) { + const endPos = newQuery.length; + inputRef.current.setSelectionRange(endPos, endPos); + inputRef.current.focus(); + } + }, 0); + }; + + // Format date to YYYY-MM-DD in local timezone + const formatLocalDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + const handleDateSelect = (date: Date) => { + const dateStr = formatLocalDate(date); // Use local date formatting + + // Add the filter and date to the query with auto-space + const filterText = `${showDatePicker}:${dateStr} `; + const newQuery = query + (query ? " " : "") + filterText; + + setQuery(newQuery); + setShowDatePicker(null); + + // Move cursor to end after the space + setTimeout(() => { + if (inputRef.current) { + const endPos = newQuery.length; + inputRef.current.setSelectionRange(endPos, endPos); + inputRef.current.focus(); + } + }, 0); + }; + + const handleRangeSelect = (startDate: Date, endDate: Date) => { + const startStr = formatLocalDate(startDate); + const endStr = formatLocalDate(endDate); + + // Store the actual date range in state + setActiveDateRange({ + start: startStr, + end: endStr, + startDisplay: startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + endDisplay: endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) + }); + + // Only add "date-range" to the query, not the actual dates + const filterText = `date-range `; + const newQuery = query + (query ? " " : "") + filterText; + + setQuery(newQuery); + setShowDatePicker(null); + + // Move cursor to end after the space + setTimeout(() => { + if (inputRef.current) { + const endPos = newQuery.length; + inputRef.current.setSelectionRange(endPos, endPos); + inputRef.current.focus(); + } + }, 0); + }; + + + // Global keyboard shortcut + useEffect(() => { + const handleGlobalKeydown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === "k") { + e.preventDefault(); + inputRef.current?.focus(); + } + }; + + window.addEventListener("keydown", handleGlobalKeydown); + return () => window.removeEventListener("keydown", handleGlobalKeydown); + }, []); + + // Close date picker or attachment types when clicking outside + useEffect(() => { + if (showDatePicker || showAttachmentTypes) { + const handleClickOutside = (e: MouseEvent) => { + // Check if click is outside the container + const container = e.target as HTMLElement; + if (!container.closest('[data-search-container]') && !container.closest('[data-date-picker]')) { + setShowDatePicker(null); + setShowAttachmentTypes(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [showDatePicker, showAttachmentTypes]); + + return ( + e.stopPropagation()}> + + {isSearching ? ( + + + + ) : ( + + + + )} + {autocompleteState.type !== "none" && ( + + )} + {showOptions && autocompleteState.type === "none" && !showDatePicker && !showAttachmentTypes && ( + e.stopPropagation()}> + {"Search Options"} + {searchOptions.map((option) => ( + + ))} + + )} + {showDatePicker && ( + + )} + {showAttachmentTypes && ( + e.stopPropagation()}> + Attachment Types + + + + + + + )} + + ); +} \ No newline at end of file diff --git a/src/components/navigation/SearchDatePicker.tsx b/src/components/navigation/SearchDatePicker.tsx new file mode 100644 index 00000000..47f68dac --- /dev/null +++ b/src/components/navigation/SearchDatePicker.tsx @@ -0,0 +1,377 @@ +import styled from "styled-components/macro"; +import { useState, useEffect, useRef } from "preact/hooks"; +import { ChevronLeft, ChevronRight } from "@styled-icons/boxicons-regular"; + +const Base = styled.div` + position: absolute; + top: 100%; + right: 0; + margin-top: 4px; + background: var(--primary-background); + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + z-index: 1002; + overflow: hidden; + padding: 12px; + min-width: 280px; + + @media (max-width: 768px) { + min-width: 240px; + right: -20px; + } +`; + +const Header = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + padding: 0 4px; +`; + +const MonthYear = styled.div` + font-weight: 600; + color: var(--foreground); + font-size: 14px; +`; + +const NavButton = styled.button` + background: transparent; + border: none; + color: var(--tertiary-foreground); + cursor: pointer; + padding: 4px; + border-radius: 4px; + display: flex; + align-items: center; + transition: all 0.1s ease; + + &:hover { + background: var(--secondary-background); + color: var(--foreground); + } +`; + +const WeekDays = styled.div` + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + margin-bottom: 8px; +`; + +const WeekDay = styled.div` + text-align: center; + font-size: 11px; + font-weight: 600; + color: var(--tertiary-foreground); + text-transform: uppercase; + padding: 4px; +`; + +const Days = styled.div` + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; +`; + +const Day = styled.button<{ + isToday?: boolean; + isSelected?: boolean; + isOtherMonth?: boolean; + isRangeStart?: boolean; + isRangeEnd?: boolean; + isInRange?: boolean; + isInHoverRange?: boolean; +}>` + background: ${props => { + if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent)'; + if (props.isInRange) return 'var(--accent-disabled)'; + if (props.isInHoverRange) return 'var(--hover)'; + return 'transparent'; + }}; + color: ${props => { + if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent-contrast)'; + if (props.isOtherMonth) return 'var(--tertiary-foreground)'; + return 'var(--foreground)'; + }}; + border: none; + padding: 8px; + border-radius: ${props => { + if (props.isRangeStart || (props.isInHoverRange && props.isOtherMonth === false && !props.isRangeEnd)) return '4px 0 0 4px'; + if (props.isRangeEnd || (props.isInHoverRange && props.isOtherMonth === false && !props.isRangeStart)) return '0 4px 4px 0'; + if (props.isInRange || props.isInHoverRange) return '0'; + return '4px'; + }}; + cursor: pointer; + font-size: 13px; + transition: all 0.1s ease; + position: relative; + + ${props => props.isToday && ` + &::after { + content: ''; + position: absolute; + bottom: 2px; + left: 50%; + transform: translateX(-50%); + width: 4px; + height: 4px; + border-radius: 50%; + background: ${props.isSelected || props.isRangeStart || props.isRangeEnd ? 'var(--accent-contrast)' : 'var(--accent)'}; + } + `} + + &:hover { + background: ${props => { + if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent)'; + return 'var(--secondary-background)'; + }}; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +`; + +const QuickSelects = styled.div` + display: flex; + gap: 4px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--secondary-background); +`; + +const RangePreview = styled.div` + margin: 12px 0; + padding: 8px 12px; + background: var(--secondary-background); + border-radius: 4px; + font-size: 13px; + color: var(--foreground); + text-align: center; + + .hint { + color: var(--tertiary-foreground); + font-size: 12px; + margin-top: 4px; + } +`; + +const QuickSelect = styled.button` + background: transparent; + border: 1px solid var(--secondary-background); + color: var(--foreground); + padding: 4px 8px; + border-radius: 4px; + cursor: pointer; + font-size: 12px; + transition: all 0.1s ease; + + &:hover { + background: var(--secondary-background); + border-color: var(--tertiary-background); + } +`; + +interface Props { + onSelect: (date: Date) => void; + onRangeSelect?: (startDate: Date, endDate: Date) => void; + filterType: "before" | "after" | "during" | "between"; +} + +export default function SearchDatePicker({ onSelect, onRangeSelect, filterType }: Props) { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [selectedDate, setSelectedDate] = useState(null); + const [rangeStart, setRangeStart] = useState(null); + const [rangeEnd, setRangeEnd] = useState(null); + const [hoverDate, setHoverDate] = useState(null); + + const monthNames = ["January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December"]; + const weekDays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + const getDaysInMonth = (date: Date) => { + const year = date.getFullYear(); + const month = date.getMonth(); + const firstDay = new Date(year, month, 1); + const lastDay = new Date(year, month + 1, 0); + const daysInMonth = lastDay.getDate(); + const startingDayOfWeek = firstDay.getDay(); + + const days: Array<{ date: Date; isOtherMonth: boolean }> = []; + + // Previous month days + const prevMonthLastDay = new Date(year, month, 0).getDate(); + for (let i = startingDayOfWeek - 1; i >= 0; i--) { + days.push({ + date: new Date(year, month - 1, prevMonthLastDay - i), + isOtherMonth: true + }); + } + + // Current month days + for (let i = 1; i <= daysInMonth; i++) { + days.push({ + date: new Date(year, month, i), + isOtherMonth: false + }); + } + + // Next month days to fill the grid + const remainingDays = 42 - days.length; // 6 weeks * 7 days + for (let i = 1; i <= remainingDays; i++) { + days.push({ + date: new Date(year, month + 1, i), + isOtherMonth: true + }); + } + + return days; + }; + + const isToday = (date: Date) => { + const today = new Date(); + return date.toDateString() === today.toDateString(); + }; + + const isSameDate = (date1: Date, date2: Date | null) => { + if (!date2) return false; + return date1.toDateString() === date2.toDateString(); + }; + + const isDateInRange = (date: Date) => { + if (!rangeStart || !rangeEnd) return false; + return date >= rangeStart && date <= rangeEnd; + }; + + const isDateInHoverRange = (date: Date) => { + if (filterType !== "between" || !rangeStart || rangeEnd || !hoverDate) return false; + const start = rangeStart < hoverDate ? rangeStart : hoverDate; + const end = rangeStart < hoverDate ? hoverDate : rangeStart; + return date >= start && date <= end; + }; + + const handleDateSelect = (date: Date) => { + if (filterType === "between") { + if (!rangeStart || (rangeStart && rangeEnd)) { + // First click or reset + setRangeStart(date); + setRangeEnd(null); + } else { + // Second click + if (date < rangeStart) { + setRangeEnd(rangeStart); + setRangeStart(date); + } else { + setRangeEnd(date); + } + // Call the callback with both dates + if (onRangeSelect) { + const start = date < rangeStart ? date : rangeStart; + const end = date < rangeStart ? rangeStart : date; + onRangeSelect(start, end); + } + } + } else { + setSelectedDate(date); + onSelect(date); + } + }; + + const handleQuickSelect = (option: string) => { + const today = new Date(); + let date: Date; + + switch (option) { + case "today": + date = today; + break; + case "yesterday": + date = new Date(today); + date.setDate(today.getDate() - 1); + break; + case "week": + date = new Date(today); + date.setDate(today.getDate() - 7); + break; + case "month": + date = new Date(today); + date.setMonth(today.getMonth() - 1); + break; + default: + return; + } + + handleDateSelect(date); + }; + + const navigateMonth = (direction: number) => { + const newMonth = new Date(currentMonth); + newMonth.setMonth(currentMonth.getMonth() + direction); + setCurrentMonth(newMonth); + }; + + const days = getDaysInMonth(currentMonth); + + return ( + e.stopPropagation()}> +
+ navigateMonth(-1)}> + + + + {filterType === "between" && rangeStart && !rangeEnd + ? "Select end date" + : `${monthNames[currentMonth.getMonth()]} ${currentMonth.getFullYear()}` + } + + navigateMonth(1)}> + + +
+ + + {weekDays.map(day => ( + {day} + ))} + + + + {days.map((day, index) => ( + handleDateSelect(day.date)} + onMouseEnter={() => setHoverDate(day.date)} + onMouseLeave={() => setHoverDate(null)} + isToday={isToday(day.date)} + isSelected={filterType !== "between" && isSameDate(day.date, selectedDate)} + isRangeStart={filterType === "between" && rangeStart && isSameDate(day.date, rangeStart)} + isRangeEnd={filterType === "between" && rangeEnd && isSameDate(day.date, rangeEnd)} + isInRange={filterType === "between" && isDateInRange(day.date)} + isInHoverRange={isDateInHoverRange(day.date)} + isOtherMonth={day.isOtherMonth} + > + {day.date.getDate()} + + ))} + + + + + handleQuickSelect("today")}> + Today + + handleQuickSelect("yesterday")}> + Yesterday + + handleQuickSelect("week")}> + Last week + + handleQuickSelect("month")}> + Last month + + + + ); +} \ No newline at end of file diff --git a/src/components/navigation/right/Search.tsx b/src/components/navigation/right/Search.tsx index f12cb7d2..b7c66276 100644 --- a/src/components/navigation/right/Search.tsx +++ b/src/components/navigation/right/Search.tsx @@ -1,13 +1,14 @@ -import { Link, useParams } from "react-router-dom"; +import { Link, useParams, useHistory } from "react-router-dom"; import { Message as MessageI } from "revolt.js"; import styled from "styled-components/macro"; import { Text } from "preact-i18n"; import { useEffect, useState } from "preact/hooks"; -import { Button, Category, Error, InputBox, Preloader } from "@revoltchat/ui"; +import { Button, Preloader } from "@revoltchat/ui"; import { useClient } from "../../../controllers/client/ClientController"; +import { internalEmit } from "../../../lib/eventEmitter"; import Message from "../../common/messaging/Message"; import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; @@ -20,11 +21,28 @@ type SearchState = } | { type: "results"; - results: MessageI[]; + pages: Map; + currentPage: number; + hasMore: boolean; + isLoadingPage?: boolean; }; +// Custom wider sidebar for search results +const SearchSidebarBase = styled(GenericSidebarBase)` + width: 360px; /* Increased from 232px */ + + @media (max-width: 1200px) { + width: 320px; + } + + @media (max-width: 900px) { + width: 280px; + } +`; + const SearchBase = styled.div` padding: 6px; + padding-top: 48px; /* Add space for the header */ input { width: 100%; @@ -38,12 +56,10 @@ const SearchBase = styled.div` } .message { - margin: 2px; - padding: 6px; + margin: 4px 2px 8px 2px; + padding: 8px; overflow: hidden; border-radius: var(--border-radius); - - color: var(--foreground); background: var(--primary-background); &:hover { @@ -53,6 +69,14 @@ const SearchBase = styled.div` > * { pointer-events: none; } + + /* Override message text color but preserve mentions and other highlights */ + p { + color: var(--foreground) !important; + } + + /* Also override any direct text that might be themed */ + color: var(--foreground); } .sort { @@ -67,57 +91,196 @@ const SearchBase = styled.div` } `; +const Overline = styled.div<{ type?: string; block?: boolean }>` + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + color: ${props => props.type === "error" ? "var(--error)" : "var(--tertiary-foreground)"}; + margin: 0.4em 0; + cursor: ${props => props.type === "error" ? "pointer" : "default"}; + display: ${props => props.block ? "block" : "inline"}; + + &:hover { + ${props => props.type === "error" && "text-decoration: underline;"} + } +`; + interface Props { close: () => void; + initialQuery?: string; + searchParams?: { + query: string; + author?: string; + mention?: string; + date_start?: string; + date_end?: string; + has?: string; + server_wide?: boolean; + }; } -export function SearchSidebar({ close }: Props) { - const channel = useClient().channels.get( - useParams<{ channel: string }>().channel, - )!; +export function SearchSidebar({ close, initialQuery = "", searchParams }: Props) { + const client = useClient(); + const history = useHistory(); + const params = useParams<{ channel: string }>(); + const channel = client.channels.get(params.channel)!; type Sort = "Relevance" | "Latest" | "Oldest"; const [sort, setSort] = useState("Latest"); - const [query, setQuery] = useState(""); + const [query, setQuery] = useState(searchParams?.query || initialQuery); 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() { - if (!query) return; - setState({ type: "loading" }); - const data = await channel.searchWithUsers({ query, sort }); - setState({ type: "results", results: data.messages }); + 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; + + // 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, + 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; + } + if (searchParams?.mention) { + searchOptions.mention = searchParams.mention; + } + + // Add date filters if provided using the new standardized parameters + if (searchParams?.date_start) { + searchOptions.date_start = searchParams.date_start; + } + if (searchParams?.date_end) { + searchOptions.date_end = searchParams.date_end; + } + + // Add server-wide filter if provided + if (searchParams?.server_wide) { + searchOptions.server_wide = true; + } + + // Add has filter if provided + if (searchParams?.has) { + searchOptions.has = searchParams.has; + } + + const data = await channel.searchWithUsers(searchOptions); + + // 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]); + }, [ + sort, + query, + searchParams?.query, + searchParams?.author, + searchParams?.mention, + searchParams?.date_start, + searchParams?.date_end, + searchParams?.has, + searchParams?.server_wide + ]); return ( - + - - « back to members} - /> - - + - - e.key === "Enter" && search()} - onChange={(e) => setQuery(e.currentTarget.value)} - /> +
- {["Latest", "Oldest", "Relevance"].map((key) => ( + {(["Latest", "Oldest", "Relevance"] as Sort[]).map((key) => (
{state.type === "loading" && } - {state.type === "results" && ( -
- {state.results.map((message) => { - let href = ""; - if (channel?.channel_type === "TextChannel") { - href += `/server/${channel.server_id}`; - } + {state.type === "results" && (() => { + const currentPageMessages = state.pages.get(state.currentPage) || []; + return ( + <> + + {currentPageMessages.length > 0 + ? currentPageMessages.length === 1 ? 'Result' : 'Results' + : 'No Results' + } + + + {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}`; + } + href += `/channel/${message.channel_id}/${message._id}`; - href += `/channel/${message.channel_id}/${message._id}`; - - return ( - -
- -
- - ); - })} -
- )} + return ( +
+ {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} + + + +
+ )} + + ); + })()}
-
+ ); -} +} \ No newline at end of file diff --git a/src/lib/hooks/useSearchAutoComplete.ts b/src/lib/hooks/useSearchAutoComplete.ts new file mode 100644 index 00000000..930a19c7 --- /dev/null +++ b/src/lib/hooks/useSearchAutoComplete.ts @@ -0,0 +1,363 @@ +import { useState } from "preact/hooks"; +import { User } from "revolt.js"; +import { useClient } from "../../controllers/client/ClientController"; +import { AutoCompleteState, SearchClues } from "../../components/common/AutoComplete"; + +export interface SearchAutoCompleteProps { + state: AutoCompleteState; + setState: (state: AutoCompleteState) => void; + onKeyUp: (ev: KeyboardEvent) => void; + onKeyDown: (ev: KeyboardEvent) => boolean; + onChange: (ev: Event) => void; + onClick: (userId: string, username: string) => void; + onFocus: () => void; + onBlur: () => void; +} + +// Mapping of user IDs to usernames for display +export interface UserMapping { + [key: string]: string; +} + +export function useSearchAutoComplete( + setValue: (v: string) => void, + userMappings: UserMapping, + setUserMappings: (mappings: UserMapping) => void, +): SearchAutoCompleteProps { + const [state, setState] = useState({ type: "none" }); + const [focused, setFocused] = useState(false); + const client = useClient(); + + function findSearchPattern( + value: string, + cursorPos: number + ): ["user", string, number, "from" | "mentions"] | undefined { + // Look backwards from cursor to find "from:" or "mentions:" + const beforeCursor = value.slice(0, cursorPos); + const afterCursor = value.slice(cursorPos); + + // Check if we're currently typing after "from:" or "mentions:" (including hyphens) + const fromMatch = beforeCursor.match(/\bfrom:(@?[\w-]*)$/); + if (fromMatch) { + // Check if there's already a username that continues after cursor + const continuationMatch = afterCursor.match(/^([\w-]*)/); + const fullUsername = fromMatch[1].replace('@', '') + (continuationMatch ? continuationMatch[1] : ''); + return ["user", fullUsername, fromMatch.index! + 5, "from"]; + } + + const mentionsMatch = beforeCursor.match(/\bmentions:(@?[\w-]*)$/); + if (mentionsMatch) { + // Check if there's already a username that continues after cursor + const continuationMatch = afterCursor.match(/^([\w-]*)/); + const fullUsername = mentionsMatch[1].replace('@', '') + (continuationMatch ? continuationMatch[1] : ''); + return ["user", fullUsername, mentionsMatch.index! + 9, "mentions"]; + } + + // Also check if cursor is inside an existing filter + const lastFromIndex = beforeCursor.lastIndexOf("from:"); + const lastMentionsIndex = beforeCursor.lastIndexOf("mentions:"); + + if (lastFromIndex !== -1 || lastMentionsIndex !== -1) { + const filterIndex = Math.max(lastFromIndex, lastMentionsIndex); + const filterType = lastFromIndex > lastMentionsIndex ? "from" : "mentions"; + const filterLength = filterType === "from" ? 5 : 9; + + // Check if we're still within this filter (no space between filter and cursor) + const betweenFilterAndCursor = value.slice(filterIndex + filterLength, cursorPos); + if (!betweenFilterAndCursor.includes(" ")) { + // Get the username part (including hyphens) + const afterFilter = value.slice(filterIndex + filterLength); + const usernameMatch = afterFilter.match(/^@?([\w-]*)/); + if (usernameMatch) { + return ["user", usernameMatch[1] || "", filterIndex + filterLength, filterType]; + } + } + } + + return undefined; + } + + function onChange(ev: Event) { + const el = ev.target as HTMLInputElement; + const cursorPos = el.selectionStart || 0; + + const result = findSearchPattern(el.value, cursorPos); + if (result) { + const [type, search, , filterType] = result; + const regex = new RegExp(search, "i"); + + if (type === "user") { + // Get all users - in a real app, you might want to limit this + // or use a specific search context + let users = [...client.users.values()]; + + // Filter out system user + users = users.filter( + (x) => x._id !== "00000000000000000000000000" + ); + + // Filter by search term + let matches = ( + search.length > 0 + ? users.filter((user) => + user.username.toLowerCase().match(regex) + ) + : users + ) + .slice(0, 5) + .filter((x) => typeof x !== "undefined"); + + // Always show autocomplete when filter is typed, even with no matches + const currentPosition = + state.type !== "none" ? state.selected : 0; + + setState({ + type: "user", + matches, + selected: Math.min(currentPosition, matches.length - 1), + within: false, + }); + + return; + } + } + + if (state.type !== "none") { + setState({ type: "none" }); + } + } + + function selectCurrent(el: HTMLInputElement) { + if (state.type === "user") { + const cursorPos = el.selectionStart || 0; + const result = findSearchPattern(el.value, cursorPos); + + if (result) { + const [, search, startIndex, filterType] = result; + const selectedUser = state.matches[state.selected]; + + // Store the mapping + const newMappings = { ...userMappings }; + const mappingKey = `${filterType}:${selectedUser.username}`; + newMappings[mappingKey] = selectedUser._id; + setUserMappings(newMappings); + + // Find the end of the current username (including @ if present and hyphens) + let endIndex = startIndex; + const afterStartIndex = el.value.slice(startIndex); + const existingMatch = afterStartIndex.match(/^@?[\w-]*/); + if (existingMatch) { + endIndex = startIndex + existingMatch[0].length; + } + + // Replace the text with @username and add space + const before = el.value.slice(0, startIndex); + const after = el.value.slice(endIndex); + setValue(before + "@" + selectedUser.username + " " + after); + + // Set cursor position after the @username and space + setTimeout(() => { + el.setSelectionRange( + startIndex + selectedUser.username.length + 2, // +1 for @, +1 for space + startIndex + selectedUser.username.length + 2 + ); + }, 0); + + setState({ type: "none" }); + } + } + } + + function onClick(userId: string, username: string) { + const el = document.querySelector('input[type="text"]') as HTMLInputElement; + if (el && state.type === "user") { + // Find which user was clicked + const clickedIndex = state.matches.findIndex(u => u._id === userId); + if (clickedIndex !== -1) { + setState({ ...state, selected: clickedIndex }); + // Use setTimeout to ensure state is updated before selection + setTimeout(() => selectCurrent(el), 0); + } + } + setFocused(false); + } + + function onKeyDown(e: KeyboardEvent) { + if (focused && state.type !== "none") { + if (e.key === "ArrowUp") { + e.preventDefault(); + if (state.selected > 0) { + setState({ + ...state, + selected: state.selected - 1, + }); + } + return true; + } + + if (e.key === "ArrowDown") { + e.preventDefault(); + if (state.selected < state.matches.length - 1) { + setState({ + ...state, + selected: state.selected + 1, + }); + } + return true; + } + + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + if (state.matches.length > 0) { + selectCurrent(e.currentTarget as HTMLInputElement); + } + return true; + } + + if (e.key === "Escape") { + e.preventDefault(); + setState({ type: "none" }); + return true; + } + } + return false; + } + + function onKeyUp(e: KeyboardEvent) { + if (e.currentTarget !== null) { + onChange(e); + } + } + + function onFocus() { + setFocused(true); + } + + function onBlur() { + if (state.type !== "none" && state.within) return; + setFocused(false); + } + + return { + state: focused ? state : { type: "none" }, + setState, + onClick, + onChange, + onKeyUp, + onKeyDown, + onFocus, + onBlur, + }; +} + +// Transform display query with usernames to API query with user IDs +export function transformSearchQuery( + displayQuery: string, + userMappings: UserMapping +): { query: string; author?: string; mention?: string; date_start?: string; date_end?: string; has?: string; server_wide?: boolean } { + let query = displayQuery; + let author: string | undefined; + let mention: string | undefined; + let date_start: string | undefined; + let date_end: string | undefined; + let has: string | undefined; + let server_wide: boolean | undefined; + + // Extract and replace from:@username with user ID (including hyphens) + const fromMatch = query.match(/\bfrom:@?([\w-]+)/); + if (fromMatch) { + const username = fromMatch[1]; + const userId = userMappings[`from:${username}`]; + if (userId) { + author = userId; + // Remove from:@username from query + query = query.replace(fromMatch[0], "").trim(); + } + } + + // Extract and replace mentions:@username with user ID (including hyphens) + const mentionsMatch = query.match(/\bmentions:@?([\w-]+)/); + if (mentionsMatch) { + const username = mentionsMatch[1]; + const userId = userMappings[`mentions:${username}`]; + if (userId) { + mention = userId; + // Remove mentions:@username from query + query = query.replace(mentionsMatch[0], "").trim(); + } + } + + // Extract date filters (YYYY-MM-DD format) and convert to ISO 8601 + // Using the new standardized date_start and date_end approach + const beforeMatch = query.match(/\bbefore:(\d{4}-\d{2}-\d{2})/); + if (beforeMatch) { + // "before" means before the START of this day + const [year, month, day] = beforeMatch[1].split('-').map(Number); + const startOfDay = new Date(year, month - 1, day); + startOfDay.setHours(0, 0, 0, 0); + + date_end = startOfDay.toISOString(); + query = query.replace(beforeMatch[0], "").trim(); + } + + const afterMatch = query.match(/\bafter:(\d{4}-\d{2}-\d{2})/); + if (afterMatch) { + // "after" means after the END of this day + const [year, month, day] = afterMatch[1].split('-').map(Number); + const endOfDay = new Date(year, month - 1, day); + endOfDay.setHours(23, 59, 59, 999); + + date_start = endOfDay.toISOString(); + query = query.replace(afterMatch[0], "").trim(); + } + + const duringMatch = query.match(/\bduring:(\d{4}-\d{2}-\d{2})/); + if (duringMatch) { + // For 'during', capture the full day from start to end + const [year, month, day] = duringMatch[1].split('-').map(Number); + + const startOfDay = new Date(year, month - 1, day); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(year, month - 1, day); + endOfDay.setHours(23, 59, 59, 999); + + date_start = startOfDay.toISOString(); + date_end = endOfDay.toISOString(); + + query = query.replace(duringMatch[0], "").trim(); + } + + // Extract between date range filter (between:YYYY-MM-DD..YYYY-MM-DD) + const betweenMatch = query.match(/\bbetween:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/); + if (betweenMatch) { + // Start date: from the START of the first day + const [startYear, startMonth, startDay] = betweenMatch[1].split('-').map(Number); + const startOfFirstDay = new Date(startYear, startMonth - 1, startDay); + startOfFirstDay.setHours(0, 0, 0, 0); + date_start = startOfFirstDay.toISOString(); + + // End date: to the END of the last day + const [endYear, endMonth, endDay] = betweenMatch[2].split('-').map(Number); + const endOfLastDay = new Date(endYear, endMonth - 1, endDay); + endOfLastDay.setHours(23, 59, 59, 999); + date_end = endOfLastDay.toISOString(); + + query = query.replace(betweenMatch[0], "").trim(); + } + + // Extract has: filter for attachment types + const hasMatch = query.match(/\bhas:(video|image|link|audio|file)/i); + if (hasMatch) { + has = hasMatch[1].toLowerCase(); + query = query.replace(hasMatch[0], "").trim(); + } + + // Check for server-wide flag + if (query.includes("server-wide")) { + server_wide = true; + query = query.replace(/\bserver-wide\b/g, "").trim(); + } + + return { query, author, mention, date_start, date_end, has, server_wide }; +} \ No newline at end of file diff --git a/src/pages/channels/actions/HeaderActions.tsx b/src/pages/channels/actions/HeaderActions.tsx index 8eeb8aec..d5f43d86 100644 --- a/src/pages/channels/actions/HeaderActions.tsx +++ b/src/pages/channels/actions/HeaderActions.tsx @@ -1,5 +1,4 @@ /* eslint-disable react-hooks/rules-of-hooks */ -import { Search } from "@styled-icons/boxicons-regular"; import { UserPlus, Cog, @@ -23,50 +22,12 @@ import { SIDEBAR_MEMBERS } from "../../../mobx/stores/Layout"; import UpdateIndicator from "../../../components/common/UpdateIndicator"; import { modalController } from "../../../controllers/modals/ModalController"; import { ChannelHeaderProps } from "../ChannelHeader"; +import { SearchBar } from "../../../components/navigation/SearchBar"; const Container = styled.div` display: flex; gap: 16px; -`; - -const SearchBar = styled.div` - display: flex; align-items: center; - background: var(--primary-background); - border-radius: 4px; - position: relative; - width: 120px; - transition: width .25s ease; - - :focus-within { - width: 200px; - box-shadow: 0 0 0 1pt var(--accent); - } - - input { - all: unset; - font-size: 13px; - padding: 0 8px; - font-weight: 400; - height: 100%; - width: 100%; - } - } - - .actions { - display: flex; - align-items: center; - position: absolute; - right: 0; - padding: 0 8px; - pointer-events: none; - background: inherit; - - svg { - opacity: 0.4; - color: var(--foreground); - } - } `; export default function HeaderActions({ channel }: ChannelHeaderProps) { @@ -138,19 +99,7 @@ export default function HeaderActions({ channel }: ChannelHeaderProps) { )} {channel.channel_type !== "VoiceChannel" && ( - /* - -
- -
-
*/ - - - + )}