diff --git a/external/revolt.js b/external/revolt.js index 4f711d53..31c167ed 160000 --- a/external/revolt.js +++ b/external/revolt.js @@ -1 +1 @@ -Subproject commit 4f711d531ec396b0b021be0d5a538223a1b0671f +Subproject commit 31c167ed78cc9187ecb324eba90f059fafcc28a9 diff --git a/src/components/navigation/SearchAutoComplete.tsx b/src/components/navigation/SearchAutoComplete.tsx index ffa85871..bf8195b9 100644 --- a/src/components/navigation/SearchAutoComplete.tsx +++ b/src/components/navigation/SearchAutoComplete.tsx @@ -16,6 +16,12 @@ const Base = styled.div` overflow: hidden; max-height: 200px; overflow-y: auto; + + @media (max-width: 768px) { + margin-top: 8px; + max-height: 250px; + border-radius: 10px; + } button { width: 100%; @@ -43,6 +49,18 @@ const Base = styled.div` 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); + } + } } `; @@ -54,6 +72,10 @@ interface Props { 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 ( @@ -82,16 +104,16 @@ export default function SearchAutoComplete({ state, setState, onClick }: Props) e.stopPropagation(); onClick(user._id, user.username); }}> - + {user.username} )) ) : (
No users found
diff --git a/src/components/navigation/SearchBar.tsx b/src/components/navigation/SearchBar.tsx index 63cb32f0..7e0b6659 100644 --- a/src/components/navigation/SearchBar.tsx +++ b/src/components/navigation/SearchBar.tsx @@ -7,6 +7,7 @@ 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"; const Container = styled.div` position: relative; @@ -28,25 +29,42 @@ const Input = styled.input` background: transparent; color: var(--foreground); font-size: 14px; - padding: 6px 2px 6px 12px; + 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 12px 0 8px; + 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` @@ -61,6 +79,29 @@ const OptionsDropdown = styled.div` 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` @@ -70,6 +111,11 @@ const OptionsHeader = styled.div` 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` @@ -88,6 +134,18 @@ const Option = styled.div` &: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` @@ -97,12 +155,21 @@ const OptionLabel = styled.span` 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)` @@ -149,10 +216,20 @@ const searchOptions: SearchOption[] = [ 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" + }, + { + label: "has:", + description: "video, image, link, audio, file", + tooltip: "Messages with specific attachment types" } ]; @@ -161,7 +238,14 @@ export function SearchBar() { const [showOptions, setShowOptions] = useState(false); const [isSearching, setIsSearching] = useState(false); const [userMappings, setUserMappings] = useState({}); - const [showDatePicker, setShowDatePicker] = useState<"before" | "after" | "during" | null>(null); + 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 inputRef = useRef(null); // Setup autocomplete @@ -218,28 +302,49 @@ export function SearchBar() { setQuery(value); - // Check for date filters + // Check for filters const beforeCursor = value.slice(0, cursorPos); 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 { - // Only trigger autocomplete if no date filter is active - const dateFilterActive = value.match(/\b(before:|after:|during:)\s*$/); - if (!dateFilterActive) { + // Check if "has:" was removed and close attachment types dropdown + if (showAttachmentTypes && !value.includes("has:")) { + setShowAttachmentTypes(false); + } + + // Only trigger autocomplete if no filter is active + const filterActive = value.match(/\b(before:|after:|during:|between:|has:)\s*$/); + if (!filterActive) { onAutocompleteChange(e); if (showDatePicker) { setShowDatePicker(null); @@ -249,28 +354,37 @@ export function SearchBar() { }; const handleSearch = () => { - const trimmedQuery = query.trim(); + let trimmedQuery = query.trim(); - // Check for incomplete filters (only user filters, not date filters) + // Check for incomplete filters const hasIncompleteUserFilter = trimmedQuery.match(/\b(from:|mentions:)\s*$/); - const hasIncompleteDateFilter = trimmedQuery.match(/\b(before:|after:|during:)\s*$/); + const hasIncompleteDateFilter = trimmedQuery.match(/\b(before:|after:|during:|between:)\s*$/); + const hasIncompleteHasFilter = trimmedQuery.match(/\bhas:\s*$/); - if (hasIncompleteUserFilter || hasIncompleteDateFilter) { + if (hasIncompleteUserFilter || hasIncompleteDateFilter || hasIncompleteHasFilter) { // Don't search if there's an incomplete filter return; } + // Check if we have "date-range" in the query and replace it with actual dates + if (trimmedQuery.includes("date-range") && 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 we have any search criteria (query text or filters) - const hasSearchCriteria = searchParams.query || - searchParams.author || - searchParams.mention || - searchParams.before_date || - searchParams.after_date || - searchParams.during || - searchParams.server_wide; + // 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) { // Open search in right sidebar with transformed query @@ -279,6 +393,17 @@ export function SearchBar() { 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 + } } }; @@ -286,13 +411,173 @@ export function SearchBar() { const currentValue = (e.currentTarget as HTMLInputElement).value; const cursorPos = (e.currentTarget as HTMLInputElement).selectionStart || 0; - // Handle backspace/delete for server-wide + // 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 if we're at the end of "server-wide" or within it + // 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(); @@ -307,7 +592,42 @@ export function SearchBar() { return; } } else if (e.key === "Delete") { - // Check if cursor is at the beginning of server-wide + // 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(); @@ -377,9 +697,24 @@ export function SearchBar() { 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:") { - setShowDatePicker(option.label.slice(0, -1) as "before" | "after" | "during"); + 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); + } 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); + + // 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 "; @@ -402,8 +737,32 @@ export function SearchBar() { } }; + 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 = date.toISOString().split('T')[0]; // Format as YYYY-MM-DD for display + const dateStr = formatLocalDate(date); // Use local date formatting // Add the filter and date to the query with auto-space const filterText = `${showDatePicker}:${dateStr} `; @@ -422,6 +781,35 @@ export function SearchBar() { }, 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) => { @@ -435,21 +823,22 @@ export function SearchBar() { return () => window.removeEventListener("keydown", handleGlobalKeydown); }, []); - // Close date picker when clicking outside + // Close date picker or attachment types when clicking outside useEffect(() => { - if (showDatePicker) { + 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]); + }, [showDatePicker, showAttachmentTypes]); return ( e.stopPropagation()}> @@ -481,7 +870,7 @@ export function SearchBar() { onClick={onAutocompleteClick} /> )} - {showOptions && autocompleteState.type === "none" && !showDatePicker && ( + {showOptions && autocompleteState.type === "none" && !showDatePicker && !showAttachmentTypes && ( e.stopPropagation()}> {"Search Options"} {searchOptions.map((option) => ( @@ -501,9 +890,35 @@ export function SearchBar() { {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 index 590fb3ae..47f68dac 100644 --- a/src/components/navigation/SearchDatePicker.tsx +++ b/src/components/navigation/SearchDatePicker.tsx @@ -74,16 +74,34 @@ const Days = styled.div` gap: 4px; `; -const Day = styled.button<{ isToday?: boolean; isSelected?: boolean; isOtherMonth?: boolean }>` - background: ${props => props.isSelected ? 'var(--accent)' : 'transparent'}; +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) return 'var(--accent-contrast)'; + 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: 4px; + 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; @@ -99,12 +117,15 @@ const Day = styled.button<{ isToday?: boolean; isSelected?: boolean; isOtherMont width: 4px; height: 4px; border-radius: 50%; - background: var(--accent); + background: ${props.isSelected || props.isRangeStart || props.isRangeEnd ? 'var(--accent-contrast)' : 'var(--accent)'}; } `} &:hover { - background: ${props => props.isSelected ? 'var(--accent)' : 'var(--secondary-background)'}; + background: ${props => { + if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent)'; + return 'var(--secondary-background)'; + }}; } &:disabled { @@ -121,6 +142,22 @@ const QuickSelects = styled.div` 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); @@ -139,12 +176,16 @@ const QuickSelect = styled.button` interface Props { onSelect: (date: Date) => void; - filterType: "before" | "after" | "during"; + onRangeSelect?: (startDate: Date, endDate: Date) => void; + filterType: "before" | "after" | "during" | "between"; } -export default function SearchDatePicker({ onSelect, filterType }: Props) { +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"]; @@ -199,9 +240,43 @@ export default function SearchDatePicker({ onSelect, filterType }: Props) { 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) => { - setSelectedDate(date); - onSelect(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) => { @@ -246,7 +321,10 @@ export default function SearchDatePicker({ onSelect, filterType }: Props) { - {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()} + {filterType === "between" && rangeStart && !rangeEnd + ? "Select end date" + : `${monthNames[currentMonth.getMonth()]} ${currentMonth.getFullYear()}` + } navigateMonth(1)}> @@ -264,8 +342,14 @@ export default function SearchDatePicker({ onSelect, filterType }: Props) { handleDateSelect(day.date)} + onMouseEnter={() => setHoverDate(day.date)} + onMouseLeave={() => setHoverDate(null)} isToday={isToday(day.date)} - isSelected={isSameDate(day.date, selectedDate)} + 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()} @@ -273,6 +357,7 @@ export default function SearchDatePicker({ onSelect, filterType }: Props) { ))} + handleQuickSelect("today")}> Today diff --git a/src/components/navigation/right/Search.tsx b/src/components/navigation/right/Search.tsx index e165134a..ea9dda6a 100644 --- a/src/components/navigation/right/Search.tsx +++ b/src/components/navigation/right/Search.tsx @@ -109,9 +109,9 @@ interface Props { query: string; author?: string; mention?: string; - before_date?: string; - after_date?: string; - during?: string; + date_start?: string; + date_end?: string; + has?: string; server_wide?: boolean; }; } @@ -132,7 +132,8 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props) async function search() { const searchQuery = searchParams?.query || query; if (!searchQuery && !searchParams?.author && !searchParams?.mention && - !searchParams?.before_date && !searchParams?.after_date && !searchParams?.during && !searchParams?.server_wide) return; + !searchParams?.date_start && !searchParams?.date_end && + !searchParams?.has && !searchParams?.server_wide) return; setState({ type: "loading" }); const searchOptions: any = { @@ -148,15 +149,12 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props) searchOptions.mention = searchParams.mention; } - // Add date filters if provided - if (searchParams?.before_date) { - searchOptions.before_date = searchParams.before_date; + // Add date filters if provided using the new standardized parameters + if (searchParams?.date_start) { + searchOptions.date_start = searchParams.date_start; } - if (searchParams?.after_date) { - searchOptions.after_date = searchParams.after_date; - } - if (searchParams?.during) { - searchOptions.during = searchParams.during; + if (searchParams?.date_end) { + searchOptions.date_end = searchParams.date_end; } // Add server-wide filter if provided @@ -164,6 +162,11 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props) searchOptions.server_wide = true; } + // Add has filter if provided + if (searchParams?.has) { + searchOptions.has = searchParams.has; + } + const data = await channel.searchWithUsers(searchOptions); setState({ type: "results", results: data.messages }); } @@ -199,7 +202,14 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props) {state.type === "loading" && } {state.type === "results" && ( -
+ <> + + {state.results.length > 0 + ? `${state.results.length} Result${state.results.length === 1 ? '' : 's'}` + : 'No Results' + } + +
{(() => { // Group messages by channel const groupedMessages = state.results.reduce((acc, message) => { @@ -256,7 +266,8 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props) ); }); })()} -
+
+ )} diff --git a/src/lib/hooks/useSearchAutoComplete.ts b/src/lib/hooks/useSearchAutoComplete.ts index c1d1de92..930a19c7 100644 --- a/src/lib/hooks/useSearchAutoComplete.ts +++ b/src/lib/hooks/useSearchAutoComplete.ts @@ -36,19 +36,19 @@ export function useSearchAutoComplete( const beforeCursor = value.slice(0, cursorPos); const afterCursor = value.slice(cursorPos); - // Check if we're currently typing after "from:" or "mentions:" - const fromMatch = beforeCursor.match(/\bfrom:(@?\w*)$/); + // 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 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*)$/); + 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 continuationMatch = afterCursor.match(/^([\w-]*)/); const fullUsername = mentionsMatch[1].replace('@', '') + (continuationMatch ? continuationMatch[1] : ''); return ["user", fullUsername, mentionsMatch.index! + 9, "mentions"]; } @@ -65,9 +65,9 @@ export function useSearchAutoComplete( // 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 + // Get the username part (including hyphens) const afterFilter = value.slice(filterIndex + filterLength); - const usernameMatch = afterFilter.match(/^@?(\w*)/); + const usernameMatch = afterFilter.match(/^@?([\w-]*)/); if (usernameMatch) { return ["user", usernameMatch[1] || "", filterIndex + filterLength, filterType]; } @@ -142,10 +142,10 @@ export function useSearchAutoComplete( newMappings[mappingKey] = selectedUser._id; setUserMappings(newMappings); - // Find the end of the current username (including @ if present) + // 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*/); + const existingMatch = afterStartIndex.match(/^@?[\w-]*/); if (existingMatch) { endIndex = startIndex + existingMatch[0].length; } @@ -254,17 +254,17 @@ export function useSearchAutoComplete( export function transformSearchQuery( displayQuery: string, userMappings: UserMapping -): { query: string; author?: string; mention?: string; before_date?: string; after_date?: string; during?: string; server_wide?: boolean } { +): { 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 before_date: string | undefined; - let after_date: string | undefined; - let during: 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 - const fromMatch = query.match(/\bfrom:@?(\w+)/); + // 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}`]; @@ -275,8 +275,8 @@ export function transformSearchQuery( } } - // Extract and replace mentions:@username with user ID - const mentionsMatch = query.match(/\bmentions:@?(\w+)/); + // 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}`]; @@ -288,35 +288,76 @@ export function transformSearchQuery( } // 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) { - // Convert YYYY-MM-DD to full ISO 8601 format - const date = new Date(beforeMatch[1]); - before_date = date.toISOString(); + // "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) { - // Convert YYYY-MM-DD to full ISO 8601 format - const date = new Date(afterMatch[1]); - after_date = date.toISOString(); + // "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) { - // Convert YYYY-MM-DD to full ISO 8601 format - const date = new Date(duringMatch[1]); - during = date.toISOString(); + // 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, before_date, after_date, during, server_wide }; + return { query, author, mention, date_start, date_end, has, server_wide }; } \ No newline at end of file