diff --git a/external/revolt.js b/external/revolt.js
index 4f711d53..31c167ed 160000
--- a/external/revolt.js
+++ b/external/revolt.js
@@ -1 +1 @@
-Subproject commit 4f711d531ec396b0b021be0d5a538223a1b0671f
+Subproject commit 31c167ed78cc9187ecb324eba90f059fafcc28a9
diff --git a/src/components/navigation/SearchAutoComplete.tsx b/src/components/navigation/SearchAutoComplete.tsx
index ffa85871..bf8195b9 100644
--- a/src/components/navigation/SearchAutoComplete.tsx
+++ b/src/components/navigation/SearchAutoComplete.tsx
@@ -16,6 +16,12 @@ const Base = styled.div`
overflow: hidden;
max-height: 200px;
overflow-y: auto;
+
+ @media (max-width: 768px) {
+ margin-top: 8px;
+ max-height: 250px;
+ border-radius: 10px;
+ }
button {
width: 100%;
@@ -43,6 +49,18 @@ const Base = styled.div`
text-overflow: ellipsis;
white-space: nowrap;
}
+
+ @media (max-width: 768px) {
+ padding: 14px 16px;
+ font-size: 15px;
+ gap: 12px;
+
+ /* Add touch feedback for mobile */
+ &:active {
+ background: var(--secondary-background);
+ transform: scale(0.98);
+ }
+ }
}
`;
@@ -54,6 +72,10 @@ interface Props {
export default function SearchAutoComplete({ state, setState, onClick }: Props) {
if (state.type !== "user") return null;
+
+ // Detect if we're on mobile
+ const isMobile = window.innerWidth <= 768;
+ const iconSize = isMobile ? 24 : 20;
return (
@@ -82,16 +104,16 @@ export default function SearchAutoComplete({ state, setState, onClick }: Props)
e.stopPropagation();
onClick(user._id, user.username);
}}>
-
+
{user.username}
))
) : (
No users found
diff --git a/src/components/navigation/SearchBar.tsx b/src/components/navigation/SearchBar.tsx
index 63cb32f0..7e0b6659 100644
--- a/src/components/navigation/SearchBar.tsx
+++ b/src/components/navigation/SearchBar.tsx
@@ -7,6 +7,7 @@ import { internalEmit } from "../../lib/eventEmitter";
import { useSearchAutoComplete, transformSearchQuery, UserMapping } from "../../lib/hooks/useSearchAutoComplete";
import SearchAutoComplete from "./SearchAutoComplete";
import SearchDatePicker from "./SearchDatePicker";
+import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
const Container = styled.div`
position: relative;
@@ -28,25 +29,42 @@ const Input = styled.input`
background: transparent;
color: var(--foreground);
font-size: 14px;
- padding: 6px 2px 6px 12px;
+ padding: 6px 2px 6px 8px; /* Reduced left padding for more space */
outline: none;
+ min-width: 0; /* Allow input to shrink properly */
&::placeholder {
color: var(--tertiary-foreground);
+ font-size: 13px; /* Slightly smaller placeholder on mobile */
+ }
+
+ @media (max-width: 768px) {
+ font-size: 13px;
+ padding: 6px 2px 6px 6px;
}
`;
const IconButton = styled.div`
display: flex;
align-items: center;
- padding: 0 12px 0 8px;
+ padding: 0 8px; /* Symmetrical padding */
color: var(--tertiary-foreground);
cursor: pointer;
transition: color 0.1s ease;
+ flex-shrink: 0; /* Prevent icon from shrinking */
&:hover {
color: var(--foreground);
}
+
+ @media (max-width: 768px) {
+ padding: 0 6px; /* Less padding on mobile */
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+ }
`;
const OptionsDropdown = styled.div`
@@ -61,6 +79,29 @@ const OptionsDropdown = styled.div`
overflow: hidden;
padding: 8px;
min-width: 300px;
+
+ @media (max-width: 768px) {
+ padding: 12px;
+ min-width: 320px;
+ max-width: calc(100vw - 20px);
+ right: -10px; /* Adjust position to prevent edge cutoff */
+ max-height: 40vh; /* Limit height when keyboard is up */
+ overflow-y: auto; /* Make it scrollable */
+
+ /* Add scrollbar styles for mobile */
+ &::-webkit-scrollbar {
+ width: 4px;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: var(--scrollbar-thumb);
+ border-radius: 2px;
+ }
+ }
`;
const OptionsHeader = styled.div`
@@ -70,6 +111,11 @@ const OptionsHeader = styled.div`
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
+
+ @media (max-width: 768px) {
+ font-size: 11px;
+ padding: 0 8px 8px 8px;
+ }
`;
const Option = styled.div`
@@ -88,6 +134,18 @@ const Option = styled.div`
&:last-child {
margin-bottom: 0;
}
+
+ @media (max-width: 768px) {
+ padding: 10px 10px;
+ margin-bottom: 3px;
+ border-radius: 6px;
+
+ /* Add touch feedback for mobile */
+ &:active {
+ background: var(--secondary-background);
+ transform: scale(0.98);
+ }
+ }
`;
const OptionLabel = styled.span`
@@ -97,12 +155,21 @@ const OptionLabel = styled.span`
font-family: var(--monospace-font), monospace;
font-size: 13px;
white-space: nowrap;
+
+ @media (max-width: 768px) {
+ font-size: 13px;
+ margin-right: 10px;
+ }
`;
const OptionDesc = styled.span`
color: var(--tertiary-foreground);
font-size: 13px;
flex: 1;
+
+ @media (max-width: 768px) {
+ font-size: 12px;
+ }
`;
const HelpIcon = styled(HelpCircle)`
@@ -149,10 +216,20 @@ const searchOptions: SearchOption[] = [
description: "specific date",
tooltip: "Messages after this date"
},
+ {
+ label: "between:",
+ description: "date range",
+ tooltip: "Messages between two dates"
+ },
{
label: "server-wide",
description: "Entire server",
tooltip: "Search in entire server instead of just this channel"
+ },
+ {
+ label: "has:",
+ description: "video, image, link, audio, file",
+ tooltip: "Messages with specific attachment types"
}
];
@@ -161,7 +238,14 @@ export function SearchBar() {
const [showOptions, setShowOptions] = useState(false);
const [isSearching, setIsSearching] = useState(false);
const [userMappings, setUserMappings] = useState({});
- const [showDatePicker, setShowDatePicker] = useState<"before" | "after" | "during" | null>(null);
+ const [showDatePicker, setShowDatePicker] = useState<"before" | "after" | "during" | "between" | null>(null);
+ const [showAttachmentTypes, setShowAttachmentTypes] = useState(false);
+ const [activeDateRange, setActiveDateRange] = useState<{
+ start: string;
+ end: string;
+ startDisplay: string;
+ endDisplay: string;
+ } | null>(null);
const inputRef = useRef(null);
// Setup autocomplete
@@ -218,28 +302,49 @@ export function SearchBar() {
setQuery(value);
- // Check for date filters
+ // Check for filters
const beforeCursor = value.slice(0, cursorPos);
const beforeMatch = beforeCursor.match(/\bbefore:\s*$/);
const afterMatch = beforeCursor.match(/\bafter:\s*$/);
const duringMatch = beforeCursor.match(/\bduring:\s*$/);
+ const betweenMatch = beforeCursor.match(/\bbetween:\s*$/);
+ const hasMatch = beforeCursor.match(/\bhas:\s*$/);
if (beforeMatch) {
setShowDatePicker("before");
setShowOptions(false);
+ setShowAttachmentTypes(false);
setAutocompleteState({ type: "none" });
} else if (afterMatch) {
setShowDatePicker("after");
setShowOptions(false);
+ setShowAttachmentTypes(false);
setAutocompleteState({ type: "none" });
} else if (duringMatch) {
setShowDatePicker("during");
setShowOptions(false);
+ setShowAttachmentTypes(false);
+ setAutocompleteState({ type: "none" });
+ } else if (betweenMatch) {
+ setShowDatePicker("between");
+ setShowOptions(false);
+ setShowAttachmentTypes(false);
+ setAutocompleteState({ type: "none" });
+ } else if (hasMatch) {
+ // Show attachment type options
+ setShowAttachmentTypes(true);
+ setShowOptions(false);
+ setShowDatePicker(null);
setAutocompleteState({ type: "none" });
} else {
- // Only trigger autocomplete if no date filter is active
- const dateFilterActive = value.match(/\b(before:|after:|during:)\s*$/);
- if (!dateFilterActive) {
+ // Check if "has:" was removed and close attachment types dropdown
+ if (showAttachmentTypes && !value.includes("has:")) {
+ setShowAttachmentTypes(false);
+ }
+
+ // Only trigger autocomplete if no filter is active
+ const filterActive = value.match(/\b(before:|after:|during:|between:|has:)\s*$/);
+ if (!filterActive) {
onAutocompleteChange(e);
if (showDatePicker) {
setShowDatePicker(null);
@@ -249,28 +354,37 @@ export function SearchBar() {
};
const handleSearch = () => {
- const trimmedQuery = query.trim();
+ let trimmedQuery = query.trim();
- // Check for incomplete filters (only user filters, not date filters)
+ // Check for incomplete filters
const hasIncompleteUserFilter = trimmedQuery.match(/\b(from:|mentions:)\s*$/);
- const hasIncompleteDateFilter = trimmedQuery.match(/\b(before:|after:|during:)\s*$/);
+ const hasIncompleteDateFilter = trimmedQuery.match(/\b(before:|after:|during:|between:)\s*$/);
+ const hasIncompleteHasFilter = trimmedQuery.match(/\bhas:\s*$/);
- if (hasIncompleteUserFilter || hasIncompleteDateFilter) {
+ if (hasIncompleteUserFilter || hasIncompleteDateFilter || hasIncompleteHasFilter) {
// Don't search if there's an incomplete filter
return;
}
+ // Check if we have "date-range" in the query and replace it with actual dates
+ if (trimmedQuery.includes("date-range") && activeDateRange) {
+ // Replace "date-range" with the actual between filter for processing
+ trimmedQuery = trimmedQuery.replace(/\bdate-range\b/, `between:${activeDateRange.start}..${activeDateRange.end}`);
+ }
+
// Transform query to use user IDs
const searchParams = transformSearchQuery(trimmedQuery, userMappings);
// Check if we have any search criteria (query text or filters)
- const hasSearchCriteria = searchParams.query ||
- searchParams.author ||
- searchParams.mention ||
- searchParams.before_date ||
- searchParams.after_date ||
- searchParams.during ||
- searchParams.server_wide;
+ // Allow empty query string if filters are present
+ const hasFilters = searchParams.author ||
+ searchParams.mention ||
+ searchParams.date_start ||
+ searchParams.date_end ||
+ searchParams.has ||
+ searchParams.server_wide;
+
+ const hasSearchCriteria = (searchParams.query && searchParams.query.trim() !== "") || hasFilters;
if (hasSearchCriteria) {
// Open search in right sidebar with transformed query
@@ -279,6 +393,17 @@ export function SearchBar() {
setIsSearching(true);
setAutocompleteState({ type: "none" });
inputRef.current?.blur();
+
+ // On mobile, automatically slide to show search results
+ if (isTouchscreenDevice) {
+ setTimeout(() => {
+ const panels = document.querySelector("#app > div > div > div");
+ panels?.scrollTo({
+ behavior: "smooth",
+ left: panels.clientWidth * 3,
+ });
+ }, 100); // Small delay to ensure sidebar is opened first
+ }
}
};
@@ -286,13 +411,173 @@ export function SearchBar() {
const currentValue = (e.currentTarget as HTMLInputElement).value;
const cursorPos = (e.currentTarget as HTMLInputElement).selectionStart || 0;
- // Handle backspace/delete for server-wide
+ // Handle backspace/delete for date filters and server-wide
if (e.key === "Backspace" || e.key === "Delete") {
const beforeCursor = currentValue.slice(0, cursorPos);
const afterCursor = currentValue.slice(cursorPos);
- // Check if we're at the end of "server-wide" or within it
+ // Check for date filters with backspace
if (e.key === "Backspace") {
+ // Handle single date filters (before:, after:, during:)
+ const singleDateMatch = beforeCursor.match(/\b(before|after|during):(\d{4}-\d{2}-\d{2})\s*$/);
+ if (singleDateMatch) {
+ e.preventDefault();
+ const filterStart = singleDateMatch.index!;
+
+ // Remove the entire filter and date
+ const newValue = currentValue.slice(0, filterStart) + afterCursor;
+ setQuery(newValue);
+
+ // Position cursor where filter was
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(filterStart, filterStart);
+ }
+ }, 0);
+ return;
+ }
+
+ // Handle filter-only patterns (e.g., "before:" without date)
+ const filterOnlyMatch = beforeCursor.match(/\b(before|after|during|between):\s*$/);
+ if (filterOnlyMatch) {
+ e.preventDefault();
+ const filterStart = filterOnlyMatch.index!;
+
+ // Remove the entire filter
+ const newValue = currentValue.slice(0, filterStart) + afterCursor;
+ setQuery(newValue);
+
+ // Close date picker if open
+ setShowDatePicker(null);
+
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(filterStart, filterStart);
+ }
+ }, 0);
+ return;
+ }
+
+ // Handle between date range filter
+ const betweenMatch = beforeCursor.match(/\bbetween:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})\s*$/);
+ if (betweenMatch) {
+ e.preventDefault();
+ const filterStart = betweenMatch.index!;
+ const startDate = betweenMatch[1];
+
+ // Remove end date but keep "between:YYYY-MM-DD.."
+ const newValue = currentValue.slice(0, filterStart) + "between:" + startDate + ".." + afterCursor;
+ setQuery(newValue);
+
+ const newCursorPos = filterStart + "between:".length + startDate.length + 2;
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(newCursorPos, newCursorPos);
+ }
+ }, 0);
+ return;
+ }
+
+ // Handle partial between filter (between:YYYY-MM-DD..)
+ const partialBetweenMatch = beforeCursor.match(/\bbetween:(\d{4}-\d{2}-\d{2})\.\.\s*$/);
+ if (partialBetweenMatch) {
+ e.preventDefault();
+ const filterStart = partialBetweenMatch.index!;
+ const startDate = partialBetweenMatch[1];
+
+ // Remove ".." to get "between:YYYY-MM-DD"
+ const newValue = currentValue.slice(0, filterStart) + "between:" + startDate + afterCursor;
+ setQuery(newValue);
+
+ const newCursorPos = filterStart + "between:".length + startDate.length;
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(newCursorPos, newCursorPos);
+ }
+ }, 0);
+ return;
+ }
+
+ // Handle between with only start date (between:YYYY-MM-DD)
+ const betweenStartOnlyMatch = beforeCursor.match(/\bbetween:(\d{4}-\d{2}-\d{2})\s*$/);
+ if (betweenStartOnlyMatch) {
+ e.preventDefault();
+ const filterStart = betweenStartOnlyMatch.index!;
+
+ // Remove date but keep "between:"
+ const newValue = currentValue.slice(0, filterStart) + "between:" + afterCursor;
+ setQuery(newValue);
+
+ const newCursorPos = filterStart + "between:".length;
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(newCursorPos, newCursorPos);
+ // Open the date picker in between mode
+ setShowDatePicker("between");
+ setShowOptions(false);
+ }
+ }, 0);
+ return;
+ }
+
+ // Handle date-range keyword removal
+ const dateRangeMatch = beforeCursor.match(/\bdate-range\s*$/);
+ if (dateRangeMatch) {
+ e.preventDefault();
+ const filterStart = dateRangeMatch.index!;
+
+ // Remove the keyword and clear the stored date range
+ const newValue = currentValue.slice(0, filterStart) + afterCursor;
+ setQuery(newValue);
+ setActiveDateRange(null);
+
+ // Position cursor where filter was
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(filterStart, filterStart);
+ }
+ }, 0);
+ return;
+ }
+
+ // Handle has: filter removal
+ const hasMatch = beforeCursor.match(/\bhas:(video|image|link|audio|file)\s*$/);
+ if (hasMatch) {
+ e.preventDefault();
+ const filterStart = hasMatch.index!;
+
+ // Remove the entire filter
+ const newValue = currentValue.slice(0, filterStart) + afterCursor;
+ setQuery(newValue);
+
+ // Position cursor where filter was
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(filterStart, filterStart);
+ }
+ }, 0);
+ return;
+ }
+
+ // Handle has: without value
+ const hasOnlyMatch = beforeCursor.match(/\bhas:\s*$/);
+ if (hasOnlyMatch) {
+ e.preventDefault();
+ const filterStart = hasOnlyMatch.index!;
+
+ // Remove the entire filter
+ const newValue = currentValue.slice(0, filterStart) + afterCursor;
+ setQuery(newValue);
+
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(filterStart, filterStart);
+ }
+ }, 0);
+ return;
+ }
+
+ // Original server-wide handling
const serverWideMatch = beforeCursor.match(/\bserver-wide\s*$/);
if (serverWideMatch) {
e.preventDefault();
@@ -307,7 +592,42 @@ export function SearchBar() {
return;
}
} else if (e.key === "Delete") {
- // Check if cursor is at the beginning of server-wide
+ // Handle date filters with delete key
+ // Check if we're at a filter: position
+ const filterMatch = afterCursor.match(/^(before|after|during|between):(\d{4}-\d{2}-\d{2})?/);
+ if (filterMatch && beforeCursor.match(/\s$|^$/)) {
+ e.preventDefault();
+ const filterType = filterMatch[1];
+ const hasDate = filterMatch[2];
+
+ if (hasDate) {
+ // Remove entire filter and date
+ const newValue = beforeCursor + afterCursor.slice(filterMatch[0].length).trimStart();
+ setQuery(newValue);
+ } else {
+ // Just filter: without date, remove it
+ const newValue = beforeCursor + afterCursor.slice(filterType.length + 1).trimStart();
+ setQuery(newValue);
+ }
+
+ setTimeout(() => {
+ if (inputRef.current) {
+ inputRef.current.setSelectionRange(beforeCursor.length, beforeCursor.length);
+ }
+ }, 0);
+ return;
+ }
+
+ // Handle has: filter with delete key
+ const hasAfter = afterCursor.match(/^has:(video|image|link|audio|file)?\s*/);
+ if (hasAfter) {
+ e.preventDefault();
+ const newValue = beforeCursor + afterCursor.slice(hasAfter[0].length);
+ setQuery(newValue);
+ return;
+ }
+
+ // Original server-wide handling
const serverWideAfter = afterCursor.match(/^server-wide\s*/);
if (serverWideAfter) {
e.preventDefault();
@@ -377,9 +697,24 @@ export function SearchBar() {
const handleOptionClick = (option: SearchOption) => {
// If it's a date filter, just show the date picker without adding text
- if (option.label === "before:" || option.label === "after:" || option.label === "during:") {
- setShowDatePicker(option.label.slice(0, -1) as "before" | "after" | "during");
+ if (option.label === "before:" || option.label === "after:" || option.label === "during:" || option.label === "between:") {
+ setShowDatePicker(option.label.slice(0, -1) as "before" | "after" | "during" | "between");
setShowOptions(false);
+ } else if (option.label === "has:") {
+ // For has: filter, add it and show attachment type options
+ const newQuery = query + (query ? " " : "") + "has:";
+ setQuery(newQuery);
+ setShowOptions(false);
+ setShowAttachmentTypes(true);
+
+ // Move cursor after "has:"
+ setTimeout(() => {
+ if (inputRef.current) {
+ const endPos = newQuery.length;
+ inputRef.current.setSelectionRange(endPos, endPos);
+ inputRef.current.focus();
+ }
+ }, 0);
} else if (option.label === "server-wide") {
// For server-wide, add it as a standalone filter with auto-space
const newQuery = query + (query ? " " : "") + "server-wide ";
@@ -402,8 +737,32 @@ export function SearchBar() {
}
};
+ const handleAttachmentTypeClick = (type: string) => {
+ // Add the attachment type to the query
+ const newQuery = query + type + " ";
+ setQuery(newQuery);
+ setShowAttachmentTypes(false);
+
+ // Move cursor to end and focus
+ setTimeout(() => {
+ if (inputRef.current) {
+ const endPos = newQuery.length;
+ inputRef.current.setSelectionRange(endPos, endPos);
+ inputRef.current.focus();
+ }
+ }, 0);
+ };
+
+ // Format date to YYYY-MM-DD in local timezone
+ const formatLocalDate = (date: Date) => {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+ };
+
const handleDateSelect = (date: Date) => {
- const dateStr = date.toISOString().split('T')[0]; // Format as YYYY-MM-DD for display
+ const dateStr = formatLocalDate(date); // Use local date formatting
// Add the filter and date to the query with auto-space
const filterText = `${showDatePicker}:${dateStr} `;
@@ -422,6 +781,35 @@ export function SearchBar() {
}, 0);
};
+ const handleRangeSelect = (startDate: Date, endDate: Date) => {
+ const startStr = formatLocalDate(startDate);
+ const endStr = formatLocalDate(endDate);
+
+ // Store the actual date range in state
+ setActiveDateRange({
+ start: startStr,
+ end: endStr,
+ startDisplay: startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }),
+ endDisplay: endDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
+ });
+
+ // Only add "date-range" to the query, not the actual dates
+ const filterText = `date-range `;
+ const newQuery = query + (query ? " " : "") + filterText;
+
+ setQuery(newQuery);
+ setShowDatePicker(null);
+
+ // Move cursor to end after the space
+ setTimeout(() => {
+ if (inputRef.current) {
+ const endPos = newQuery.length;
+ inputRef.current.setSelectionRange(endPos, endPos);
+ inputRef.current.focus();
+ }
+ }, 0);
+ };
+
// Global keyboard shortcut
useEffect(() => {
const handleGlobalKeydown = (e: KeyboardEvent) => {
@@ -435,21 +823,22 @@ export function SearchBar() {
return () => window.removeEventListener("keydown", handleGlobalKeydown);
}, []);
- // Close date picker when clicking outside
+ // Close date picker or attachment types when clicking outside
useEffect(() => {
- if (showDatePicker) {
+ if (showDatePicker || showAttachmentTypes) {
const handleClickOutside = (e: MouseEvent) => {
// Check if click is outside the container
const container = e.target as HTMLElement;
if (!container.closest('[data-search-container]') && !container.closest('[data-date-picker]')) {
setShowDatePicker(null);
+ setShowAttachmentTypes(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}
- }, [showDatePicker]);
+ }, [showDatePicker, showAttachmentTypes]);
return (
e.stopPropagation()}>
@@ -481,7 +870,7 @@ export function SearchBar() {
onClick={onAutocompleteClick}
/>
)}
- {showOptions && autocompleteState.type === "none" && !showDatePicker && (
+ {showOptions && autocompleteState.type === "none" && !showDatePicker && !showAttachmentTypes && (
e.stopPropagation()}>
{"Search Options"}
{searchOptions.map((option) => (
@@ -501,9 +890,35 @@ export function SearchBar() {
{showDatePicker && (
)}
+ {showAttachmentTypes && (
+ e.stopPropagation()}>
+ Attachment Types
+
+
+
+
+
+
+ )}
);
}
\ No newline at end of file
diff --git a/src/components/navigation/SearchDatePicker.tsx b/src/components/navigation/SearchDatePicker.tsx
index 590fb3ae..47f68dac 100644
--- a/src/components/navigation/SearchDatePicker.tsx
+++ b/src/components/navigation/SearchDatePicker.tsx
@@ -74,16 +74,34 @@ const Days = styled.div`
gap: 4px;
`;
-const Day = styled.button<{ isToday?: boolean; isSelected?: boolean; isOtherMonth?: boolean }>`
- background: ${props => props.isSelected ? 'var(--accent)' : 'transparent'};
+const Day = styled.button<{
+ isToday?: boolean;
+ isSelected?: boolean;
+ isOtherMonth?: boolean;
+ isRangeStart?: boolean;
+ isRangeEnd?: boolean;
+ isInRange?: boolean;
+ isInHoverRange?: boolean;
+}>`
+ background: ${props => {
+ if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent)';
+ if (props.isInRange) return 'var(--accent-disabled)';
+ if (props.isInHoverRange) return 'var(--hover)';
+ return 'transparent';
+ }};
color: ${props => {
- if (props.isSelected) return 'var(--accent-contrast)';
+ if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent-contrast)';
if (props.isOtherMonth) return 'var(--tertiary-foreground)';
return 'var(--foreground)';
}};
border: none;
padding: 8px;
- border-radius: 4px;
+ border-radius: ${props => {
+ if (props.isRangeStart || (props.isInHoverRange && props.isOtherMonth === false && !props.isRangeEnd)) return '4px 0 0 4px';
+ if (props.isRangeEnd || (props.isInHoverRange && props.isOtherMonth === false && !props.isRangeStart)) return '0 4px 4px 0';
+ if (props.isInRange || props.isInHoverRange) return '0';
+ return '4px';
+ }};
cursor: pointer;
font-size: 13px;
transition: all 0.1s ease;
@@ -99,12 +117,15 @@ const Day = styled.button<{ isToday?: boolean; isSelected?: boolean; isOtherMont
width: 4px;
height: 4px;
border-radius: 50%;
- background: var(--accent);
+ background: ${props.isSelected || props.isRangeStart || props.isRangeEnd ? 'var(--accent-contrast)' : 'var(--accent)'};
}
`}
&:hover {
- background: ${props => props.isSelected ? 'var(--accent)' : 'var(--secondary-background)'};
+ background: ${props => {
+ if (props.isSelected || props.isRangeStart || props.isRangeEnd) return 'var(--accent)';
+ return 'var(--secondary-background)';
+ }};
}
&:disabled {
@@ -121,6 +142,22 @@ const QuickSelects = styled.div`
border-top: 1px solid var(--secondary-background);
`;
+const RangePreview = styled.div`
+ margin: 12px 0;
+ padding: 8px 12px;
+ background: var(--secondary-background);
+ border-radius: 4px;
+ font-size: 13px;
+ color: var(--foreground);
+ text-align: center;
+
+ .hint {
+ color: var(--tertiary-foreground);
+ font-size: 12px;
+ margin-top: 4px;
+ }
+`;
+
const QuickSelect = styled.button`
background: transparent;
border: 1px solid var(--secondary-background);
@@ -139,12 +176,16 @@ const QuickSelect = styled.button`
interface Props {
onSelect: (date: Date) => void;
- filterType: "before" | "after" | "during";
+ onRangeSelect?: (startDate: Date, endDate: Date) => void;
+ filterType: "before" | "after" | "during" | "between";
}
-export default function SearchDatePicker({ onSelect, filterType }: Props) {
+export default function SearchDatePicker({ onSelect, onRangeSelect, filterType }: Props) {
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState(null);
+ const [rangeStart, setRangeStart] = useState(null);
+ const [rangeEnd, setRangeEnd] = useState(null);
+ const [hoverDate, setHoverDate] = useState(null);
const monthNames = ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"];
@@ -199,9 +240,43 @@ export default function SearchDatePicker({ onSelect, filterType }: Props) {
return date1.toDateString() === date2.toDateString();
};
+ const isDateInRange = (date: Date) => {
+ if (!rangeStart || !rangeEnd) return false;
+ return date >= rangeStart && date <= rangeEnd;
+ };
+
+ const isDateInHoverRange = (date: Date) => {
+ if (filterType !== "between" || !rangeStart || rangeEnd || !hoverDate) return false;
+ const start = rangeStart < hoverDate ? rangeStart : hoverDate;
+ const end = rangeStart < hoverDate ? hoverDate : rangeStart;
+ return date >= start && date <= end;
+ };
+
const handleDateSelect = (date: Date) => {
- setSelectedDate(date);
- onSelect(date);
+ if (filterType === "between") {
+ if (!rangeStart || (rangeStart && rangeEnd)) {
+ // First click or reset
+ setRangeStart(date);
+ setRangeEnd(null);
+ } else {
+ // Second click
+ if (date < rangeStart) {
+ setRangeEnd(rangeStart);
+ setRangeStart(date);
+ } else {
+ setRangeEnd(date);
+ }
+ // Call the callback with both dates
+ if (onRangeSelect) {
+ const start = date < rangeStart ? date : rangeStart;
+ const end = date < rangeStart ? rangeStart : date;
+ onRangeSelect(start, end);
+ }
+ }
+ } else {
+ setSelectedDate(date);
+ onSelect(date);
+ }
};
const handleQuickSelect = (option: string) => {
@@ -246,7 +321,10 @@ export default function SearchDatePicker({ onSelect, filterType }: Props) {
- {monthNames[currentMonth.getMonth()]} {currentMonth.getFullYear()}
+ {filterType === "between" && rangeStart && !rangeEnd
+ ? "Select end date"
+ : `${monthNames[currentMonth.getMonth()]} ${currentMonth.getFullYear()}`
+ }
navigateMonth(1)}>
@@ -264,8 +342,14 @@ export default function SearchDatePicker({ onSelect, filterType }: Props) {
handleDateSelect(day.date)}
+ onMouseEnter={() => setHoverDate(day.date)}
+ onMouseLeave={() => setHoverDate(null)}
isToday={isToday(day.date)}
- isSelected={isSameDate(day.date, selectedDate)}
+ isSelected={filterType !== "between" && isSameDate(day.date, selectedDate)}
+ isRangeStart={filterType === "between" && rangeStart && isSameDate(day.date, rangeStart)}
+ isRangeEnd={filterType === "between" && rangeEnd && isSameDate(day.date, rangeEnd)}
+ isInRange={filterType === "between" && isDateInRange(day.date)}
+ isInHoverRange={isDateInHoverRange(day.date)}
isOtherMonth={day.isOtherMonth}
>
{day.date.getDate()}
@@ -273,6 +357,7 @@ export default function SearchDatePicker({ onSelect, filterType }: Props) {
))}
+
handleQuickSelect("today")}>
Today
diff --git a/src/components/navigation/right/Search.tsx b/src/components/navigation/right/Search.tsx
index e165134a..ea9dda6a 100644
--- a/src/components/navigation/right/Search.tsx
+++ b/src/components/navigation/right/Search.tsx
@@ -109,9 +109,9 @@ interface Props {
query: string;
author?: string;
mention?: string;
- before_date?: string;
- after_date?: string;
- during?: string;
+ date_start?: string;
+ date_end?: string;
+ has?: string;
server_wide?: boolean;
};
}
@@ -132,7 +132,8 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
async function search() {
const searchQuery = searchParams?.query || query;
if (!searchQuery && !searchParams?.author && !searchParams?.mention &&
- !searchParams?.before_date && !searchParams?.after_date && !searchParams?.during && !searchParams?.server_wide) return;
+ !searchParams?.date_start && !searchParams?.date_end &&
+ !searchParams?.has && !searchParams?.server_wide) return;
setState({ type: "loading" });
const searchOptions: any = {
@@ -148,15 +149,12 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
searchOptions.mention = searchParams.mention;
}
- // Add date filters if provided
- if (searchParams?.before_date) {
- searchOptions.before_date = searchParams.before_date;
+ // Add date filters if provided using the new standardized parameters
+ if (searchParams?.date_start) {
+ searchOptions.date_start = searchParams.date_start;
}
- if (searchParams?.after_date) {
- searchOptions.after_date = searchParams.after_date;
- }
- if (searchParams?.during) {
- searchOptions.during = searchParams.during;
+ if (searchParams?.date_end) {
+ searchOptions.date_end = searchParams.date_end;
}
// Add server-wide filter if provided
@@ -164,6 +162,11 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
searchOptions.server_wide = true;
}
+ // Add has filter if provided
+ if (searchParams?.has) {
+ searchOptions.has = searchParams.has;
+ }
+
const data = await channel.searchWithUsers(searchOptions);
setState({ type: "results", results: data.messages });
}
@@ -199,7 +202,14 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
{state.type === "loading" && }
{state.type === "results" && (
-
+ <>
+
+ {state.results.length > 0
+ ? `${state.results.length} Result${state.results.length === 1 ? '' : 's'}`
+ : 'No Results'
+ }
+
+
{(() => {
// Group messages by channel
const groupedMessages = state.results.reduce((acc, message) => {
@@ -256,7 +266,8 @@ export function SearchSidebar({ close, initialQuery = "", searchParams }: Props)
);
});
})()}
-
+
+ >
)}
diff --git a/src/lib/hooks/useSearchAutoComplete.ts b/src/lib/hooks/useSearchAutoComplete.ts
index c1d1de92..930a19c7 100644
--- a/src/lib/hooks/useSearchAutoComplete.ts
+++ b/src/lib/hooks/useSearchAutoComplete.ts
@@ -36,19 +36,19 @@ export function useSearchAutoComplete(
const beforeCursor = value.slice(0, cursorPos);
const afterCursor = value.slice(cursorPos);
- // Check if we're currently typing after "from:" or "mentions:"
- const fromMatch = beforeCursor.match(/\bfrom:(@?\w*)$/);
+ // Check if we're currently typing after "from:" or "mentions:" (including hyphens)
+ const fromMatch = beforeCursor.match(/\bfrom:(@?[\w-]*)$/);
if (fromMatch) {
// Check if there's already a username that continues after cursor
- const continuationMatch = afterCursor.match(/^(\w*)/);
+ const continuationMatch = afterCursor.match(/^([\w-]*)/);
const fullUsername = fromMatch[1].replace('@', '') + (continuationMatch ? continuationMatch[1] : '');
return ["user", fullUsername, fromMatch.index! + 5, "from"];
}
- const mentionsMatch = beforeCursor.match(/\bmentions:(@?\w*)$/);
+ const mentionsMatch = beforeCursor.match(/\bmentions:(@?[\w-]*)$/);
if (mentionsMatch) {
// Check if there's already a username that continues after cursor
- const continuationMatch = afterCursor.match(/^(\w*)/);
+ const continuationMatch = afterCursor.match(/^([\w-]*)/);
const fullUsername = mentionsMatch[1].replace('@', '') + (continuationMatch ? continuationMatch[1] : '');
return ["user", fullUsername, mentionsMatch.index! + 9, "mentions"];
}
@@ -65,9 +65,9 @@ export function useSearchAutoComplete(
// Check if we're still within this filter (no space between filter and cursor)
const betweenFilterAndCursor = value.slice(filterIndex + filterLength, cursorPos);
if (!betweenFilterAndCursor.includes(" ")) {
- // Get the username part
+ // Get the username part (including hyphens)
const afterFilter = value.slice(filterIndex + filterLength);
- const usernameMatch = afterFilter.match(/^@?(\w*)/);
+ const usernameMatch = afterFilter.match(/^@?([\w-]*)/);
if (usernameMatch) {
return ["user", usernameMatch[1] || "", filterIndex + filterLength, filterType];
}
@@ -142,10 +142,10 @@ export function useSearchAutoComplete(
newMappings[mappingKey] = selectedUser._id;
setUserMappings(newMappings);
- // Find the end of the current username (including @ if present)
+ // Find the end of the current username (including @ if present and hyphens)
let endIndex = startIndex;
const afterStartIndex = el.value.slice(startIndex);
- const existingMatch = afterStartIndex.match(/^@?\w*/);
+ const existingMatch = afterStartIndex.match(/^@?[\w-]*/);
if (existingMatch) {
endIndex = startIndex + existingMatch[0].length;
}
@@ -254,17 +254,17 @@ export function useSearchAutoComplete(
export function transformSearchQuery(
displayQuery: string,
userMappings: UserMapping
-): { query: string; author?: string; mention?: string; before_date?: string; after_date?: string; during?: string; server_wide?: boolean } {
+): { query: string; author?: string; mention?: string; date_start?: string; date_end?: string; has?: string; server_wide?: boolean } {
let query = displayQuery;
let author: string | undefined;
let mention: string | undefined;
- let before_date: string | undefined;
- let after_date: string | undefined;
- let during: string | undefined;
+ let date_start: string | undefined;
+ let date_end: string | undefined;
+ let has: string | undefined;
let server_wide: boolean | undefined;
- // Extract and replace from:@username with user ID
- const fromMatch = query.match(/\bfrom:@?(\w+)/);
+ // Extract and replace from:@username with user ID (including hyphens)
+ const fromMatch = query.match(/\bfrom:@?([\w-]+)/);
if (fromMatch) {
const username = fromMatch[1];
const userId = userMappings[`from:${username}`];
@@ -275,8 +275,8 @@ export function transformSearchQuery(
}
}
- // Extract and replace mentions:@username with user ID
- const mentionsMatch = query.match(/\bmentions:@?(\w+)/);
+ // Extract and replace mentions:@username with user ID (including hyphens)
+ const mentionsMatch = query.match(/\bmentions:@?([\w-]+)/);
if (mentionsMatch) {
const username = mentionsMatch[1];
const userId = userMappings[`mentions:${username}`];
@@ -288,35 +288,76 @@ export function transformSearchQuery(
}
// Extract date filters (YYYY-MM-DD format) and convert to ISO 8601
+ // Using the new standardized date_start and date_end approach
const beforeMatch = query.match(/\bbefore:(\d{4}-\d{2}-\d{2})/);
if (beforeMatch) {
- // Convert YYYY-MM-DD to full ISO 8601 format
- const date = new Date(beforeMatch[1]);
- before_date = date.toISOString();
+ // "before" means before the START of this day
+ const [year, month, day] = beforeMatch[1].split('-').map(Number);
+ const startOfDay = new Date(year, month - 1, day);
+ startOfDay.setHours(0, 0, 0, 0);
+
+ date_end = startOfDay.toISOString();
query = query.replace(beforeMatch[0], "").trim();
}
const afterMatch = query.match(/\bafter:(\d{4}-\d{2}-\d{2})/);
if (afterMatch) {
- // Convert YYYY-MM-DD to full ISO 8601 format
- const date = new Date(afterMatch[1]);
- after_date = date.toISOString();
+ // "after" means after the END of this day
+ const [year, month, day] = afterMatch[1].split('-').map(Number);
+ const endOfDay = new Date(year, month - 1, day);
+ endOfDay.setHours(23, 59, 59, 999);
+
+ date_start = endOfDay.toISOString();
query = query.replace(afterMatch[0], "").trim();
}
const duringMatch = query.match(/\bduring:(\d{4}-\d{2}-\d{2})/);
if (duringMatch) {
- // Convert YYYY-MM-DD to full ISO 8601 format
- const date = new Date(duringMatch[1]);
- during = date.toISOString();
+ // For 'during', capture the full day from start to end
+ const [year, month, day] = duringMatch[1].split('-').map(Number);
+
+ const startOfDay = new Date(year, month - 1, day);
+ startOfDay.setHours(0, 0, 0, 0);
+
+ const endOfDay = new Date(year, month - 1, day);
+ endOfDay.setHours(23, 59, 59, 999);
+
+ date_start = startOfDay.toISOString();
+ date_end = endOfDay.toISOString();
+
query = query.replace(duringMatch[0], "").trim();
}
+ // Extract between date range filter (between:YYYY-MM-DD..YYYY-MM-DD)
+ const betweenMatch = query.match(/\bbetween:(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})/);
+ if (betweenMatch) {
+ // Start date: from the START of the first day
+ const [startYear, startMonth, startDay] = betweenMatch[1].split('-').map(Number);
+ const startOfFirstDay = new Date(startYear, startMonth - 1, startDay);
+ startOfFirstDay.setHours(0, 0, 0, 0);
+ date_start = startOfFirstDay.toISOString();
+
+ // End date: to the END of the last day
+ const [endYear, endMonth, endDay] = betweenMatch[2].split('-').map(Number);
+ const endOfLastDay = new Date(endYear, endMonth - 1, endDay);
+ endOfLastDay.setHours(23, 59, 59, 999);
+ date_end = endOfLastDay.toISOString();
+
+ query = query.replace(betweenMatch[0], "").trim();
+ }
+
+ // Extract has: filter for attachment types
+ const hasMatch = query.match(/\bhas:(video|image|link|audio|file)/i);
+ if (hasMatch) {
+ has = hasMatch[1].toLowerCase();
+ query = query.replace(hasMatch[0], "").trim();
+ }
+
// Check for server-wide flag
if (query.includes("server-wide")) {
server_wide = true;
query = query.replace(/\bserver-wide\b/g, "").trim();
}
- return { query, author, mention, before_date, after_date, during, server_wide };
+ return { query, author, mention, date_start, date_end, has, server_wide };
}
\ No newline at end of file