Compare commits
3 Commits
a77c4b6db5
...
e2b957b6bd
| Author | SHA1 | Date | |
|---|---|---|---|
| e2b957b6bd | |||
| abf15391f6 | |||
| 44e36f7dd2 |
+12
-3
@@ -188,10 +188,19 @@ Features:
|
||||
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
|
||||
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
|
||||
|
||||
### [ ] P4-9 · Advanced Search Filter UI
|
||||
### [x] P4-9 · Advanced Search Filter UI — PARTIALLY DONE (UNTESTED)
|
||||
|
||||
**What:** Introduce a more robust search filter UI in `SearchFilters.tsx`.
|
||||
**Approach:** Add UI components for easier filtering and a visual date-range picker that correctly maps to `fromTs` and `toTs`.
|
||||
**What:** Improve search filter UX in `SearchFilters.tsx`.
|
||||
**Completed 2026-06-18:**
|
||||
- ✅ `SelectSenderButton` — picker UI for sender filter (previously required typing `from:@user` by hand)
|
||||
- ✅ `DateRangeButton` — quick-pick presets: Today / Last week / Last month / Last year
|
||||
- ✅ `Has link` chip — `contains_url: true` filter, wired to Matrix API and URL param
|
||||
**UNTESTED** — needs verification at chat.lotusguild.org.
|
||||
|
||||
**Remaining for parity with Discord/Slack:**
|
||||
- [ ] `has:image` / `has:file` / `has:video` — msgtype filters (require client-side post-filtering, no server API)
|
||||
- [ ] Pinned messages filter
|
||||
- [ ] Saved searches / search history
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
|
||||
import { Box, Button, color, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
||||
@@ -197,7 +197,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
||||
style={{
|
||||
paddingTop: config.space.S100,
|
||||
paddingBottom: config.space.S100,
|
||||
borderTop: `${toRem(1)} solid var(--border-surface-variant)`,
|
||||
borderTop: `${toRem(1)} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<UserDeviceRow userId={userId} device={device} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const LiveChipText = style({
|
||||
@@ -16,6 +16,19 @@ export const ControlDivider = style({
|
||||
height: toRem(16),
|
||||
});
|
||||
|
||||
export const SpeakerAvatarOutline = style({
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
|
||||
const speakerPulse = keyframes({
|
||||
'0%': { boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}` },
|
||||
'50%': { boxShadow: `0 0 0 ${toRem(4)} ${color.Success.ContainerActive}` },
|
||||
'100%': { boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}` },
|
||||
});
|
||||
|
||||
export const SpeakerAvatarOutline = style({
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: no-preference)': {
|
||||
animation: `${speakerPulse} 1200ms ease-in-out infinite`,
|
||||
},
|
||||
'(prefers-reduced-motion: reduce)': {
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B600} ${color.Success.Main}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@ const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSe
|
||||
senders: searchParams.get('senders') ?? undefined,
|
||||
fromTs: searchParams.get('fromTs') ?? undefined,
|
||||
toTs: searchParams.get('toTs') ?? undefined,
|
||||
containsUrl: searchParams.get('containsUrl') ?? undefined,
|
||||
}),
|
||||
[searchParams],
|
||||
);
|
||||
@@ -197,6 +198,7 @@ export function MessageSearch({
|
||||
senders: searchParamsSenders ?? senders,
|
||||
fromTs: searchPathSearchParams.fromTs ? Number(searchPathSearchParams.fromTs) : undefined,
|
||||
toTs: searchPathSearchParams.toTs ? Number(searchPathSearchParams.toTs) : undefined,
|
||||
containsUrl: searchPathSearchParams.containsUrl === 'true' ? true : undefined,
|
||||
};
|
||||
}, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]);
|
||||
|
||||
@@ -357,6 +359,18 @@ export function MessageSearch({
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const handleContainsUrlChange = useCallback(
|
||||
(value?: boolean) => {
|
||||
setSearchParams((prevParams) => {
|
||||
const p = new URLSearchParams(prevParams);
|
||||
p.delete('containsUrl');
|
||||
if (value) p.append('containsUrl', 'true');
|
||||
return p;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
const lastVItem = vItems[vItems.length - 1];
|
||||
const lastVItemIndex: number | undefined = lastVItem?.index;
|
||||
const lastGroupIndex = groups.length - 1;
|
||||
@@ -409,6 +423,8 @@ export function MessageSearch({
|
||||
fromTs={msgSearchParams.fromTs}
|
||||
toTs={msgSearchParams.toTs}
|
||||
onDateRangeChange={handleDateRangeChange}
|
||||
containsUrl={msgSearchParams.containsUrl}
|
||||
onContainsUrlChange={handleContainsUrlChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, {
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
Badge,
|
||||
RectCords,
|
||||
} from 'folds';
|
||||
import { color } from 'folds';
|
||||
import { SearchOrderBy } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
@@ -326,6 +328,166 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
||||
);
|
||||
}
|
||||
|
||||
type SelectSenderButtonProps = {
|
||||
selectedSenders?: string[];
|
||||
onChange: (senders?: string[]) => void;
|
||||
};
|
||||
function SelectSenderButton({ selectedSenders, onChange }: SelectSenderButtonProps) {
|
||||
const mx = useMatrixClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [localSelected, setLocalSelected] = useState(selectedSenders);
|
||||
|
||||
const knownUsers = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
mx.getRooms().forEach((room) => {
|
||||
room.getJoinedMembers().forEach((m) => {
|
||||
if (m.userId !== mx.getSafeUserId()) ids.add(m.userId);
|
||||
});
|
||||
});
|
||||
return Array.from(ids).sort();
|
||||
}, [mx]);
|
||||
|
||||
const getUserDisplayStr: SearchItemStrGetter<string> = useCallback(
|
||||
(userId) => {
|
||||
const user = mx.getUser(userId);
|
||||
return user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
},
|
||||
[mx],
|
||||
);
|
||||
|
||||
const [searchResult, _searchUser, resetSearch] = useAsyncSearch(
|
||||
knownUsers,
|
||||
getUserDisplayStr,
|
||||
SEARCH_OPTS,
|
||||
);
|
||||
const users = searchResult?.items ?? knownUsers;
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: users.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 32,
|
||||
overscan: 5,
|
||||
});
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
const searchUser = useDebounce(_searchUser, SEARCH_DEBOUNCE_OPTS);
|
||||
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
||||
const value = evt.currentTarget.value.trim();
|
||||
if (!value) { resetSearch(); return; }
|
||||
searchUser(value);
|
||||
};
|
||||
|
||||
const handleUserClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const userId = evt.currentTarget.getAttribute('data-user-id');
|
||||
if (!userId) return;
|
||||
if (localSelected?.includes(userId)) {
|
||||
setLocalSelected(localSelected.filter((id) => id !== userId));
|
||||
return;
|
||||
}
|
||||
setLocalSelected([...(localSelected ?? []), userId]);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setMenuAnchor(undefined);
|
||||
onChange(localSelected && localSelected.length > 0 ? localSelected : undefined);
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setMenuAnchor(undefined);
|
||||
onChange(undefined);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSelected(selectedSenders);
|
||||
resetSearch();
|
||||
}, [menuAnchor, selectedSenders, resetSearch]);
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
align="Center"
|
||||
position="Bottom"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu variant="Surface" style={{ width: toRem(250) }}>
|
||||
<Box direction="Column" style={{ maxHeight: toRem(400), maxWidth: toRem(300) }}>
|
||||
<Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200, paddingBottom: 0 }}>
|
||||
<Text size="L400">From</Text>
|
||||
<Input onChange={handleSearchChange} size="300" radii="300" placeholder="Search people..." />
|
||||
</Box>
|
||||
<Scroll ref={scrollRef} size="300" hideTrack>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200, paddingRight: 0 }}>
|
||||
{users.length === 0 && (
|
||||
<Text style={{ padding: config.space.S400 }} size="T300" align="Center">No match found!</Text>
|
||||
)}
|
||||
<div style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
|
||||
{vItems.map((vItem) => {
|
||||
const userId = users[vItem.index];
|
||||
const user = mx.getUser(userId);
|
||||
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const selected = localSelected?.includes(userId);
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{ paddingBottom: config.space.S100 }}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
>
|
||||
<MenuItem
|
||||
data-user-id={userId}
|
||||
onClick={handleUserClick}
|
||||
variant={selected ? 'Success' : 'Surface'}
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-pressed={selected}
|
||||
before={<Icon size="50" src={Icons.User} />}
|
||||
>
|
||||
<Text truncate size="T300">{name}</Text>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
</Scroll>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box shrink="No" direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
<Button size="300" variant="Secondary" radii="300" onClick={handleSave}>
|
||||
{localSelected && localSelected.length > 0 ? (
|
||||
<Text size="B300">Save ({localSelected.length})</Text>
|
||||
) : (
|
||||
<Text size="B300">Save</Text>
|
||||
)}
|
||||
</Button>
|
||||
<Button size="300" radii="300" variant="Secondary" fill="Soft" onClick={handleDeselectAll} disabled={!localSelected || localSelected.length === 0}>
|
||||
<Text size="B300">Deselect All</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) => setMenuAnchor(e.currentTarget.getBoundingClientRect())}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.User} />}
|
||||
>
|
||||
<Text size="T200">From</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
||||
type DateRangeButtonProps = {
|
||||
fromTs?: number;
|
||||
toTs?: number;
|
||||
@@ -364,6 +526,33 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
|
||||
>
|
||||
<Menu variant="Surface" style={{ padding: config.space.S300, minWidth: toRem(220) }}>
|
||||
<Box direction="Column" gap="200">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Quick pick</Text>
|
||||
<Box gap="100" wrap="Wrap">
|
||||
{([
|
||||
{ label: 'Today', days: 0 },
|
||||
{ label: 'Last week', days: 7 },
|
||||
{ label: 'Last month', days: 30 },
|
||||
{ label: 'Last year', days: 365 },
|
||||
] as const).map(({ label: l, days }) => {
|
||||
const now = Date.now();
|
||||
const from = days === 0
|
||||
? new Date().setHours(0, 0, 0, 0)
|
||||
: now - days * 24 * 60 * 60 * 1000;
|
||||
return (
|
||||
<Chip
|
||||
key={l}
|
||||
radii="Pill"
|
||||
variant="SurfaceVariant"
|
||||
onClick={() => { onChange(from, now); setMenuAnchor(undefined); }}
|
||||
>
|
||||
<Text size="T200">{l}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">From</Text>
|
||||
<input
|
||||
@@ -372,8 +561,8 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
|
||||
max={toDate || undefined}
|
||||
onChange={(e) => handleFrom(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--bg-surface-variant)',
|
||||
border: '1px solid var(--border-surface-variant)',
|
||||
background: color.SurfaceVariant.Container,
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
color: 'inherit',
|
||||
fontSize: '0.82rem',
|
||||
@@ -389,8 +578,8 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
|
||||
min={fromDate || undefined}
|
||||
onChange={(e) => handleTo(e.target.value)}
|
||||
style={{
|
||||
background: 'var(--bg-surface-variant)',
|
||||
border: '1px solid var(--border-surface-variant)',
|
||||
background: color.SurfaceVariant.Container,
|
||||
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
color: 'inherit',
|
||||
fontSize: '0.82rem',
|
||||
@@ -458,6 +647,8 @@ type SearchFiltersProps = {
|
||||
fromTs?: number;
|
||||
toTs?: number;
|
||||
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
|
||||
containsUrl?: boolean;
|
||||
onContainsUrlChange: (value?: boolean) => void;
|
||||
};
|
||||
export function SearchFilters({
|
||||
defaultRoomsFilterName,
|
||||
@@ -474,6 +665,8 @@ export function SearchFilters({
|
||||
fromTs,
|
||||
toTs,
|
||||
onDateRangeChange,
|
||||
containsUrl,
|
||||
onContainsUrlChange,
|
||||
}: SearchFiltersProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
@@ -531,14 +724,12 @@ export function SearchFilters({
|
||||
selectedRooms={selectedRooms}
|
||||
onChange={onSelectedRoomsChange}
|
||||
/>
|
||||
{selectedSenders && selectedSenders.length > 0 && (
|
||||
<Line
|
||||
style={{ margin: `${config.space.S100} 0` }}
|
||||
direction="Vertical"
|
||||
variant="Surface"
|
||||
size="300"
|
||||
/>
|
||||
)}
|
||||
<Line
|
||||
style={{ margin: `${config.space.S100} 0` }}
|
||||
direction="Vertical"
|
||||
variant="Surface"
|
||||
size="300"
|
||||
/>
|
||||
{selectedSenders?.map((userId) => {
|
||||
const user = mx.getUser(userId);
|
||||
const name = user?.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
@@ -555,7 +746,29 @@ export function SearchFilters({
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
<SelectSenderButton
|
||||
selectedSenders={selectedSenders}
|
||||
onChange={onSelectedSendersChange}
|
||||
/>
|
||||
<Box grow="Yes" data-spacing-node />
|
||||
<Chip
|
||||
variant={containsUrl ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
aria-pressed={!!containsUrl}
|
||||
before={<Icon size="100" src={Icons.Link} />}
|
||||
after={
|
||||
containsUrl ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => { e.stopPropagation(); onContainsUrlChange(undefined); }}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
onClick={() => onContainsUrlChange(containsUrl ? undefined : true)}
|
||||
>
|
||||
<Text size="T200">Has link</Text>
|
||||
</Chip>
|
||||
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
|
||||
<OrderButton order={order} onChange={onOrderChange} />
|
||||
</Box>
|
||||
|
||||
@@ -70,10 +70,11 @@ export type MessageSearchParams = {
|
||||
senders?: string[];
|
||||
fromTs?: number;
|
||||
toTs?: number;
|
||||
containsUrl?: boolean;
|
||||
};
|
||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
const mx = useMatrixClient();
|
||||
const { term, order, rooms, senders, fromTs, toTs } = params;
|
||||
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
|
||||
|
||||
const searchMessages = useCallback(
|
||||
async (nextBatch?: string) => {
|
||||
@@ -96,9 +97,10 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
limit,
|
||||
rooms,
|
||||
senders,
|
||||
// from_ts / to_ts are valid Matrix spec fields not yet in SDK types
|
||||
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
|
||||
...(fromTs !== undefined && { from_ts: fromTs }),
|
||||
...(toTs !== undefined && { to_ts: toTs }),
|
||||
...(containsUrl !== undefined && { contains_url: containsUrl }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
include_state: false,
|
||||
@@ -114,7 +116,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
},
|
||||
[mx, term, order, rooms, senders, fromTs, toTs],
|
||||
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
|
||||
);
|
||||
|
||||
return searchMessages;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
@@ -109,13 +110,31 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
return (
|
||||
<Page ref={roomViewRef} style={chatBgStyle}>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="400"
|
||||
style={{ padding: config.space.S400, opacity: 0.7 }}
|
||||
>
|
||||
<Text size="H4">Timeline unavailable</Text>
|
||||
<Text size="T300" align="Center">
|
||||
An error occurred while rendering messages. Try refreshing the page.
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<RoomTimeline
|
||||
key={roomId}
|
||||
room={room}
|
||||
eventId={eventId}
|
||||
roomInputRef={roomInputRef}
|
||||
editor={editor}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
<RoomViewTyping room={room} />
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
@@ -129,13 +148,27 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
) : (
|
||||
<>
|
||||
{canMessage && (
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<RoomInputPlaceholder
|
||||
style={{ padding: config.space.S200 }}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Text align="Center">
|
||||
Message composer encountered an error. Try refreshing.
|
||||
</Text>
|
||||
</RoomInputPlaceholder>
|
||||
}
|
||||
>
|
||||
<RoomInput
|
||||
room={room}
|
||||
editor={editor}
|
||||
roomId={roomId}
|
||||
fileDropContainerRef={roomViewRef}
|
||||
ref={roomInputRef}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
{!canMessage && (
|
||||
<RoomInputPlaceholder
|
||||
|
||||
@@ -35,6 +35,7 @@ export type _SearchPathSearchParams = {
|
||||
senders?: string;
|
||||
fromTs?: string;
|
||||
toTs?: string;
|
||||
containsUrl?: string;
|
||||
};
|
||||
export const _SEARCH_PATH = 'search/';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user