New Search UI

pull/1154/head
NanoAim 2025-08-05 18:01:23 +08:00
parent 2475a36e50
commit f5a0818d84
8 changed files with 1419 additions and 110 deletions

2
external/revolt.js vendored

@ -1 +1 @@
Subproject commit 62d4a668b2115227b7d13e5551923b676d1d8adf
Subproject commit 9ab546e4432b1821b6c3c1e66d64d46eb159b9ee

View File

@ -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<any>(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" ? (
<SearchSidebar close={close} />
<SearchSidebar
close={close}
initialQuery={searchQuery}
searchParams={searchParams}
/>
) : (
<MemberSidebar />
);

View File

@ -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 (
<Base>
{state.matches.length > 0 ? (
state.matches.map((user, i) => (
<button
key={user._id}
className={i === state.selected ? "active" : ""}
onMouseEnter={() =>
(i !== state.selected || !state.within) &&
setState({
...state,
selected: i,
within: true,
})
}
onMouseLeave={() =>
state.within &&
setState({
...state,
within: false,
})
}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onClick(user._id, user.username);
}}>
<UserIcon size={20} target={user} status={true} />
<span>{user.username}</span>
</button>
))
) : (
<div style={{
padding: "12px",
textAlign: "center",
color: "var(--tertiary-foreground)",
fontSize: "13px"
}}>
No users found
</div>
)}
</Base>
);
}

View File

@ -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<UserMapping>({});
const [showDatePicker, setShowDatePicker] = useState<"before" | "after" | "during" | null>(null);
const inputRef = useRef<HTMLInputElement>(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 (
<Container data-search-container onMouseDown={(e) => e.stopPropagation()}>
<Input
ref={inputRef}
type="text"
placeholder="Search"
value={query}
onFocus={handleFocus}
onBlur={handleBlur}
onClick={handleClick}
onInput={handleInput}
onKeyDown={handleKeyDown}
onKeyUp={onKeyUp}
/>
{isSearching ? (
<IconButton onClick={handleClear}>
<X size={18} />
</IconButton>
) : (
<IconButton onClick={handleSearch}>
<Search size={18} />
</IconButton>
)}
{autocompleteState.type !== "none" && (
<SearchAutoComplete
state={autocompleteState}
setState={setAutocompleteState}
onClick={onAutocompleteClick}
/>
)}
{showOptions && autocompleteState.type === "none" && !showDatePicker && (
<OptionsDropdown onClick={(e) => e.stopPropagation()}>
<OptionsHeader>{"Search Options"}</OptionsHeader>
{searchOptions.map((option) => (
<Option
key={option.label}
onClick={() => handleOptionClick(option)}
>
<OptionLabel>{option.label}</OptionLabel>
<OptionDesc>{option.description}</OptionDesc>
<Tooltip content={option.tooltip} placement="right">
<HelpIcon size={16} />
</Tooltip>
</Option>
))}
</OptionsDropdown>
)}
{showDatePicker && (
<SearchDatePicker
onSelect={handleDateSelect}
filterType={showDatePicker}
/>
)}
</Container>
);
}

View File

