pull/1154/head
abron 2025-08-26 15:56:26 +03:30
commit 5d9e49bf11
8 changed files with 2320 additions and 128 deletions

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,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

View File

@ -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>
);
}

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";
@ -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>
);
}
}

View File

@ -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 };
}

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>
</>

View File

@ -49,6 +49,7 @@ interface Server {
inviteCode: string;
disabled: boolean;
new: boolean;
showcolor: string;
sortorder: number;
}
@ -67,6 +68,16 @@ const NewServerWrapper = styled.div`
}
`;
// Dynamic color wrapper component
const ColorWrapper = styled.div<{ color: string }>`
color: ${props => props.color};
display: contents;
a {
color: ${props => props.color};
}
`;
const CACHE_KEY = "server_list_cache";
const CACHE_DURATION = 1 * 60 * 1000; // 1 minutes in milliseconds
@ -112,8 +123,8 @@ const Home: React.FC = () => {
const fetchAndCacheData = async () => {
try {
const csvUrl =
"https://docs.google.com/spreadsheets/d/1kNF50scEUJVJ9KD-0_ibX43vJiOzdHrmgauLoSoBy34/export?format=csv&gid=0";
//"https://docs.google.com/spreadsheets/d/e/2PACX-1vRY41D-NgTE6bC3kTN3dRpisI-DoeHG8Eg7n31xb1CdydWjOLaphqYckkTiaG9oIQSWP92h3NE-7cpF/pub?gid=0&single=true&output=csv";
"https://docs.google.com/spreadsheets/d/1kNF50scEUJVJ9KD-0_ibX43vJiOzdHrmgauLoSoBy34/edit?single=true&output=csv&gid=0#gid=0";
// Add cache-busting parameter to prevent browser caching
const urlWithCacheBust = `${csvUrl}&_cb=${Date.now()}`;
@ -225,11 +236,13 @@ const Home: React.FC = () => {
<Link to={linkTo}>{buttonContent}</Link>
);
return server.new ? (
<NewServerWrapper>{content}</NewServerWrapper>
) : (
content
);
if (server.showcolor && server.showcolor.trim()) {
content = <ColorWrapper color={server.showcolor}>{content}</ColorWrapper>;
} else if (server.new) {
content = <NewServerWrapper>{content}</NewServerWrapper>;
}
return content;
};
if (loading) {