Merge branch 'feat/search-engine'
commit
6539b9c5b4
|
|
@ -1 +1 @@
|
|||
Subproject commit 62d4a668b2115227b7d13e5551923b676d1d8adf
|
||||
Subproject commit 31c167ed78cc9187ecb324eba90f059fafcc28a9
|
||||
|
|
@ -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 />
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
import styled from "styled-components/macro";
|
||||
import { User } from "revolt.js";
|
||||
import { AutoCompleteState } from "../common/AutoComplete";
|
||||
import UserIcon from "../common/user/UserIcon";
|
||||
|
||||
const Base = styled.div`
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--primary-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1001;
|
||||
overflow: hidden;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
margin-top: 8px;
|
||||
max-height: 250px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
display: flex;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
font-family: inherit;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
text-align: left;
|
||||
transition: background 0.15s ease;
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 14px 16px;
|
||||
font-size: 15px;
|
||||
gap: 12px;
|
||||
|
||||
/* Add touch feedback for mobile */
|
||||
&:active {
|
||||
background: var(--secondary-background);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
state: AutoCompleteState;
|
||||
setState: (state: AutoCompleteState) => void;
|
||||
onClick: (userId: string, username: string) => void;
|
||||
}
|
||||
|
||||
export default function SearchAutoComplete({ state, setState, onClick }: Props) {
|
||||
if (state.type !== "user") return null;
|
||||
|
||||
// Detect if we're on mobile
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const iconSize = isMobile ? 24 : 20;
|
||||
|
||||
return (
|
||||
<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={iconSize} target={user} status={true} />
|
||||
<span>{user.username}</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div style={{
|
||||
padding: isMobile ? "16px" : "12px",
|
||||
textAlign: "center",
|
||||
color: "var(--tertiary-foreground)",
|
||||
fontSize: isMobile ? "14px" : "13px"
|
||||
}}>
|
||||
No users found
|
||||
</div>
|
||||
)}
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,377 @@
|
|||
import styled from "styled-components/macro";
|
||||
import { useState, useEffect, useRef } from "preact/hooks";
|
||||
import { ChevronLeft, ChevronRight } from "@styled-icons/boxicons-regular";
|
||||
|
||||
const Base = styled.div`
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 4px;
|
||||
background: var(--primary-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1002;
|
||||
overflow: hidden;
|
||||
padding: 12px;
|
||||
min-width: 280px;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
min-width: 240px;
|
||||
right: -20px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Header = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
`;
|
||||
|
||||
const MonthYear = styled.div`
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
font-size: 14px;
|
||||
`;
|
||||
|
||||
const NavButton = styled.button`
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--tertiary-foreground);
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--secondary-background);
|
||||
color: var(--foreground);
|
||||
}
|
||||
`;
|
||||
|
||||
const WeekDays = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
`;
|
||||
|
||||
const WeekDay = styled.div`
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--tertiary-foreground);
|
||||
text-transform: uppercase;
|
||||
padding: 4px;
|
||||
`;
|
||||
|
||||
const Days = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
const Day = styled.button<{
|
||||
isToday?: boolean;
|
||||
isSelected?: boolean;
|
||||
isOtherMonth?: boolean;
|
||||
isRangeStart?: boolean;
|
||||
isRangeEnd?: boolean;
|
||||
isInRange?: boolean;
|
||||
isInHoverRange?: boolean;
|
||||
}>`
|
||||
background: ${props => {
|
||||
if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent)';
|
||||
if (props.isInRange) return 'var(--accent-disabled)';
|
||||
if (props.isInHoverRange) return 'var(--hover)';
|
||||
return 'transparent';
|
||||
}};
|
||||
color: ${props => {
|
||||
if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent-contrast)';
|
||||
if (props.isOtherMonth) return 'var(--tertiary-foreground)';
|
||||
return 'var(--foreground)';
|
||||
}};
|
||||
border: none;
|
||||
padding: 8px;
|
||||
border-radius: ${props => {
|
||||
if (props.isRangeStart || (props.isInHoverRange && props.isOtherMonth === false && !props.isRangeEnd)) return '4px 0 0 4px';
|
||||
if (props.isRangeEnd || (props.isInHoverRange && props.isOtherMonth === false && !props.isRangeStart)) return '0 4px 4px 0';
|
||||
if (props.isInRange || props.isInHoverRange) return '0';
|
||||
return '4px';
|
||||
}};
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 0.1s ease;
|
||||
position: relative;
|
||||
|
||||
${props => props.isToday && `
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: ${props.isSelected || props.isRangeStart || props.isRangeEnd ? 'var(--accent-contrast)' : 'var(--accent)'};
|
||||
}
|
||||
`}
|
||||
|
||||
&:hover {
|
||||
background: ${props => {
|
||||
if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent)';
|
||||
return 'var(--secondary-background)';
|
||||
}};
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
`;
|
||||
|
||||
const QuickSelects = styled.div`
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--secondary-background);
|
||||
`;
|
||||
|
||||
const RangePreview = styled.div`
|
||||
margin: 12px 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--secondary-background);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--foreground);
|
||||
text-align: center;
|
||||
|
||||
.hint {
|
||||
color: var(--tertiary-foreground);
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
`;
|
||||
|
||||
const QuickSelect = styled.button`
|
||||
background: transparent;
|
||||
border: 1px solid var(--secondary-background);
|
||||
color: var(--foreground);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--secondary-background);
|
||||
border-color: var(--tertiary-background);
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
onSelect: (date: Date) => void;
|
||||
onRangeSelect?: (startDate: Date, endDate: Date) => void;
|
||||
filterType: "before" | "after" | "during" | "between";
|
||||
}
|
||||
|
||||
export default function SearchDatePicker({ onSelect, onRangeSelect, filterType }: Props) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date());
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const [rangeStart, setRangeStart] = useState<Date | null>(null);
|
||||
const [rangeEnd, setRangeEnd] = useState<Date | null>(null);
|
||||
const [hoverDate, setHoverDate] = 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 isDateInRange = (date: Date) => {
|
||||
if (!rangeStart || !rangeEnd) return false;
|
||||
return date >= rangeStart && date <= rangeEnd;
|
||||
};
|
||||
|
||||
const isDateInHoverRange = (date: Date) => {
|
||||
if (filterType !== "between" || !rangeStart || rangeEnd || !hoverDate) return false;
|
||||
const start = rangeStart < hoverDate ? rangeStart : hoverDate;
|
||||
const end = rangeStart < hoverDate ? hoverDate : rangeStart;
|
||||
return date >= start && date <= end;
|
||||
};
|
||||
|
||||
const handleDateSelect = (date: Date) => {
|
||||
if (filterType === "between") {
|
||||
if (!rangeStart || (rangeStart && rangeEnd)) {
|
||||
// First click or reset
|
||||
setRangeStart(date);
|
||||
setRangeEnd(null);
|
||||
} else {
|
||||
// Second click
|
||||
if (date < rangeStart) {
|
||||
setRangeEnd(rangeStart);
|
||||
setRangeStart(date);
|
||||
} else {
|
||||
setRangeEnd(date);
|
||||
}
|
||||
// Call the callback with both dates
|
||||
if (onRangeSelect) {
|
||||
const start = date < rangeStart ? date : rangeStart;
|
||||
const end = date < rangeStart ? rangeStart : date;
|
||||
onRangeSelect(start, end);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setSelectedDate(date);
|
||||
onSelect(date);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickSelect = (option: string) => {
|
||||
const today = new Date();
|
||||
let date: Date;
|
||||
|
||||
switch (option) {
|
||||
case "today":
|
||||
date = today;
|
||||
break;
|
||||
case "yesterday":
|
||||
date = new Date(today);
|
||||
date.setDate(today.getDate() - 1);
|
||||
break;
|
||||
case "week":
|
||||
date = new Date(today);
|
||||
date.setDate(today.getDate() - 7);
|
||||
break;
|
||||
case "month":
|
||||
date = new Date(today);
|
||||
date.setMonth(today.getMonth() - 1);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
handleDateSelect(date);
|
||||
};
|
||||
|
||||
const navigateMonth = (direction: number) => {
|
||||
const newMonth = new Date(currentMonth);
|
||||
newMonth.setMonth(currentMonth.getMonth() + direction);
|
||||
setCurrentMonth(newMonth);
|
||||
};
|
||||
|
||||
const days = getDaysInMonth(currentMonth);
|
||||
|
||||
return (
|
||||
<Base data-date-picker onClick={(e) => e.stopPropagation()}>
|
||||
<Header>
|
||||
<NavButton onClick={() => navigateMonth(-1)}>
|
||||
<ChevronLeft size={20} />
|
||||
</NavButton>
|
||||
<MonthYear>
|
||||
{filterType === "between" && rangeStart && !rangeEnd
|
||||
? "Select end date"
|
||||
: `${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)}
|
||||
onMouseEnter={() => setHoverDate(day.date)}
|
||||
onMouseLeave={() => setHoverDate(null)}
|
||||
isToday={isToday(day.date)}
|
||||
isSelected={filterType !== "between" && isSameDate(day.date, selectedDate)}
|
||||
isRangeStart={filterType === "between" && rangeStart && isSameDate(day.date, rangeStart)}
|
||||
isRangeEnd={filterType === "between" && rangeEnd && isSameDate(day.date, rangeEnd)}
|
||||
isInRange={filterType === "between" && isDateInRange(day.date)}
|
||||
isInHoverRange={isDateInHoverRange(day.date)}
|
||||
isOtherMonth={day.isOtherMonth}
|
||||
>
|
||||
{day.date.getDate()}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,13 +1,14 @@
|
|||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useParams, useHistory } from "react-router-dom";
|
||||
import { Message as MessageI } from "revolt.js";
|
||||
import styled from "styled-components/macro";
|
||||
|
||||
import { Text } from "preact-i18n";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { Button, Category, Error, InputBox, Preloader } from "@revoltchat/ui";
|
||||
import { Button, Preloader } from "@revoltchat/ui";
|
||||
|
||||
import { useClient } from "../../../controllers/client/ClientController";
|
||||
import { internalEmit } from "../../../lib/eventEmitter";
|
||||
import Message from "../../common/messaging/Message";
|
||||
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
||||
|
||||
|
|
@ -20,11 +21,28 @@ type SearchState =
|
|||
}
|
||||
| {
|
||||
type: "results";
|
||||
results: MessageI[];
|
||||
pages: Map<number, MessageI[]>;
|
||||
currentPage: number;
|
||||
hasMore: boolean;
|
||||
isLoadingPage?: boolean;
|
||||
};
|
||||
|
||||
// Custom wider sidebar for search results
|
||||
const SearchSidebarBase = styled(GenericSidebarBase)`
|
||||
width: 360px; /* Increased from 232px */
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
width: 280px;
|
||||
}
|
||||
`;
|
||||
|
||||
const SearchBase = styled.div`
|
||||
padding: 6px;
|
||||
padding-top: 48px; /* Add space for the header */
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
|
|
@ -38,12 +56,10 @@ const SearchBase = styled.div`
|
|||
}
|
||||
|
||||
.message {
|
||||
margin: 2px;
|
||||
padding: 6px;
|
||||
margin: 4px 2px 8px 2px;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--border-radius);
|
||||
|
||||
color: var(--foreground);
|
||||
background: var(--primary-background);
|
||||
|
||||
&:hover {
|
||||
|
|
@ -53,6 +69,14 @@ const SearchBase = styled.div`
|
|||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Override message text color but preserve mentions and other highlights */
|
||||
p {
|
||||
color: var(--foreground) !important;
|
||||
}
|
||||
|
||||
/* Also override any direct text that might be themed */
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
.sort {
|
||||
|
|
@ -67,57 +91,196 @@ const SearchBase = styled.div`
|
|||
}
|
||||
`;
|
||||
|
||||
const Overline = styled.div<{ type?: string; block?: boolean }>`
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: ${props => props.type === "error" ? "var(--error)" : "var(--tertiary-foreground)"};
|
||||
margin: 0.4em 0;
|
||||
cursor: ${props => props.type === "error" ? "pointer" : "default"};
|
||||
display: ${props => props.block ? "block" : "inline"};
|
||||
|
||||
&:hover {
|
||||
${props => props.type === "error" && "text-decoration: underline;"}
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
close: () => void;
|
||||
initialQuery?: string;
|
||||
searchParams?: {
|
||||
query: string;
|
||||
author?: string;
|
||||
mention?: string;
|
||||
date_start?: string;
|
||||
date_end?: string;
|
||||
has?: string;
|
||||
server_wide?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function SearchSidebar({ close }: Props) {
|
||||
const channel = useClient().channels.get(
|
||||
useParams<{ channel: string }>().channel,
|
||||
)!;
|
||||
export function SearchSidebar({ close, initialQuery = "", searchParams }: Props) {
|
||||
const client = useClient();
|
||||
const history = useHistory();
|
||||
const params = useParams<{ channel: string }>();
|
||||
const channel = client.channels.get(params.channel)!;
|
||||
|
||||
type Sort = "Relevance" | "Latest" | "Oldest";
|
||||
const [sort, setSort] = useState<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);
|
||||
const [pageLastMessageIds, setPageLastMessageIds] = useState<Map<number, string>>(new Map());
|
||||
|
||||
const MESSAGES_PER_PAGE = 50;
|
||||
|
||||
async function search() {
|
||||
if (!query) return;
|
||||
setState({ type: "loading" });
|
||||
const data = await channel.searchWithUsers({ query, sort });
|
||||
setState({ type: "results", results: data.messages });
|
||||
async function searchPage(pageNumber: number) {
|
||||
const searchQuery = searchParams?.query || query;
|
||||
if (!searchQuery && !searchParams?.author && !searchParams?.mention &&
|
||||
!searchParams?.date_start && !searchParams?.date_end &&
|
||||
!searchParams?.has && !searchParams?.server_wide) return;
|
||||
|
||||
// Check if we already have this page cached
|
||||
if (state.type === "results" && state.pages.has(pageNumber) && pageNumber !== state.currentPage) {
|
||||
setState({ ...state, currentPage: pageNumber });
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as loading page
|
||||
if (state.type === "results") {
|
||||
setState({ ...state, isLoadingPage: true });
|
||||
} else {
|
||||
setState({ type: "loading" });
|
||||
}
|
||||
|
||||
const searchOptions: any = {
|
||||
query: searchQuery,
|
||||
sort,
|
||||
limit: MESSAGES_PER_PAGE
|
||||
};
|
||||
|
||||
// Add pagination cursor for pages after the first
|
||||
if (pageNumber > 1) {
|
||||
const previousPageLastId = pageLastMessageIds.get(pageNumber - 1);
|
||||
if (previousPageLastId) {
|
||||
searchOptions.before = previousPageLastId;
|
||||
} else {
|
||||
// If we don't have the previous page, we need to load pages sequentially
|
||||
// This shouldn't happen in normal navigation
|
||||
console.warn("Previous page not loaded, loading sequentially");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add user filters if provided
|
||||
if (searchParams?.author) {
|
||||
searchOptions.author = searchParams.author;
|
||||
}
|
||||
if (searchParams?.mention) {
|
||||
searchOptions.mention = searchParams.mention;
|
||||
}
|
||||
|
||||
// Add date filters if provided using the new standardized parameters
|
||||
if (searchParams?.date_start) {
|
||||
searchOptions.date_start = searchParams.date_start;
|
||||
}
|
||||
if (searchParams?.date_end) {
|
||||
searchOptions.date_end = searchParams.date_end;
|
||||
}
|
||||
|
||||
// Add server-wide filter if provided
|
||||
if (searchParams?.server_wide) {
|
||||
searchOptions.server_wide = true;
|
||||
}
|
||||
|
||||
// Add has filter if provided
|
||||
if (searchParams?.has) {
|
||||
searchOptions.has = searchParams.has;
|
||||
}
|
||||
|
||||
const data = await channel.searchWithUsers(searchOptions);
|
||||
|
||||
// Store the last message ID for this page
|
||||
if (data.messages.length > 0) {
|
||||
const newPageLastIds = new Map(pageLastMessageIds);
|
||||
newPageLastIds.set(pageNumber, data.messages[data.messages.length - 1]._id);
|
||||
setPageLastMessageIds(newPageLastIds);
|
||||
}
|
||||
|
||||
if (state.type === "results") {
|
||||
// Add this page to the cache
|
||||
const newPages = new Map(state.pages);
|
||||
newPages.set(pageNumber, data.messages);
|
||||
setState({
|
||||
type: "results",
|
||||
pages: newPages,
|
||||
currentPage: pageNumber,
|
||||
hasMore: data.messages.length === MESSAGES_PER_PAGE,
|
||||
isLoadingPage: false
|
||||
});
|
||||
} else {
|
||||
// First page load
|
||||
const newPages = new Map<number, MessageI[]>();
|
||||
newPages.set(1, data.messages);
|
||||
setState({
|
||||
type: "results",
|
||||
pages: newPages,
|
||||
currentPage: 1,
|
||||
hasMore: data.messages.length === MESSAGES_PER_PAGE,
|
||||
isLoadingPage: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function goToNextPage() {
|
||||
if (state.type === "results" && state.hasMore && !state.isLoadingPage) {
|
||||
searchPage(state.currentPage + 1);
|
||||
}
|
||||
}
|
||||
|
||||
function goToPreviousPage() {
|
||||
if (state.type === "results" && state.currentPage > 1 && !state.isLoadingPage) {
|
||||
searchPage(state.currentPage - 1);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
search();
|
||||
// Reset to page 1 when search params change
|
||||
searchPage(1);
|
||||
// Clear cached pages when search params change
|
||||
setPageLastMessageIds(new Map());
|
||||
// Save search params when they change
|
||||
if (searchParams) {
|
||||
setSavedSearchParams(searchParams);
|
||||
}
|
||||
// eslint-disable-next-line
|
||||
}, [sort]);
|
||||
}, [
|
||||
sort,
|
||||
query,
|
||||
searchParams?.query,
|
||||
searchParams?.author,
|
||||
searchParams?.mention,
|
||||
searchParams?.date_start,
|
||||
searchParams?.date_end,
|
||||
searchParams?.has,
|
||||
searchParams?.server_wide
|
||||
]);
|
||||
|
||||
return (
|
||||
<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()}`}
|
||||
/>
|
||||
|
|
@ -125,32 +288,110 @@ export function SearchSidebar({ close }: Props) {
|
|||
))}
|
||||
</div>
|
||||
{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}`;
|
||||
}
|
||||
{state.type === "results" && (() => {
|
||||
const currentPageMessages = state.pages.get(state.currentPage) || [];
|
||||
return (
|
||||
<>
|
||||
<Overline type="subtle" block style={{ textAlign: 'center', marginTop: '12px' }}>
|
||||
{currentPageMessages.length > 0
|
||||
? currentPageMessages.length === 1 ? 'Result' : 'Results'
|
||||
: 'No Results'
|
||||
}
|
||||
</Overline>
|
||||
|
||||
{state.isLoadingPage ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||
<Preloader type="ring" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="list">
|
||||
{currentPageMessages.map((message, index) => {
|
||||
const messageChannel = client.channels.get(message.channel_id);
|
||||
const channelName = messageChannel?.name || "Unknown Channel";
|
||||
|
||||
// Check if this is the first message or if the channel changed from the previous message
|
||||
const showChannelIndicator = index === 0 ||
|
||||
message.channel_id !== currentPageMessages[index - 1].channel_id;
|
||||
|
||||
let href = "";
|
||||
if (messageChannel?.channel_type === "TextChannel") {
|
||||
href += `/server/${messageChannel.server_id}`;
|
||||
}
|
||||
href += `/channel/${message.channel_id}/${message._id}`;
|
||||
|
||||
href += `/channel/${message.channel_id}/${message._id}`;
|
||||
|
||||
return (
|
||||
<Link to={href} key={message._id}>
|
||||
<div className="message">
|
||||
<Message
|
||||
message={message}
|
||||
head
|
||||
hideReply
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<div key={message._id}>
|
||||
{showChannelIndicator && (
|
||||
<Overline type="subtle" block>
|
||||
# {channelName}
|
||||
</Overline>
|
||||
)}
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation with page count at the bottom - only show if there are results */}
|
||||
{currentPageMessages.length > 0 && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
justifyContent: 'center',
|
||||
margin: '16px 0 8px 0'
|
||||
}}>
|
||||
<Button
|
||||
compact
|
||||
palette="secondary"
|
||||
disabled={state.currentPage === 1 || state.isLoadingPage}
|
||||
onClick={goToPreviousPage}
|
||||
>
|
||||
← Back
|
||||
</Button>
|
||||
|
||||
<span style={{
|
||||
color: 'var(--tertiary-foreground)',
|
||||
fontSize: '13px',
|
||||
fontWeight: '500'
|
||||
}}>
|
||||
Page {state.currentPage}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
compact
|
||||
palette="secondary"
|
||||
disabled={!state.hasMore || state.isLoadingPage}
|
||||
onClick={goToNextPage}
|
||||
>
|
||||
Next →
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</SearchBase>
|
||||
</GenericSidebarList>
|
||||
</GenericSidebarBase>
|
||||
</SearchSidebarBase>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
import { useState } from "preact/hooks";
|
||||
import { User } from "revolt.js";
|
||||
import { useClient } from "../../controllers/client/ClientController";
|
||||
import { AutoCompleteState, SearchClues } from "../../components/common/AutoComplete";
|
||||
|
||||
export interface SearchAutoCompleteProps {
|
||||
state: AutoCompleteState;
|
||||
setState: (state: AutoCompleteState) => void;
|
||||
onKeyUp: (ev: KeyboardEvent) => void;
|
||||
onKeyDown: (ev: KeyboardEvent) => boolean;
|
||||
onChange: (ev: Event) => void;
|
||||
onClick: (userId: string, username: string) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
}
|
||||
|
||||
// Mapping of user IDs to usernames for display
|
||||
export interface UserMapping {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export function useSearchAutoComplete(
|
||||
setValue: (v: string) => void,
|
||||
userMappings: UserMapping,
|
||||
setUserMappings: (mappings: UserMapping) => void,
|
||||
): SearchAutoCompleteProps {
|
||||
const [state, setState] = useState<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:" (including hyphens)
|
||||
const fromMatch = beforeCursor.match(/\bfrom:(@?[\w-]*)$/);
|
||||
if (fromMatch) {
|
||||
// Check if there's already a username that continues after cursor
|
||||
const continuationMatch = afterCursor.match(/^([\w-]*)/);
|
||||
const fullUsername = fromMatch[1].replace('@', '') + (continuationMatch ? continuationMatch[1] : '');
|
||||
return ["user", fullUsername, fromMatch.index! + 5, "from"];
|
||||
}
|
||||
|
||||
const mentionsMatch = beforeCursor.match(/\bmentions:(@?[\w-]*)$/);
|
||||
if (mentionsMatch) {
|
||||
// Check if there's already a username that continues after cursor
|
||||
const continuationMatch = afterCursor.match(/^([\w-]*)/);
|
||||
const fullUsername = mentionsMatch[1].replace('@', '') + (continuationMatch ? continuationMatch[1] : '');
|
||||
return ["user", fullUsername, mentionsMatch.index! + 9, "mentions"];
|
||||
}
|
||||
|
||||
// Also check if cursor is inside an existing filter
|
||||
const lastFromIndex = beforeCursor.lastIndexOf("from:");
|
||||
const lastMentionsIndex = beforeCursor.lastIndexOf("mentions:");
|
||||
|
||||
if (lastFromIndex !== -1 || lastMentionsIndex !== -1) {
|
||||
const filterIndex = Math.max(lastFromIndex, lastMentionsIndex);
|
||||
const filterType = lastFromIndex > lastMentionsIndex ? "from" : "mentions";
|
||||
const filterLength = filterType === "from" ? 5 : 9;
|
||||
|
||||
// Check if we're still within this filter (no space between filter and cursor)
|
||||
const betweenFilterAndCursor = value.slice(filterIndex + filterLength, cursorPos);
|
||||
if (!betweenFilterAndCursor.includes(" ")) {
|
||||
// Get the username part (including hyphens)
|
||||
const afterFilter = value.slice(filterIndex + filterLength);
|
||||
const usernameMatch = afterFilter.match(/^@?([\w-]*)/);
|
||||
if (usernameMatch) {
|
||||
return ["user", usernameMatch[1] || "", filterIndex + filterLength, filterType];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function onChange(ev: Event) {
|
||||
const el = ev.target as HTMLInputElement;
|
||||
const cursorPos = el.selectionStart || 0;
|
||||
|
||||
const result = findSearchPattern(el.value, cursorPos);
|
||||
if (result) {
|
||||
const [type, search, , filterType] = result;
|
||||
const regex = new RegExp(search, "i");
|
||||
|
||||
if (type === "user") {
|
||||
// Get all users - in a real app, you might want to limit this
|
||||
// or use a specific search context
|
||||
let users = [...client.users.values()];
|
||||
|
||||
// Filter out system user
|
||||
users = users.filter(
|
||||
(x) => x._id !== "00000000000000000000000000"
|
||||
);
|
||||
|
||||
// Filter by search term
|
||||
let matches = (
|
||||
search.length > 0
|
||||
? users.filter((user) =>
|
||||
user.username.toLowerCase().match(regex)
|
||||
)
|
||||
: users
|
||||
)
|
||||
.slice(0, 5)
|
||||
.filter((x) => typeof x !== "undefined");
|
||||
|
||||
// Always show autocomplete when filter is typed, even with no matches
|
||||
const currentPosition =
|
||||
state.type !== "none" ? state.selected : 0;
|
||||
|
||||
setState({
|
||||
type: "user",
|
||||
matches,
|
||||
selected: Math.min(currentPosition, matches.length - 1),
|
||||
within: false,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (state.type !== "none") {
|
||||
setState({ type: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
function selectCurrent(el: HTMLInputElement) {
|
||||
if (state.type === "user") {
|
||||
const cursorPos = el.selectionStart || 0;
|
||||
const result = findSearchPattern(el.value, cursorPos);
|
||||
|
||||
if (result) {
|
||||
const [, search, startIndex, filterType] = result;
|
||||
const selectedUser = state.matches[state.selected];
|
||||
|
||||
// Store the mapping
|
||||
const newMappings = { ...userMappings };
|
||||
const mappingKey = `${filterType}:${selectedUser.username}`;
|
||||
newMappings[mappingKey] = selectedUser._id;
|
||||
setUserMappings(newMappings);
|
||||
|
||||
// Find the end of the current username (including @ if present and hyphens)
|
||||
let endIndex = startIndex;
|
||||
const afterStartIndex = el.value.slice(startIndex);
|
||||
const existingMatch = afterStartIndex.match(/^@?[\w-]*/);
|
||||
if (existingMatch) {
|
||||
endIndex = startIndex + existingMatch[0].length;
|
||||
}
|
||||
|
||||
// Replace the text with @username and add space
|
||||
const before = el.value.slice(0, startIndex);
|
||||
const after = el.value.slice(endIndex);
|
||||
setValue(before + "@" + selectedUser.username + " " + after);
|
||||
|
||||
// Set cursor position after the @username and space
|
||||
setTimeout(() => {
|
||||
el.setSelectionRange(
|
||||
startIndex + selectedUser.username.length + 2, // +1 for @, +1 for space
|
||||
startIndex + selectedUser.username.length + 2
|
||||
);
|
||||
}, 0);
|
||||
|
||||
setState({ type: "none" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(userId: string, username: string) {
|
||||
const el = document.querySelector('input[type="text"]') as HTMLInputElement;
|
||||
if (el && state.type === "user") {
|
||||
// Find which user was clicked
|
||||
const clickedIndex = state.matches.findIndex(u => u._id === userId);
|
||||
if (clickedIndex !== -1) {
|
||||
setState({ ...state, selected: clickedIndex });
|
||||
// Use setTimeout to ensure state is updated before selection
|
||||
setTimeout(() => selectCurrent(el), 0);
|
||||
}
|
||||
}
|
||||
setFocused(false);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (focused && state.type !== "none") {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (state.selected > 0) {
|
||||
setState({
|
||||
...state,
|
||||
selected: state.selected - 1,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (state.selected < state.matches.length - 1) {
|
||||
setState({
|
||||
...state,
|
||||
selected: state.selected + 1,
|
||||
});
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
if (state.matches.length > 0) {
|
||||
selectCurrent(e.currentTarget as HTMLInputElement);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setState({ type: "none" });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (e.currentTarget !== null) {
|
||||
onChange(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onFocus() {
|
||||
setFocused(true);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (state.type !== "none" && state.within) return;
|
||||
setFocused(false);
|
||||
}
|
||||
|
||||
return {
|
||||
state: focused ? state : { type: "none" },
|
||||
setState,
|
||||
onClick,
|
||||
onChange,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform display query with usernames to API query with user IDs
|
||||
export function transformSearchQuery(
|
||||
displayQuery: string,
|
||||
userMappings: UserMapping
|
||||
): { query: string; author?: string; mention?: string; date_start?: string; date_end?: string; has?: string; server_wide?: boolean } {
|
||||
let query = displayQuery;
|
||||
let author: string | undefined;
|
||||
let mention: string | undefined;
|
||||
let date_start: string | undefined;
|
||||
let date_end: string | undefined;
|
||||
let has: string | undefined;
|
||||
let server_wide: boolean | undefined;
|
||||
|
||||
// Extract and replace from:@username with user ID (including hyphens)
|
||||
const fromMatch = query.match(/\bfrom:@?([\w-]+)/);
|
||||
if (fromMatch) {
|
||||
const username = fromMatch[1];
|
||||
const userId = userMappings[`from:${username}`];
|
||||
if (userId) {
|
||||
author = userId;
|
||||
// Remove from:@username from query
|
||||
query = query.replace(fromMatch[0], "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract and replace mentions:@username with user ID (including hyphens)
|
||||
const mentionsMatch = query.match(/\bmentions:@?([\w-]+)/);
|
||||
if (mentionsMatch) {
|
||||
const username = mentionsMatch[1];
|
||||
const userId = userMappings[`mentions:${username}`];
|
||||
if (userId) {
|
||||
mention = userId;
|
||||
// Remove mentions:@username from query
|
||||
query = query.replace(mentionsMatch[0], "").trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Extract date filters (YYYY-MM-DD format) and convert to ISO 8601
|
||||
// Using the new standardized date_start and date_end approach
|
||||
const beforeMatch = query.match(/\bbefore:(\d{4}-\d{2}-\d{2})/);
|
||||
if (beforeMatch) {
|
||||
// "before" means before the START of this day
|
||||
const [year, month, day] = beforeMatch[1].split('-').map(Number);
|
||||
const startOfDay = new Date(year, month - 1, day);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
date_end = startOfDay.toISOString();
|
||||
query = query.replace(beforeMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const afterMatch = query.match(/\bafter:(\d{4}-\d{2}-\d{2})/);
|
||||
if (afterMatch) {
|
||||
// "after" means after the END of this day
|
||||
const [year, month, day] = afterMatch[1].split('-').map(Number);
|
||||
const endOfDay = new Date(year, month - 1, day);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
date_start = endOfDay.toISOString();
|
||||
query = query.replace(afterMatch[0], "").trim();
|
||||
}
|
||||
|
||||
const duringMatch = query.match(/\bduring:(\d{4}-\d{2}-\d{2})/);
|
||||
if (duringMatch) {
|
||||
// For 'during', capture the full day from start to end
|
||||
const [year, month, day] = duringMatch[1].split('-').map(Number);
|
||||
|
||||
const startOfDay = new Date(year, month - 1, day);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(year, month - 1, day);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
date_start = startOfDay.toISOString();
|
||||
date_end = endOfDay.toISOString();
|
||||
|
||||
query = query.replace(duringMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Extract between date range filter (between:YYYY-MM-DD..YYYY-MM-DD)
|
||||
const betweenMatch = query.match(/\bbetween:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/);
|
||||
if (betweenMatch) {
|
||||
// Start date: from the START of the first day
|
||||
const [startYear, startMonth, startDay] = betweenMatch[1].split('-').map(Number);
|
||||
const startOfFirstDay = new Date(startYear, startMonth - 1, startDay);
|
||||
startOfFirstDay.setHours(0, 0, 0, 0);
|
||||
date_start = startOfFirstDay.toISOString();
|
||||
|
||||
// End date: to the END of the last day
|
||||
const [endYear, endMonth, endDay] = betweenMatch[2].split('-').map(Number);
|
||||
const endOfLastDay = new Date(endYear, endMonth - 1, endDay);
|
||||
endOfLastDay.setHours(23, 59, 59, 999);
|
||||
date_end = endOfLastDay.toISOString();
|
||||
|
||||
query = query.replace(betweenMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Extract has: filter for attachment types
|
||||
const hasMatch = query.match(/\bhas:(video|image|link|audio|file)/i);
|
||||
if (hasMatch) {
|
||||
has = hasMatch[1].toLowerCase();
|
||||
query = query.replace(hasMatch[0], "").trim();
|
||||
}
|
||||
|
||||
// Check for server-wide flag
|
||||
if (query.includes("server-wide")) {
|
||||
server_wide = true;
|
||||
query = query.replace(/\bserver-wide\b/g, "").trim();
|
||||
}
|
||||
|
||||
return { query, author, mention, date_start, date_end, has, server_wide };
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
Loading…
Reference in New Issue