@ -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<Date | null>(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 (
<Base data-date-picker onClick={(e) => e.stopPropagation()}>
<Header>
<NavButton onClick={() => navigateMonth(-1)}>
<ChevronLeft size={20} />
</NavButton>
<MonthYear>
{monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
</MonthYear>
<NavButton onClick={() => navigateMonth(1)}>
<ChevronRight size={20} />
</NavButton>
</Header>
<WeekDays>
{weekDays.map(day => (
<WeekDay key={day}>{day}</WeekDay>
))}
</WeekDays>
<Days>
{days.map((day, index) => (
<Day
key={index}
onClick={() => handleDateSelect(day.date)}
isToday={isToday(day.date)}
isSelected={isSameDate(day.date, selectedDate)}
isOtherMonth={day.isOtherMonth}
>
{day.date.getDate()}
</Day>
))}
</Days>
<QuickSelects>
<QuickSelect onClick={() => handleQuickSelect("today")}>
Today
</QuickSelect>
<QuickSelect onClick={() => handleQuickSelect("yesterday")}>
Yesterday
</QuickSelect>
<QuickSelect onClick={() => handleQuickSelect("week")}>
Last week
</QuickSelect>
<QuickSelect onClick={() => handleQuickSelect("month")}>
Last month
</QuickSelect>
</QuickSelects>
</Base>
);
}

View File

@ -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<Sort>("Latest");
const [query, setQuery] = useState("");
const [query, setQuery] = useState(searchParams?.query || initialQuery);
const [state, setState] = useState<SearchState>({ 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 (
<GenericSidebarBase data-scroll-offset="with-padding">
<SearchSidebarBase>
<GenericSidebarList>
<SearchBase>
<Category>
<Error
error={<a onClick={close}>« back to members</a>}
/>
</Category>
<Category>
<Overline type="subtle" block>
<Text id="app.main.channel.search.title" />
</Category>
<InputBox
value={query}
onKeyDown={(e) => e.key === "Enter" && search()}
onChange={(e) => setQuery(e.currentTarget.value)}
/>
</Overline>
<div className="sort">
{["Latest", "Oldest", "Relevance"].map((key) => (
{(["Latest", "Oldest", "Relevance"] as Sort[]).map((key) => (
<Button
key={key}
compact
palette={sort === key ? "accent" : "primary"}
onClick={() => setSort(key as Sort)}>
palette={sort === key ? "accent" : "secondary"}
onClick={() => setSort(key)}>
<Text
id={`app.main.channel.search.sort.${key.toLowerCase()}`}
/>
@ -127,30 +200,66 @@ export function SearchSidebar({ close }: Props) {
{state.type === "loading" && <Preloader type="ring" />}
{state.type === "results" && (
<div className="list">
{state.results.map((message) => {
let href = "";
if (channel?.channel_type === "TextChannel") {
href += `/server/${channel.server_id}`;
{(() => {
// Group messages by channel
const groupedMessages = state.results.reduce((acc, message) => {
const channelId = message.channel_id;
if (!acc[channelId]) {
acc[channelId] = [];
}
acc[channelId].push(message);
return acc;
}, {} as Record<string, MessageI[]>);
const client = useClient();
return Object.entries(groupedMessages).map(([channelId, messages]) => {
const messageChannel = client.channels.get(channelId);
const channelName = messageChannel?.name || "Unknown Channel";
return (
<div key={channelId}>
<Overline type="subtle" block>
# {channelName}
</Overline>
{messages.map((message) => {
let href = "";
if (messageChannel?.channel_type === "TextChannel") {
href += `/server/${messageChannel.server_id}`;
}
href += `/channel/${message.channel_id}/${message._id}`;
return (
<Link to={href} key={message._id}>
<div className="message">
<div
key={message._id}
className="message"
style={{ cursor: 'pointer' }}
onClick={(e) => {
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);
}}
>
<Message
message={message}
head
hideReply
/>
</div>
</Link>
);
})}
</div>
);
});
})()}
</div>
)}
</SearchBase>
</GenericSidebarList>
</GenericSidebarBase>
</SearchSidebarBase>
);
}

View File

@ -0,0 +1,322 @@
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<AutoCompleteState>({ 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:"
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
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)
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; before_date?: string; after_date?: string; during?: 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 server_wide: boolean | undefined;
// Extract and replace from:@username with user ID
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
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
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();
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();
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();
query = query.replace(duringMatch[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 };
}

View File

@ -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) {
</IconButton>
)}
{channel.channel_type !== "VoiceChannel" && (
/*<SearchBar>
<input
type="text"
placeholder="Search..."
onClick={openSearch}
/>
<div className="actions">
<Search size={18} />
</div>
</SearchBar>*/
<IconButton onClick={openSearch}>
<Search size={25} />
</IconButton>
<SearchBar />
)}
</Container>
</>