From f5a0818d84e89293af5c34dd72066a3684dbaac5 Mon Sep 17 00:00:00 2001 From: NanoAim <65581271+NanoAim@users.noreply.github.com> Date: Tue, 5 Aug 2025 18:01:23 +0800 Subject: [PATCH] New Search UI --- external/revolt.js | 2 +- src/components/navigation/RightSidebar.tsx | 43 +- .../navigation/SearchAutoComplete.tsx | 101 ++++ src/components/navigation/SearchBar.tsx | 509 ++++++++++++++++++ .../navigation/SearchDatePicker.tsx | 292 ++++++++++ src/components/navigation/right/Search.tsx | 205 +++++-- src/lib/hooks/useSearchAutoComplete.ts | 322 +++++++++++ src/pages/channels/actions/HeaderActions.tsx | 55 +- 8 files changed, 1419 insertions(+), 110 deletions(-) create mode 100644 src/components/navigation/SearchAutoComplete.tsx create mode 100644 src/components/navigation/SearchBar.tsx create mode 100644 src/components/navigation/SearchDatePicker.tsx create mode 100644 src/lib/hooks/useSearchAutoComplete.ts diff --git a/external/revolt.js b/external/revolt.js index 62d4a668..9ab546e4 160000 --- a/external/revolt.js +++ b/external/revolt.js @@ -1 +1 @@ -Subproject commit 62d4a668b2115227b7d13e5551923b676d1d8adf +Subproject commit 9ab546e4432b1821b6c3c1e66d64d46eb159b9ee 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..ffa85871 --- /dev/null +++ b/src/components/navigation/SearchAutoComplete.tsx @@ -0,0 +1,101 @@ +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; + + 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; + } + } +`; + +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; + + 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..63cb32f0 --- /dev/null +++ b/src/components/navigation/SearchBar.tsx @@ -0,0 +1,509 @@ +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 { Tooltip } from "@revoltchat/ui"; +import { internalEmit } from "../../lib/eventEmitter"; +import { useSearchAutoComplete, transformSearchQuery, UserMapping } from "../../lib/hooks/useSearchAutoComplete"; +import SearchAutoComplete from "./SearchAutoComplete"; +import SearchDatePicker from "./SearchDatePicker"; + +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 12px; + outline: none; + + &::placeholder { + color: var(--tertiary-foreground); + } +`; + +const IconButton = styled.div` + display: flex; + align-items: center; + padding: 0 12px 0 8px; + color: var(--tertiary-foreground); + cursor: pointer; + transition: color 0.1s ease; + + &:hover { + color: var(--foreground); + } +`; + +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; +`; + +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; +`; + +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; + } +`; + +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; +`; + +const OptionDesc = styled.span` + color: var(--tertiary-foreground); + font-size: 13px; + flex: 1; +`; + +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: "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: "server-wide", + description: "Entire server", + tooltip: "Search in entire server instead of just this channel" + } +]; + +export function SearchBar() { + 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" | null>(null); + const inputRef = useRef(null); + + // Setup autocomplete + const { + state: autocompleteState, + setState: setAutocompleteState, + onKeyUp, + onKeyDown: onAutocompleteKeyDown, + onChange: onAutocompleteChange, + onClick: onAutocompleteClick, + onFocus: onAutocompleteFocus, + onBlur: onAutocompleteBlur, + } = useSearchAutoComplete(setQuery, userMappings, setUserMappings); + + const handleFocus = () => { + onAutocompleteFocus(); + setShowOptions(true); + }; + + const handleClick = () => { + // Show options when clicking on the input, even if already focused + if (!showOptions && autocompleteState.type === "none" && !showDatePicker) { + setShowOptions(true); + } + }; + + const handleBlur = () => { + onAutocompleteBlur(); + // Delay to allow clicking on options + setTimeout(() => { + // Check if we have an incomplete filter + const hasIncompleteFilter = query.match(/\b(from:|mentions:)\s*$/); + const hasIncompleteDateFilter = query.match(/\b(before:|after:|during:)\s*$/); + + if (!hasIncompleteFilter && !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); + + // Check for date 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*$/); + + if (beforeMatch) { + setShowDatePicker("before"); + setShowOptions(false); + setAutocompleteState({ type: "none" }); + } else if (afterMatch) { + setShowDatePicker("after"); + setShowOptions(false); + setAutocompleteState({ type: "none" }); + } else if (duringMatch) { + setShowDatePicker("during"); + setShowOptions(false); + setAutocompleteState({ type: "none" }); + } else { + // Only trigger autocomplete if no date filter is active + const dateFilterActive = value.match(/\b(before:|after:|during:)\s*$/); + if (!dateFilterActive) { + onAutocompleteChange(e); + if (showDatePicker) { + setShowDatePicker(null); + } + } + } + }; + + const handleSearch = () => { + const trimmedQuery = query.trim(); + + // Check for incomplete filters (only user filters, not date filters) + const hasIncompleteUserFilter = trimmedQuery.match(/\b(from:|mentions:)\s*$/); + const hasIncompleteDateFilter = trimmedQuery.match(/\b(before:|after:|during:)\s*$/); + + if (hasIncompleteUserFilter || hasIncompleteDateFilter) { + // Don't search if there's an incomplete filter + return; + } + + // 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; + + if (hasSearchCriteria) { + // Open search in right sidebar with transformed query + internalEmit("RightSidebar", "open", "search", searchParams); + setShowOptions(false); + setIsSearching(true); + setAutocompleteState({ type: "none" }); + inputRef.current?.blur(); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + const currentValue = (e.currentTarget as HTMLInputElement).value; + const cursorPos = (e.currentTarget as HTMLInputElement).selectionStart || 0; + + // Handle backspace/delete for 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 + if (e.key === "Backspace") { + 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") { + // Check if cursor is at the beginning of server-wide + 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(""); + } else { + inputRef.current?.blur(); + } + if (isSearching) { + internalEmit("RightSidebar", "close"); + setIsSearching(false); + } + } + }; + + const handleClear = () => { + setQuery(""); + setIsSearching(false); + 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:") { + setShowDatePicker(option.label.slice(0, -1) as "before" | "after" | "during"); + setShowOptions(false); + } 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); + + // 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 { + // For other filters, add the text immediately + const newQuery = query + (query ? " " : "") + option.label; + setQuery(newQuery); + inputRef.current?.focus(); + } + }; + + const handleDateSelect = (date: Date) => { + const dateStr = date.toISOString().split('T')[0]; // Format as YYYY-MM-DD for display + + // 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); + }; + + // 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 when clicking outside + useEffect(() => { + if (showDatePicker) { + 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); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + } + }, [showDatePicker]); + + return ( + e.stopPropagation()}> + + {isSearching ? ( + + + + ) : ( + + + + )} + {autocompleteState.type !== "none" && ( + + )} + {showOptions && autocompleteState.type === "none" && !showDatePicker && ( + e.stopPropagation()}> + {"Search Options"} + {searchOptions.map((option) => ( + + ))} + + )} + {showDatePicker && ( + + )} + + ); +} \ 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..590fb3ae --- /dev/null +++ b/src/components/navigation/SearchDatePicker.tsx @@ -0,0 +1,292 @@ +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 }>` + background: ${props => props.isSelected ? 'var(--accent)' : 'transparent'}; + color: ${props => { + if (props.isSelected) return 'var(--accent-contrast)'; + if (props.isOtherMonth) return 'var(--tertiary-foreground)'; + return 'var(--foreground)'; + }}; + border: none; + padding: 8px; + border-radius: 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: var(--accent); + } + `} + + &:hover { + background: ${props => props.isSelected ? 'var(--accent)' : '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 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; + filterType: "before" | "after" | "during"; +} + +export default function SearchDatePicker({ onSelect, filterType }: Props) { + const [currentMonth, setCurrentMonth] = useState(new Date()); + const [selectedDate, setSelectedDate] = 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 handleDateSelect = (date: Date) => { + 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)}> + + + + {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()} + + navigateMonth(1)}> + + +
+ + + {weekDays.map(day => ( + {day} + ))} + + + + {days.map((day, index) => ( + handleDateSelect(day.date)} + isToday={isToday(day.date)} + isSelected={isSameDate(day.date, selectedDate)} + 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..e165134a 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"; @@ -23,8 +24,22 @@ type SearchState = results: MessageI[]; }; +// 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 +53,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 +66,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 +88,109 @@ 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; + before_date?: string; + after_date?: string; + during?: 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); async function search() { - if (!query) return; + const searchQuery = searchParams?.query || query; + if (!searchQuery && !searchParams?.author && !searchParams?.mention && + !searchParams?.before_date && !searchParams?.after_date && !searchParams?.during && !searchParams?.server_wide) return; + setState({ type: "loading" }); - const data = await channel.searchWithUsers({ query, sort }); + const searchOptions: any = { + query: searchQuery, + sort + }; + + // Add user filters if provided + if (searchParams?.author) { + searchOptions.author = searchParams.author; + } + if (searchParams?.mention) { + searchOptions.mention = searchParams.mention; + } + + // Add date filters if provided + if (searchParams?.before_date) { + searchOptions.before_date = searchParams.before_date; + } + if (searchParams?.after_date) { + searchOptions.after_date = searchParams.after_date; + } + if (searchParams?.during) { + searchOptions.during = searchParams.during; + } + + // Add server-wide filter if provided + if (searchParams?.server_wide) { + searchOptions.server_wide = true; + } + + const data = await channel.searchWithUsers(searchOptions); setState({ type: "results", results: data.messages }); } useEffect(() => { search(); + // Save search params when they change + if (searchParams) { + setSavedSearchParams(searchParams); + } // eslint-disable-next-line - }, [sort]); + }, [sort, query, searchParams]); 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) => (