diff --git a/0001-Stylistic-UI-changes.patch b/0001-Stylistic-UI-changes.patch new file mode 100644 index 0000000000..0f7a55bad8 --- /dev/null +++ b/0001-Stylistic-UI-changes.patch @@ -0,0 +1,231 @@ +From 2d92ae6de64cfeee41d4af520a091610cc1a8d74 Mon Sep 17 00:00:00 2001 +From: niki <295591807+nikiwastaken@users.noreply.github.com> +Date: Sun, 5 Jul 2026 16:16:08 +0000 +Subject: [PATCH] Stylistic UI changes + +--- + src/app/pages/client/profile/Profile.tsx | 51 +++++++++----------- + src/app/pages/client/sidebar/UserMenuTab.tsx | 37 ++++++++------ + 2 files changed, 45 insertions(+), 43 deletions(-) + +diff --git a/src/app/pages/client/profile/Profile.tsx b/src/app/pages/client/profile/Profile.tsx +index 24f928b6..0d3598f6 100644 +--- a/src/app/pages/client/profile/Profile.tsx ++++ b/src/app/pages/client/profile/Profile.tsx +@@ -5,15 +5,13 @@ import { + color, + config, + Menu, +- Icon, +- Icons, + Line, + MenuItem, + OverlayBackdrop, + Overlay, + OverlayCenter, + } from 'folds'; +-import { SquaresFour, menuIcon, settingsNavIcon, sizedIcon } from '$components/icons/phosphor'; ++import { GearSix, SquaresFour, menuIcon, sizedIcon } from '$components/icons/phosphor'; + import { PageNav, PageNavHeader } from '$components/page'; + import { useEffect, useMemo, useState } from 'react'; + import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +@@ -36,7 +34,7 @@ import { useUserProfile } from '$hooks/useUserProfile'; + import type { SettingsMenuItem } from '$features/settings'; + import { settingsMenuIcons, settingsSections, useOpenSettings } from '$features/settings'; + import { UserQuickTools } from '../sidebar/UserQuickTools'; +-import { SignOutIcon } from '@phosphor-icons/react'; ++import { CaretDownIcon, CaretRightIcon, SignOutIcon } from '@phosphor-icons/react'; + import { UseStateProvider } from '$components/UseStateProvider'; + import { FocusTrap } from 'focus-trap-react'; + import { LogoutDialog } from '$components/LogoutDialog'; +@@ -144,17 +142,19 @@ export function ProfileMobile() { + background: color.Background.Container, + }} + > +- ++ ++ + +- +- ++ ++ ++ + + + +@@ -169,18 +169,15 @@ export function ProfileMobile() { + + + +- ++ + } +- after={ +- +- } ++ before={menuIcon(GearSix)} ++ style={{ ++ background: isSettingsOpen ? color.Surface.Container : color.Background.Container, ++ }} ++ after={menuIcon(isSettingsOpen && isMobile ? CaretDownIcon : CaretRightIcon)} + onClick={() => setIsSettingsOpen(!isSettingsOpen)} + > + +@@ -188,17 +185,17 @@ export function ProfileMobile() { + + + {isSettingsOpen && ( +-
++ + {menuItems.map((item) => { + const IconComponent = item.icon; + + return ( + openSettings(item.id)} + > + +@@ -238,7 +235,7 @@ export function ProfileMobile() { + + )} + +-
++
+ )} +
+ +diff --git a/src/app/pages/client/sidebar/UserMenuTab.tsx b/src/app/pages/client/sidebar/UserMenuTab.tsx +index 6f321da7..66b489cd 100644 +--- a/src/app/pages/client/sidebar/UserMenuTab.tsx ++++ b/src/app/pages/client/sidebar/UserMenuTab.tsx +@@ -8,8 +8,6 @@ import { + Chip, + Dialog, + Header, +- Icon, +- Icons, + Line, + Menu, + MenuItem, +@@ -38,7 +36,7 @@ import { createLogger } from '$utils/debug'; + import type { Session } from '$state/sessions'; + import { activeSessionIdAtom, backgroundUnreadCountsAtom, sessionsAtom } from '$state/sessions'; + import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +-import { Check, chipIcon, Plus } from '$components/icons/phosphor'; ++import { Check, chipIcon, menuIcon, Plus } from '$components/icons/phosphor'; + import { useSessionProfiles } from '$hooks/useSessionProfiles'; + import { useClientConfig } from '$hooks/useClientConfig'; + import { getHomePath, getLoginPath, getProfilePath, withSearchParam } from '$pages/pathUtils'; +@@ -56,7 +54,14 @@ import { + useUnverifiedDeviceCount, + VerificationStatus, + } from '$hooks/useDeviceVerificationStatus'; +-import { ShieldWarningIcon } from '@phosphor-icons/react'; ++import { ++ CaretDownIcon, ++ CaretRightIcon, ++ GearSixIcon, ++ PencilIcon, ++ ShieldWarningIcon, ++ UserIcon, ++} from '@phosphor-icons/react'; + import * as css from './UserMenuTab.css'; + + const log = createLogger('AccountSwitcherTab'); +@@ -262,14 +267,14 @@ export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; is + } +- after={ +- +- } ++ before={menuIcon(UserIcon)} ++ after={menuIcon(isOpen && isMobile ? CaretDownIcon : CaretRightIcon)} + style={{ + position: 'relative', + background: isMobile +- ? color.Background.Container ++ ? isOpen ++ ? color.Surface.Container ++ : color.Background.Container + : isOpen + ? color.Secondary.Container + : color.Surface.Container, +@@ -479,13 +484,13 @@ export function PresenceMenuOption({ + )} + + } +- after={ +- +- } ++ after={menuIcon(isOpen && isMobile ? CaretDownIcon : CaretRightIcon)} + style={{ + position: 'relative', + background: isMobile +- ? color.Background.Container ++ ? isOpen ++ ? color.Surface.Container ++ : color.Background.Container + : isOpen + ? color.Secondary.Container + : color.Surface.Container, +@@ -751,7 +756,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi + showColor={false} + allowEditing={true} + /> +- ++ + + + +@@ -766,7 +771,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi + onClick={() => openSettings('account')} + size="300" + radii="300" +- before={} ++ before={menuIcon(PencilIcon)} + > + + Edit Profile +@@ -790,7 +795,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi + } ++ before={menuIcon(GearSixIcon)} + onClick={() => openSettings()} + > + +-- +2.54.0 + diff --git a/0001-ui-changes.diff b/0001-ui-changes.diff new file mode 100644 index 0000000000..79a18e2c9f --- /dev/null +++ b/0001-ui-changes.diff @@ -0,0 +1,218 @@ +diff --git c/src/app/pages/client/profile/Profile.tsx w/src/app/pages/client/profile/Profile.tsx +index 24f928b6..0d3598f6 100644 +--- c/src/app/pages/client/profile/Profile.tsx ++++ w/src/app/pages/client/profile/Profile.tsx +@@ -5,15 +5,13 @@ import { + color, + config, + Menu, +- Icon, +- Icons, + Line, + MenuItem, + OverlayBackdrop, + Overlay, + OverlayCenter, + } from 'folds'; +-import { SquaresFour, menuIcon, settingsNavIcon, sizedIcon } from '$components/icons/phosphor'; ++import { GearSix, SquaresFour, menuIcon, sizedIcon } from '$components/icons/phosphor'; + import { PageNav, PageNavHeader } from '$components/page'; + import { useEffect, useMemo, useState } from 'react'; + import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +@@ -36,7 +34,7 @@ import { useUserProfile } from '$hooks/useUserProfile'; + import type { SettingsMenuItem } from '$features/settings'; + import { settingsMenuIcons, settingsSections, useOpenSettings } from '$features/settings'; + import { UserQuickTools } from '../sidebar/UserQuickTools'; +-import { SignOutIcon } from '@phosphor-icons/react'; ++import { CaretDownIcon, CaretRightIcon, SignOutIcon } from '@phosphor-icons/react'; + import { UseStateProvider } from '$components/UseStateProvider'; + import { FocusTrap } from 'focus-trap-react'; + import { LogoutDialog } from '$components/LogoutDialog'; +@@ -144,17 +142,19 @@ export function ProfileMobile() { + background: color.Background.Container, + }} + > +- ++ ++ + +- +- ++ ++ ++ + + + +@@ -169,18 +169,15 @@ export function ProfileMobile() { + + + +- ++ + } +- after={ +- +- } ++ before={menuIcon(GearSix)} ++ style={{ ++ background: isSettingsOpen ? color.Surface.Container : color.Background.Container, ++ }} ++ after={menuIcon(isSettingsOpen && isMobile ? CaretDownIcon : CaretRightIcon)} + onClick={() => setIsSettingsOpen(!isSettingsOpen)} + > + +@@ -188,17 +185,17 @@ export function ProfileMobile() { + + + {isSettingsOpen && ( +-
++ + {menuItems.map((item) => { + const IconComponent = item.icon; + + return ( + openSettings(item.id)} + > + +@@ -238,7 +235,7 @@ export function ProfileMobile() { + + )} + +-
++
+ )} +
+ +diff --git c/src/app/pages/client/sidebar/UserMenuTab.tsx w/src/app/pages/client/sidebar/UserMenuTab.tsx +index 6f321da7..66b489cd 100644 +--- c/src/app/pages/client/sidebar/UserMenuTab.tsx ++++ w/src/app/pages/client/sidebar/UserMenuTab.tsx +@@ -8,8 +8,6 @@ import { + Chip, + Dialog, + Header, +- Icon, +- Icons, + Line, + Menu, + MenuItem, +@@ -38,7 +36,7 @@ import { createLogger } from '$utils/debug'; + import type { Session } from '$state/sessions'; + import { activeSessionIdAtom, backgroundUnreadCountsAtom, sessionsAtom } from '$state/sessions'; + import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; +-import { Check, chipIcon, Plus } from '$components/icons/phosphor'; ++import { Check, chipIcon, menuIcon, Plus } from '$components/icons/phosphor'; + import { useSessionProfiles } from '$hooks/useSessionProfiles'; + import { useClientConfig } from '$hooks/useClientConfig'; + import { getHomePath, getLoginPath, getProfilePath, withSearchParam } from '$pages/pathUtils'; +@@ -56,7 +54,14 @@ import { + useUnverifiedDeviceCount, + VerificationStatus, + } from '$hooks/useDeviceVerificationStatus'; +-import { ShieldWarningIcon } from '@phosphor-icons/react'; ++import { ++ CaretDownIcon, ++ CaretRightIcon, ++ GearSixIcon, ++ PencilIcon, ++ ShieldWarningIcon, ++ UserIcon, ++} from '@phosphor-icons/react'; + import * as css from './UserMenuTab.css'; + + const log = createLogger('AccountSwitcherTab'); +@@ -262,14 +267,14 @@ export function AccountMenuOption({ isMobile, isRight }: { isMobile: boolean; is + } +- after={ +- +- } ++ before={menuIcon(UserIcon)} ++ after={menuIcon(isOpen && isMobile ? CaretDownIcon : CaretRightIcon)} + style={{ + position: 'relative', + background: isMobile +- ? color.Background.Container ++ ? isOpen ++ ? color.Surface.Container ++ : color.Background.Container + : isOpen + ? color.Secondary.Container + : color.Surface.Container, +@@ -479,13 +484,13 @@ export function PresenceMenuOption({ + )} + + } +- after={ +- +- } ++ after={menuIcon(isOpen && isMobile ? CaretDownIcon : CaretRightIcon)} + style={{ + position: 'relative', + background: isMobile +- ? color.Background.Container ++ ? isOpen ++ ? color.Surface.Container ++ : color.Background.Container + : isOpen + ? color.Secondary.Container + : color.Surface.Container, +@@ -751,7 +756,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi + showColor={false} + allowEditing={true} + /> +- ++ + + + +@@ -766,7 +771,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi + onClick={() => openSettings('account')} + size="300" + radii="300" +- before={} ++ before={menuIcon(PencilIcon)} + > + + Edit Profile +@@ -790,7 +795,7 @@ export function UserMenuTab({ isBottom, isMobile }: { isBottom?: boolean; isMobi + } ++ before={menuIcon(GearSixIcon)} + onClick={() => openSettings()} + > + diff --git a/flake.nix b/flake.nix index 1d5d2a6bd9..c8ef7fa74e 100644 --- a/flake.nix +++ b/flake.nix @@ -61,7 +61,7 @@ ; pname = "sable"; fetcherVersion = 3; - hash = "sha256-b6+j893Ltg2880YVOIwPnSH3diJdNZdmhZSwsMnJKtU="; + hash = "sha256-WIZM9ZHMtBIVEy6q1vP6WvOtnfW7V/Dokb7wUR8TKWc="; }; mkPnpmCheck = diff --git a/package.json b/package.json index 022b97b4a0..eeb4d5ced6 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "emojibase-data": "^17.0.0", "eventemitter3": "^5.0.4", "file-saver": "^2.0.5", + "flexsearch": "0.8.212", "focus-trap-react": "^12.0.3", "folds": "^2.6.2", "framer-motion": "^12.42.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e23aea298a..acb2e42909 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -98,6 +98,9 @@ importers: file-saver: specifier: ^2.0.5 version: 2.0.5 + flexsearch: + specifier: 0.8.212 + version: 0.8.212 focus-trap-react: specifier: ^12.0.3 version: 12.0.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3482,6 +3485,9 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + flexsearch@0.8.212: + resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==} + focus-trap-react@12.0.3: resolution: {integrity: sha512-4eXtzhRTtFrxle9Tkl0Wkj8SFJnWz3i3yb5e53Mdn4E8XZQ/Lzqom9U4uUAJ8wd7yDmyGVP96dxwQZXLXd4sbg==} peerDependencies: @@ -8131,6 +8137,8 @@ snapshots: flatted@3.4.2: {} + flexsearch@0.8.212: {} + focus-trap-react@12.0.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@types/react': 18.3.31 diff --git a/src/app/components/GlobalKeyboardShortcuts.tsx b/src/app/components/GlobalKeyboardShortcuts.tsx index e3b577ffca..d1c5519345 100644 --- a/src/app/components/GlobalKeyboardShortcuts.tsx +++ b/src/app/components/GlobalKeyboardShortcuts.tsx @@ -21,6 +21,7 @@ import { getHomeRoomPath, getHomeSearchPath, getInboxBookmarksPath, + getDirectSearchPath, getSpaceRoomPath, getSpaceSearchPath, withSearchParam, @@ -32,6 +33,7 @@ import { announce } from '$utils/announce'; import { roomIdToReplyDraftAtomFamily } from '$state/room/roomInputDrafts'; import type { Room } from '$types/matrix-sdk'; import { useSelectedSpace } from '$hooks/router/useSelectedSpace'; +import { useIsDirectRoom } from '$hooks/useRoom'; export function GlobalKeyboardShortcuts() { const navigate = useNavigate(); @@ -39,6 +41,7 @@ export function GlobalKeyboardShortcuts() { const mx = useMatrixClient(); const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); + const direct = useIsDirectRoom(); const roomToUnread = useAtomValue(roomToUnreadAtom); const unreadIndexRef = useRef(0); @@ -185,12 +188,34 @@ export function GlobalKeyboardShortcuts() { }; const path = currentSpace ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, currentSpace)) - : getHomeSearchPath(); + : direct + ? getDirectSearchPath() + : getHomeSearchPath(); const roomName = mx.getRoom(currentRoom?.roomId)?.name; navigate(withSearchParam(path, searchParams)); announce(`Start Searching messages ${roomName ? `in ${roomName}` : ''}`); }, - [mx, currentRoom, currentSpace, navigate] + [mx, currentRoom, currentSpace, navigate, direct] + ); + + const handleSearchMessageGlobally = useCallback( + (evt: KeyboardEvent) => { + if (!isKeyHotkey('mod+shift+f', evt)) return; + evt.preventDefault(); + + const searchParams: SearchPathSearchParams = { + global: 'true', + }; + const path = currentSpace + ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, currentSpace)) + : direct + ? getDirectSearchPath() + : getHomeSearchPath(); + const roomName = mx.getRoom(currentRoom?.roomId)?.name; + navigate(withSearchParam(path, searchParams)); + announce(`Start Searching messages ${roomName ? `in ${roomName}` : ''}`); + }, + [mx, currentRoom, currentSpace, navigate, direct] ); useKeyDown(window, handleNextUnreadKeyDown); @@ -198,6 +223,7 @@ export function GlobalKeyboardShortcuts() { useKeyDown(window, handleReplyKeyDown); useKeyDown(window, handleBookmarkKeyDown); useKeyDown(window, handleSearchMessageInRoom); + useKeyDown(window, handleSearchMessageGlobally); return null; } diff --git a/src/app/components/SearchIndexProvider.tsx b/src/app/components/SearchIndexProvider.tsx new file mode 100644 index 0000000000..fb50f09638 --- /dev/null +++ b/src/app/components/SearchIndexProvider.tsx @@ -0,0 +1,609 @@ +import { useMatrixClient } from '$hooks/useMatrixClient'; +import type { SearchIndexContextType, SearchIndexState } from '$hooks/useSearchIndex'; +import { SearchIndexContext } from '$hooks/useSearchIndex'; +import type { + SearchIndexEvent, + IndexWorkerMessageIn, + IndexWorkerMessageOut, + BackfillState, +} from '$plugins/search-indexer/types'; +import { WorkerMessageTypeIn, WorkerMessageTypeOut } from '$plugins/search-indexer/types'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import type { MatrixEvent, Room } from 'matrix-js-sdk'; +import { + EventType, + MatrixEventEvent, + EventTimelineSet, + Direction, + ClientEvent, + RoomEvent, + SyncState, +} from 'matrix-js-sdk'; +import type { ReactNode } from 'react'; +import { useState, useRef, useCallback, useMemo, useEffect } from 'react'; +import * as Sentry from '@sentry/react'; +// oxlint-disable-next-line import/default +import CreateSearchWorker from '$plugins/search-indexer/index.ts?worker'; + +const BACKFILL_PAGE_SIZE = 50; +const HAS_IDLE_CALLBACK = typeof requestIdleCallback === 'function'; +let IDLE_CALLBACK_COUNT = 0; +const MAX_CONCURRENT_BACKFILLS = HAS_IDLE_CALLBACK ? Infinity : 2; +const BACKFILL_STARTUP_DELAY_MS = 30_000; + +const canRunMobileBackfill = (): boolean => + HAS_IDLE_CALLBACK || (document.visibilityState === 'visible' && document.hasFocus()); + +function scheduleIdle(cb: () => void): () => void { + if (HAS_IDLE_CALLBACK) { + IDLE_CALLBACK_COUNT += 1; + const id = requestIdleCallback(cb, { timeout: 5000 }); + return () => cancelIdleCallback(id); + } + + const id = setTimeout(cb, 500); + return () => clearTimeout(id); +} + +const MEDIA_MSGTYPES = new Set(['m.image', 'm.file', 'm.audio', 'm.video']); + +function toSearchIndexEvent( + event: MatrixEvent, + replaced: boolean = false +): SearchIndexEvent | null { + const eventId = event.getId(); + if (!eventId) return null; + + const roomId = event.getRoomId(); + if (!roomId) return null; + + if (event.getType() !== EventType.RoomMessage.toString()) return null; + if (event.isRedacted()) return null; + + const content = event.getContent(); + + const body: string = (replaced ? event.getContent()['m.new_content']?.body : content.body) ?? ''; + if (!body.trim()) return null; + + const sender = event.getSender(); + if (!sender) return null; + + const msgtype = content.msgtype ?? 'm.text'; + const ts = event.getTs(); + const hasLink = /https?:\/\//i.test(body); + + const searchIndexEvent: SearchIndexEvent = { + eventId, + roomId, + sender, + msgtype, + body, + ts, + hasLink, + }; + + if (MEDIA_MSGTYPES.has(msgtype)) { + if (content.url !== undefined) searchIndexEvent.url = content.url; + if (content.file !== undefined) searchIndexEvent.file = content.file; + if (content.info !== undefined) searchIndexEvent.info = content.info; + if (content.filename !== undefined) searchIndexEvent.filename = content.filename; + } + return searchIndexEvent; +} + +type PendingStats = { + resolve: (stats: SearchIndexState) => void; + backfillingRoomCount: number; +}; +type PendingQuery = { + resolve: (events: SearchIndexEvent[]) => void; + reject: (err: unknown) => void; +}; + +export function SearchIndexProvider({ children }: { children: ReactNode }) { + const mx = useMatrixClient(); + const [idbSearchIndex] = useSetting(settingsAtom, 'idbSearchIndex'); + const [maxResults] = useSetting(settingsAtom, 'idbSearchMaxResults'); + + const [isReady, setIsReady] = useState(false); + const [isBackfilling, setIsBackfilling] = useState(false); + + const workerRef = useRef(null); + const pendingStatsRef = useRef(null); + const pendingQueriesRef = useRef>(new Map()); + + const postToWorker = useCallback((msg: IndexWorkerMessageIn) => { + workerRef.current?.postMessage(msg); + }, []); + + const headlessSetsRef = useRef>(new Map()); + const cancelIdlesRef = useRef void>>([]); + const syncStateRef = useRef(null); + + const backfillQueueRef = useRef>([]); + const backfillStatesRef = useRef>({}); + const backfillingRoomsRef = useRef>(new Set()); // TODO: I dont like this + const backfillStartDelayRef = useRef | null>(null); + const backfillReadyRef = useRef(false); + + const indexEvent = useCallback( + (event: MatrixEvent) => { + const handleEvent = () => { + const searchEvent = toSearchIndexEvent(event); + if (searchEvent) + postToWorker({ + type: WorkerMessageTypeIn.Index, + events: [searchEvent], + }); + }; + + if (event.getType() === 'm.room.encrypted') { + event.once(MatrixEventEvent.Decrypted, handleEvent); + } else if (event.isSending()) { + event.once(MatrixEventEvent.LocalEventIdReplaced, handleEvent); + } else { + handleEvent(); + } + }, + [postToWorker] + ); + + const backfillRoom = useCallback( + async (room: Room, state: BackfillState) => { + if (state.done) return; + + if (!canRunMobileBackfill()) { + backfillingRoomsRef.current.delete(room.roomId); + backfillQueueRef.current.unshift({ room, state }); + Sentry.addBreadcrumb({ + category: 'search.backfill', + message: `Backfill deferred for room ${room.roomId}`, + data: { + reason: 'mobile_not_focused', + isMobile: !HAS_IDLE_CALLBACK, + visibilityState: document.visibilityState, + focused: document.hasFocus(), + }, + level: 'info', + }); + return; + } + + let headlessSet = headlessSetsRef.current.get(room.roomId); + if (!headlessSet) { + headlessSet = new EventTimelineSet(room, {}); + headlessSetsRef.current.set(room.roomId, headlessSet); + } + const headlessTimeline = headlessSet.getLiveTimeline(); + + const seedToken = + state.token ?? room.getLiveTimeline().getPaginationToken(Direction.Backward); + if (!seedToken) { + postToWorker({ + type: WorkerMessageTypeIn.SetBackfillState, + roomId: room.roomId, + state: { ...state, done: true }, + }); + backfillingRoomsRef.current.delete(room.roomId); + return; + } + + headlessTimeline.setPaginationToken(seedToken, Direction.Backward); + + const prevEventCount = headlessTimeline.getEvents().length; + + let hasMore = false; + try { + hasMore = await mx.paginateEventTimeline(headlessTimeline, { + backwards: true, + limit: BACKFILL_PAGE_SIZE, + }); + } catch { + backfillingRoomsRef.current.delete(room.roomId); + } + + const allEvents = headlessTimeline.getEvents(); + const newEvents = allEvents.slice(0, allEvents.length - prevEventCount); + + const recoveryFrontier = state.token === null ? state.oldestTs : undefined; + const unindexedEvents = + recoveryFrontier !== undefined + ? newEvents.filter((ev) => ev.getTs() < recoveryFrontier) + : newEvents; + + const events: SearchIndexEvent[] = []; + for (const ev of unindexedEvents) { + try { + const relatesTo = ev.getContent()?.['m.relates_to']; + if (relatesTo?.rel_type === 'm.thread' || ev.isRedacted()) { + continue; + } + + if (ev.getType() === 'm.room.encrypted') { + indexEvent(ev); + } else { + const indexable = toSearchIndexEvent(ev); + if (indexable) events.push(indexable); + } + } catch { + continue; + } + } + + if (events.length > 0) { + postToWorker({ type: WorkerMessageTypeIn.Index, events }); + } + + const nextToken = headlessTimeline.getPaginationToken(Direction.Backward); + const done = !hasMore && !nextToken; + + // Track the oldest event timestamp we've indexed so far. Only update when + // we actually processed new events (unindexedEvents may be empty on the + // first page of expired-token recovery while fast-forwarding the frontier). + const minTsThisPage = + unindexedEvents.length > 0 ? Math.min(...unindexedEvents.map((e) => e.getTs())) : undefined; + const newOldestTs = + minTsThisPage !== undefined + ? Math.min(state.oldestTs ?? Infinity, minTsThisPage) + : state.oldestTs; + + postToWorker({ + type: WorkerMessageTypeIn.SetBackfillState, + roomId: room.roomId, + state: { + token: nextToken, + done, + indexedCount: state.indexedCount + events.length, + oldestTs: newOldestTs, + }, + }); + + if (!done) { + // Schedule next page — but yield to the main sync if it's struggling. + // resumeBackfill will restart this room once sync recovers. + const nextState: BackfillState = { + token: nextToken, + done: false, + indexedCount: state.indexedCount + events.length, + oldestTs: newOldestTs, + }; + const cancel = scheduleIdle(() => { + const s = syncStateRef.current; + if (s !== SyncState.Syncing && s !== SyncState.Prepared && s !== SyncState.Catchup) { + backfillingRoomsRef.current.delete(room.roomId); + backfillQueueRef.current.unshift({ room, state: nextState }); + Sentry.addBreadcrumb({ + category: 'search.backfill', + message: `Backfill deferred for room ${room.roomId}`, + data: { reason: 'sync_not_healthy', syncState: s }, + level: 'info', + }); + return; + } + + if (!canRunMobileBackfill()) { + backfillingRoomsRef.current.delete(room.roomId); + backfillQueueRef.current.unshift({ room, state: nextState }); + Sentry.addBreadcrumb({ + category: 'search.backfill', + message: `Backfill deferred for room ${room.roomId}`, + data: { + reason: 'mobile_not_focused', + isMobile: !HAS_IDLE_CALLBACK, + visibilityState: document.visibilityState, + focused: document.hasFocus(), + }, + level: 'info', + }); + return; + } + void backfillRoom(room, nextState); + }); + cancelIdlesRef.current.push(cancel); + } else { + backfillingRoomsRef.current.delete(room.roomId); + // Dequeue the next room from the concurrency queue while under the limit + while ( + backfillingRoomsRef.current.size < MAX_CONCURRENT_BACKFILLS && + backfillQueueRef.current.length > 0 + ) { + const next = backfillQueueRef.current.shift()!; + backfillingRoomsRef.current.add(next.room.roomId); + const cancel = scheduleIdle(() => void backfillRoom(next.room, next.state)); + cancelIdlesRef.current.push(cancel); + } + if (backfillingRoomsRef.current.size === 0 && backfillQueueRef.current.length === 0) { + setIsBackfilling(false); + } + } + }, + [mx, postToWorker, indexEvent] + ); + + const resumeBackfill = useCallback(() => { + if (!backfillReadyRef.current) return; + const s = syncStateRef.current; + if (s !== SyncState.Syncing && s !== SyncState.Prepared && s !== SyncState.Catchup) return; + if (!canRunMobileBackfill()) return; + + while ( + backfillingRoomsRef.current.size < MAX_CONCURRENT_BACKFILLS && + backfillQueueRef.current.length > 0 + ) { + const next = backfillQueueRef.current.shift()!; + backfillingRoomsRef.current.add(next.room.roomId); + const cancel = scheduleIdle(() => void backfillRoom(next.room, next.state)); + cancelIdlesRef.current.push(cancel); + } + }, [backfillRoom]); + + const startBackfill = useCallback( + (backfillStates: Record) => { + backfillStatesRef.current = backfillStates; + const rooms = mx.getRooms().filter((r) => !r.isSpaceRoom()); + for (const room of rooms) { + const state = backfillStates[room.roomId] ?? { + token: null, + done: false, + indexedCount: 0, + }; + if (state.done) continue; + if (backfillingRoomsRef.current.has(room.roomId)) continue; + if (backfillQueueRef.current.some((e) => e.room.roomId === room.roomId)) continue; + + backfillQueueRef.current.push({ room, state }); + } + + if (backfillQueueRef.current.length > 0 || backfillingRoomsRef.current.size > 0) { + setIsBackfilling(true); + } + + backfillStartDelayRef.current = setTimeout(() => { + backfillStartDelayRef.current = null; + backfillReadyRef.current = true; + resumeBackfill(); + }, BACKFILL_STARTUP_DELAY_MS); + }, + [mx, resumeBackfill] + ); + + const handleWorkerMessage = useCallback( + (event: MessageEvent) => { + const msg = event.data; + + switch (msg.type) { + case WorkerMessageTypeOut.Ready: + setIsReady(true); + postToWorker({ type: WorkerMessageTypeIn.GetBackfillStates }); + break; + case WorkerMessageTypeOut.State: + const pending = pendingStatsRef.current; + if (pending) { + pendingStatsRef.current = null; + pending.resolve({ + indexedEventsCount: msg.indexedEventCount, + roomCount: msg.roomCount, + backfillingRoomCount: pending.backfillingRoomCount, + }); + } + break; + case WorkerMessageTypeOut.QueryResult: + const pendingQuery = pendingQueriesRef.current.get(msg.id); + if (pendingQuery) { + pendingQueriesRef.current.delete(msg.id); + pendingQuery.resolve(msg.events); + } + break; + case WorkerMessageTypeOut.BackfillStatesDone: + startBackfill(msg.states); + break; + } + }, + [startBackfill, postToWorker] + ); + + useEffect(() => { + if (!idbSearchIndex) { + setIsReady(false); + return () => {}; + } + let worker: Worker; + try { + Sentry.addBreadcrumb({ + category: 'search.index', + message: 'Initializing search worker', + level: 'info', + }); + worker = new CreateSearchWorker(); + } catch { + return () => {}; + } + + const userId = mx.getUserId(); + if (!userId) return () => {}; + + Sentry.addBreadcrumb({ + category: 'search.index', + message: 'Initializing search worker', + level: 'info', + data: { userId }, + }); + + workerRef.current = worker; + worker.addEventListener('message', handleWorkerMessage); + + Sentry.addBreadcrumb({ + category: 'search.index', + message: 'INIT sent to worker', + level: 'info', + data: { userId }, + }); + + postToWorker({ + type: WorkerMessageTypeIn.Init, + userId, + }); + + const handleSync = (state: SyncState) => { + syncStateRef.current = state; + if (state === SyncState.Syncing) { + resumeBackfill(); + } + }; + mx.on(ClientEvent.Sync, handleSync as unknown as (...args: unknown[]) => void); + + const handleTimeline = (mEvent: MatrixEvent, room: Room | undefined) => { + const relation = mEvent.getRelation(); + + if (relation && relation.rel_type === 'm.replace') { + const targetEventId = relation.event_id; + if (!targetEventId) return; + const searchEvent = toSearchIndexEvent(mEvent, true); + if (!searchEvent) return; + + postToWorker({ + type: WorkerMessageTypeIn.EditEvents, + events: { + [targetEventId]: searchEvent, + }, + }); + return; + } + + if (!room) return; + indexEvent(mEvent); + }; + mx.on(RoomEvent.Timeline, handleTimeline); + + const handleRedaction = (mEvent: MatrixEvent) => { + if (mEvent.getType() !== EventType.RoomRedaction.toString()) return; + let eventId = mEvent.event.redacts; + if (!eventId) return; + + postToWorker({ + type: WorkerMessageTypeIn.RedactEvents, + eventIds: [eventId], + }); + }; + mx.on(RoomEvent.Redaction, handleRedaction); + + const handleRoomAdded = (room: Room) => { + if (room.isSpaceRoom()) return; + if (backfillingRoomsRef.current.has(room.roomId)) return; + if (backfillQueueRef.current.some((e) => e.room.roomId === room.roomId)) return; + const state = backfillStatesRef.current[room.roomId] ?? { + token: null, + done: false, + indexedCount: 0, + }; + if (state.done) return; + backfillQueueRef.current.push({ room, state }); + setIsBackfilling(true); + resumeBackfill(); + }; + mx.on(ClientEvent.Room, handleRoomAdded); + + const handleForegroundFocus = () => { + if (canRunMobileBackfill()) { + resumeBackfill(); + } + }; + if (!HAS_IDLE_CALLBACK) { + document.addEventListener('visibilitychange', handleForegroundFocus); + window.addEventListener('focus', handleForegroundFocus); + window.addEventListener('pageshow', handleForegroundFocus); + } + + const handleOnBeforeUnload = () => { + postToWorker({ type: WorkerMessageTypeIn.Flush }); + + worker.addEventListener( + 'message', + (ev: MessageEvent) => { + if (ev.data.type === WorkerMessageTypeOut.FlushDone) { + worker.removeEventListener('message', handleWorkerMessage); + worker.terminate(); + workerRef.current = null; + } + }, + { once: true } + ); + }; + + window.addEventListener('beforeunload', handleOnBeforeUnload); + + return () => { + mx.removeListener(ClientEvent.Sync, handleSync); + mx.removeListener(RoomEvent.Timeline, handleTimeline); + mx.removeListener(ClientEvent.Room, handleRoomAdded); + mx.removeListener(RoomEvent.Redaction, handleRedaction); + worker.removeEventListener('message', handleWorkerMessage); + // worker.removeEventListener('error', handleWorkerError); + window.removeEventListener('beforeunload', handleOnBeforeUnload); + + setIsReady(false); + setIsBackfilling(false); + }; + }, [idbSearchIndex, mx, handleWorkerMessage, indexEvent, postToWorker, resumeBackfill]); + + const query = useCallback( + ( + term: string, + opts?: { roomIds?: string[]; senders?: string[]; hasTypes?: string[] } + ): Promise => { + if (!workerRef.current || !isReady) return Promise.resolve([]); + const id = crypto.randomUUID(); + return new Promise((resolve, reject) => { + pendingQueriesRef.current.set(id, { resolve, reject }); + postToWorker({ + type: WorkerMessageTypeIn.Query, + id, + term, + maxResults, + roomIds: opts?.roomIds, + senders: opts?.senders, + hasTypes: opts?.hasTypes, + }); + }); + }, + [isReady, maxResults, postToWorker] + ); + + const state = useCallback((): Promise => { + if (!workerRef.current || !isReady) { + return Promise.resolve({ + indexedEventsCount: 0, + roomCount: 0, + backfillingRoomCount: 0, + }); + } + return new Promise((resolve) => { + pendingStatsRef.current = { + backfillingRoomCount: backfillingRoomsRef.current.size, + resolve, + }; + postToWorker({ type: WorkerMessageTypeIn.State }); + }); + }, [isReady, postToWorker]); + + const clearIndex = useCallback((): Promise => { + if (!workerRef.current || !isReady) { + return Promise.resolve(); + } + postToWorker({ type: WorkerMessageTypeIn.Clear }); + headlessSetsRef.current.clear(); + for (const cancel of cancelIdlesRef.current) cancel(); + cancelIdlesRef.current = []; + backfillingRoomsRef.current.clear(); + setIsBackfilling(false); + return Promise.resolve(); + }, [isReady, postToWorker]); + + const ctx = useMemo( + () => ({ query, state, clearIndex, isBackfilling, ready: isReady }), + [query, state, clearIndex, isReady, isBackfilling] + ); + + return {children}; +} diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index c18d1ba152..1ec3cb0231 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -17,12 +17,11 @@ import { useRoomNavigate } from '$hooks/useRoomNavigate'; import { ScrollTopContainer } from '$components/scroll-top-container'; import { ContainerColor } from '$styles/ContainerColor.css'; import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '$pages/pathUtils'; -import { useRooms } from '$state/hooks/roomList'; import { allRoomsAtom } from '$state/room-list/roomList'; import { mDirectAtom } from '$state/mDirectList'; import { VirtualTile } from '$components/virtualizer'; -import type { MessageSearchParams } from './useMessageSearch'; -import { useMessageSearch } from './useMessageSearch'; +import type { MessageSearchParams, SearchHasType } from './useMessageSearch'; +import { useMessageSearch, VALID_HAS_TYPES } from './useMessageSearch'; import { SearchResultGroup } from './SearchResultGroup'; import { SearchInput } from './SearchInput'; import { SearchFilters } from './SearchFilters'; @@ -35,6 +34,7 @@ const useSearchPathSearchParams = (searchParams: URLSearchParams): SearchPathSea order: searchParams.get('order') ?? undefined, rooms: searchParams.get('rooms') ?? undefined, senders: searchParams.get('senders') ?? undefined, + has: searchParams.get('has') ?? undefined, }), [searchParams] ); @@ -55,7 +55,7 @@ export function MessageSearch({ }: Readonly) { const mx = useMatrixClient(); const mDirects = useAtomValue(mDirectAtom); - const allRooms = useRooms(mx, allRoomsAtom, mDirects); + const allRooms = useAtomValue(allRoomsAtom); const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad'); const [urlPreview] = useSetting(settingsAtom, 'urlPreview'); const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor'); @@ -84,6 +84,13 @@ export function MessageSearch({ } return undefined; }, [searchPathSearchParams.senders]); + const searchParamHasTypes = useMemo(() => { + if (!searchPathSearchParams.has) return undefined; + const decoded = decodeSearchParamValueArray(searchPathSearchParams.has).filter( + (t): t is SearchHasType => VALID_HAS_TYPES.includes(t as SearchHasType) + ); + return decoded.length > 0 ? decoded : undefined; + }, [searchPathSearchParams.has]); const msgSearchParams: MessageSearchParams = useMemo(() => { const isGlobal = searchPathSearchParams.global === 'true'; @@ -94,19 +101,33 @@ export function MessageSearch({ order: searchPathSearchParams.order ?? SearchOrderBy.Recent, rooms: searchParamRooms ?? defaultRooms, senders: searchParamsSenders ?? senders, + hasTypes: searchParamHasTypes, }; - }, [searchPathSearchParams, searchParamRooms, searchParamsSenders, rooms, senders]); + }, [ + searchPathSearchParams, + searchParamRooms, + searchParamsSenders, + searchParamHasTypes, + rooms, + senders, + ]); + + const isSearching = + !!msgSearchParams.term || + (!!msgSearchParams.hasTypes && msgSearchParams.hasTypes.length > 0) || + (!!msgSearchParams.senders && msgSearchParams.senders.length > 0); const searchMessages = useMessageSearch(msgSearchParams); const { status, data, error, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - enabled: !!msgSearchParams.term, + enabled: isSearching, queryKey: [ 'search', msgSearchParams.term, msgSearchParams.order, msgSearchParams.rooms, msgSearchParams.senders, + msgSearchParams.hasTypes, ], queryFn: ({ pageParam }) => searchMessages(pageParam), initialPageParam: '', @@ -178,6 +199,28 @@ export function MessageSearch({ }); }; + const handleHasTypesChange = (hasTypes?: SearchHasType[]) => { + setSearchParams((prevParams) => { + const newParams = new URLSearchParams(prevParams); + newParams.delete('has'); + if (hasTypes && hasTypes.length > 0) { + newParams.append('has', encodeSearchParamValueArray(hasTypes)); + } + return newParams; + }); + }; + + const handleSendersChange = (newSenders?: string[]) => { + setSearchParams((prevParams) => { + const newParams = new URLSearchParams(prevParams); + newParams.delete('senders'); + if (newSenders && newSenders.length > 0) { + newParams.append('senders', encodeSearchParamValueArray(newSenders)); + } + return newParams; + }); + }; + const lastVItem = vItems.at(-1); const lastVItemIndex: number | undefined = lastVItem?.index; const lastGroupIndex = groups.length - 1; @@ -224,6 +267,10 @@ export function MessageSearch({ onGlobalChange={handleGlobalChange} order={msgSearchParams.order} onOrderChange={handleOrderChange} + hasTypes={searchParamHasTypes} + onHasTypesChange={handleHasTypesChange} + senders={searchParamsSenders ?? senders} + onSendersChange={handleSendersChange} />
diff --git a/src/app/features/message-search/SearchFilters.tsx b/src/app/features/message-search/SearchFilters.tsx index cc6487e081..192108e988 100644 --- a/src/app/features/message-search/SearchFilters.tsx +++ b/src/app/features/message-search/SearchFilters.tsx @@ -1,10 +1,13 @@ import type { ChangeEventHandler, MouseEventHandler } from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { RectCords } from 'folds'; import { + Avatar, Box, Chip, Text, + Icon, + Icons, Line, config, PopOut, @@ -17,12 +20,13 @@ import { Input, Badge, } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { getRoomIconComponent } from '$components/icons/roomIcons'; -import { Check, sizedIcon, PlusCircle, SortAscending, X } from '$components/icons/phosphor'; +import type { IconSrc } from 'folds'; import { SearchOrderBy } from '$types/matrix-sdk'; +import type { RoomMember } from '$types/matrix-sdk'; +import FocusTrap from 'focus-trap-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { getRoomIconSrc } from '$utils/room'; import { factoryRoomIdByAtoZ } from '$utils/sort'; import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch'; import { useAsyncSearch } from '$hooks/useAsyncSearch'; @@ -30,6 +34,9 @@ import type { DebounceOptions } from '$hooks/useDebounce'; import { useDebounce } from '$hooks/useDebounce'; import { VirtualTile } from '$components/virtualizer'; import { stopPropagation } from '$utils/keyboard'; +import { UserAvatar } from '$components/user-avatar'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import type { SearchHasType } from './useMessageSearch'; type OrderButtonProps = { order?: string; @@ -93,7 +100,7 @@ function OrderButton({ order, onChange }: OrderButtonProps) { } onClick={handleOpenMenu} > {rankOrder ? Relevance : Recent} @@ -262,10 +269,19 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto size="300" radii="300" aria-pressed={selected} - before={sizedIcon( - getRoomIconComponent(room.getType(), room.getJoinRule()), - '50' - )} + before={ + + } + after={ + mx.isRoomEncrypted(roomId) ? ( + + + + ) : null + } > {room.name} @@ -306,7 +322,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto onClick={handleOpenMenu} variant="SurfaceVariant" radii="Pill" - before={sizedIcon(PlusCircle, '100')} + before={} > Select Rooms @@ -314,6 +330,243 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto ); } +const HAS_FILTER_OPTIONS: { type: SearchHasType; label: string; icon: IconSrc }[] = [ + { type: 'image', label: 'Image', icon: Icons.Photo }, + { type: 'file', label: 'File', icon: Icons.File }, + { type: 'audio', label: 'Audio', icon: Icons.VolumeHigh }, + { type: 'video', label: 'Video', icon: Icons.Play }, + { type: 'link', label: 'Link', icon: Icons.Link }, +]; + +type HasFilterChipsProps = { + hasTypes?: SearchHasType[]; + onChange: (hasTypes?: SearchHasType[]) => void; +}; +function HasFilterChips({ hasTypes, onChange }: HasFilterChipsProps) { + const toggle = (type: SearchHasType) => { + if (hasTypes?.includes(type)) { + const next = hasTypes.filter((t) => t !== type); + onChange(next.length > 0 ? next : undefined); + } else { + onChange([...(hasTypes ?? []), type]); + } + }; + + return ( + <> + {HAS_FILTER_OPTIONS.map(({ type, label, icon }) => { + const active = hasTypes?.includes(type); + return ( + : } + outlined + onClick={() => toggle(type)} + > + {label} + + ); + })} + + ); +} + +type SelectSenderButtonProps = { + roomList: string[]; + selectedSenders?: string[]; + onChange: (senders?: string[]) => void; +}; + +const SENDER_SEARCH_OPTS: UseAsyncSearchOptions = { limit: 50, matchOptions: { contain: true } }; +const SENDER_DEBOUNCE_OPTS: DebounceOptions = { wait: 200 }; +const getMemberStr: SearchItemStrGetter = (member, query) => { + const name = member.name ?? member.userId; + return query ? [name, member.userId] : name; +}; +const getMemberDisplayName = (member: RoomMember): string => member.name ?? member.userId; +function SelectSenderButton({ roomList, selectedSenders, onChange }: SelectSenderButtonProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const [menuAnchor, setMenuAnchor] = useState(); + const scrollRef = useRef(null); + + const members = useMemo(() => { + const seen = new Set(); + const result: RoomMember[] = []; + const scope = roomList.length > 0 ? roomList : []; + for (const roomId of scope) { + const room = mx.getRoom(roomId); + if (!room) continue; + for (const m of room.getMembers()) { + if (!seen.has(m.userId)) { + seen.add(m.userId); + result.push(m); + } + } + } + return result.toSorted((a, b) => + getMemberDisplayName(a).localeCompare(getMemberDisplayName(b)) + ); + }, [mx, roomList]); + + const [searchState, searchMembersRaw, resetSearch] = useAsyncSearch( + members, + getMemberStr, + SENDER_SEARCH_OPTS + ); + const searchMembers = useDebounce(searchMembersRaw, SENDER_DEBOUNCE_OPTS); + const handleSearchChange: ChangeEventHandler = (evt) => { + const value = evt.currentTarget.value.trim(); + if (!value) { + resetSearch(); + return; + } + searchMembers(value); + }; + + const displayMembers = searchState?.items ?? members; + + const virtualizer = useVirtualizer({ + count: displayMembers.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 32 + 4, + overscan: 10, + }); + const vItems = virtualizer.getVirtualItems(); + + const handleOpenMenu: MouseEventHandler = (evt) => { + setMenuAnchor(evt.currentTarget.getBoundingClientRect()); + }; + + const handleMemberClick: MouseEventHandler = (evt) => { + const userId = evt.currentTarget.getAttribute('data-user-id'); + if (!userId) return; + if (selectedSenders?.includes(userId)) { + const next = selectedSenders.filter((s) => s !== userId); + onChange(next.length > 0 ? next : undefined); + } else { + onChange([...(selectedSenders ?? []), userId]); + } + }; + + return ( + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + From + 0 ? ( + + {searchState.items.length} + + ) : null + } + /> + + + + {displayMembers.length === 0 && ( + + No members found + + )} +
+ {vItems.map((vItem) => { + const member = displayMembers[vItem.index]!; + const selected = selectedSenders?.includes(member.userId); + return ( + + + } + /> + + } + > + + {getMemberDisplayName(member)} + + + + ); + })} +
+
+
+
+
+ + } + > + } + > + Add Sender + +
+ ); +} + type SearchFiltersProps = { defaultRoomsFilterName: string; allowGlobal?: boolean; @@ -324,6 +577,10 @@ type SearchFiltersProps = { onGlobalChange: (global?: boolean) => void; order?: string; onOrderChange: (order?: string) => void; + hasTypes?: SearchHasType[]; + onHasTypesChange: (hasTypes?: SearchHasType[]) => void; + senders?: string[]; + onSendersChange: (senders?: string[]) => void; }; export function SearchFilters({ defaultRoomsFilterName, @@ -335,7 +592,12 @@ export function SearchFilters({ order, onGlobalChange, onOrderChange, + hasTypes, + onHasTypesChange, + senders, + onSendersChange, }: SearchFiltersProps) { + const senderScope = selectedRooms && selectedRooms.length > 0 ? selectedRooms : roomList; const mx = useMatrixClient(); return ( @@ -345,7 +607,7 @@ export function SearchFilters({ } outlined onClick={() => onGlobalChange()} > @@ -355,7 +617,7 @@ export function SearchFilters({ } outlined onClick={() => onGlobalChange(true)} > @@ -378,8 +640,10 @@ export function SearchFilters({ variant="Success" onClick={() => onSelectedRoomsChange(selectedRooms.filter((rId) => rId !== roomId))} radii="Pill" - before={sizedIcon(getRoomIconComponent(room.getType(), room.getJoinRule()), '50')} - after={sizedIcon(X, '50')} + before={ + + } + after={} > {room.name} @@ -393,6 +657,41 @@ export function SearchFilters({ + + + Has: + + + + + From: + + {senders?.map((sender) => ( + { + const next = senders.filter((s) => s !== sender); + onSendersChange(next.length > 0 ? next : undefined); + }} + radii="Pill" + before={} + after={} + > + {mx.getUser(sender)?.displayName ?? sender} + + ))} + +
); } diff --git a/src/app/features/message-search/searchEncryptedRooms.ts b/src/app/features/message-search/searchEncryptedRooms.ts new file mode 100644 index 0000000000..d8a69e6425 --- /dev/null +++ b/src/app/features/message-search/searchEncryptedRooms.ts @@ -0,0 +1,135 @@ +import { EventType } from '$types/matrix-sdk'; +import type { IEventWithRoomId, MatrixClient, MatrixEvent } from '$types/matrix-sdk'; +import type { SearchHasType } from './useMessageSearch'; +import { HAS_TYPE_TO_MSGTYPE, type ResultGroup, type ResultItem } from './useMessageSearch'; + +function toSearchEvent(mEvent: MatrixEvent, roomId: string): IEventWithRoomId { + return { + event_id: mEvent.getId() ?? '', + room_id: roomId, + sender: mEvent.getSender() ?? '', + origin_server_ts: mEvent.getTs(), + content: mEvent.getContent(), + type: mEvent.getType(), + unsigned: mEvent.getUnsigned(), + } as IEventWithRoomId; +} + +function mEventMatchesHasTypes(mEvent: MatrixEvent, hasTypes: SearchHasType[]): boolean { + const content = mEvent.getContent() as { msgtype?: string; body?: string }; + for (const type of hasTypes) { + const msgtype = HAS_TYPE_TO_MSGTYPE[type]; + if (msgtype && content.msgtype === msgtype) return true; + if (type === 'link' && /https?:\/\//i.test(content.body ?? '')) return true; + } + return false; +} + +/** + * Searches a single room's live timeline for message events that contain + * `term` in their body. Returns a ResultGroup or undefined if no matches. + */ +export function searchRoomTimeline( + room: { roomId: string; getLiveTimeline: () => { getEvents: () => MatrixEvent[] } }, + term: string, + senders?: string[], + hasTypes?: SearchHasType[] +): ResultGroup | undefined { + const events = room.getLiveTimeline().getEvents(); + const items: ResultItem[] = []; + + for (const event of events) { + if (event.getType() !== EventType.RoomMessage.toString()) continue; + if (event.isBeingDecrypted() || event.isDecryptionFailure()) continue; + + const sender = event.getSender(); + if (!sender) continue; + if (senders && !senders.includes(sender)) continue; + + if (hasTypes && hasTypes.length > 0 && !mEventMatchesHasTypes(event, hasTypes)) continue; + + if (!event.getId()) continue; + + if (term !== '') { + const body: string = event.getContent().body ?? ''; + if (!body || !body.toLowerCase().includes(term.toLowerCase())) continue; // TODO: fuzzy search? + } + + items.push({ + rank: 1, + event: toSearchEvent(event, room.roomId), + context: { + events_before: [], + events_after: [], + profile_info: {}, + }, + }); + } + + if (items.length === 0) return undefined; + + items.sort((a, b) => b.event.origin_server_ts - a.event.origin_server_ts); + + return { roomId: room.roomId, items }; +} + +/** + * Searches the in-memory live timeline of each listed encrypted room. + * Returns one ResultGroup per room that has at least one match. + */ +export function searchEncryptedRoomsInMemory( + mx: Pick, + term: string, + encryptedRoomIds: string[], + senders?: string[], + hasTypes?: SearchHasType[] +): ResultGroup[] { + const groups: ResultGroup[] = []; + + for (const roomId of encryptedRoomIds) { + const room = mx.getRoom(roomId); + if (!room) continue; + + const group = searchRoomTimeline(room, term, senders, hasTypes); + if (group) groups.push(group); + } + + return groups; +} + +/** + * Splits the user's room filter into encrypted (in-memory) and plaintext (server) buckets. + * + * - When `rooms` is undefined (global search), the server handles plaintext rooms and + * we additionally scan all joined encrypted rooms in memory. + * - When `rooms` is defined, each room is routed to the appropriate search path. + */ +export function partitionRoomsByEncryption( + mx: Pick, + rooms?: string[] +): { encryptedRoomIds: string[]; serverRooms: string[] | undefined; skipServerSearch: boolean } { + let allRooms = mx.getRooms(); + if (rooms === undefined) { + const encryptedRoomIds = allRooms + .filter((r) => r.hasEncryptionStateEvent()) + .map((r) => r.roomId); + return { encryptedRoomIds, serverRooms: undefined, skipServerSearch: false }; + } + + const encryptedRoomIds: string[] = []; + const serverRooms: string[] = []; + + for (const roomId of rooms) { + if (allRooms.find((r) => r.roomId == roomId)?.hasEncryptionStateEvent()) { + encryptedRoomIds.push(roomId); + } else { + serverRooms.push(roomId); + } + } + + return { + encryptedRoomIds, + serverRooms: serverRooms.length > 0 ? serverRooms : undefined, + skipServerSearch: rooms.length > 0 && serverRooms.length === 0, + }; +} diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index dfe205f691..be7fee34c0 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -4,10 +4,91 @@ import type { ISearchRequestBody, ISearchResponse, ISearchResult, + MatrixClient, + MatrixEvent, SearchOrderBy, } from '$types/matrix-sdk'; import { useCallback } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; +import { searchEncryptedRoomsInMemory, partitionRoomsByEncryption } from './searchEncryptedRooms'; +import { useSearchIndex } from '$hooks/useSearchIndex'; +import { useAtomValue } from 'jotai'; +import { settingsAtom } from '$state/settings'; +import type { SearchIndexEvent } from '$plugins/search-indexer/types'; + +export function toSearchEvent(mEvent: MatrixEvent, roomId: string): IEventWithRoomId { + return { + event_id: mEvent.getId() ?? '', + room_id: roomId, + sender: mEvent.getSender() ?? '', + origin_server_ts: mEvent.getTs(), + content: mEvent.getContent(), // decrypted content for e2ee events + type: mEvent.getType(), // decrypted event type (e.g. m.room.message, not m.room.encrypted) + unsigned: mEvent.getUnsigned(), + } as IEventWithRoomId; +} + +function idbEventsToGroups( + mx: MatrixClient, + events: SearchIndexEvent[], + order?: string +): ResultGroup[] { + const byRoom = new Map(); + for (const ev of events) { + const liveEvent = mx.getRoom(ev.roomId)?.findEventById(ev.eventId); + const eventData: IEventWithRoomId = liveEvent + ? toSearchEvent(liveEvent, ev.roomId) + : ({ + event_id: ev.eventId, + room_id: ev.roomId, + sender: ev.sender, + origin_server_ts: ev.ts, + content: { + msgtype: ev.msgtype, + body: ev.body, + ...(ev.url !== undefined && { url: ev.url }), + ...(ev.file !== undefined && { file: ev.file }), + ...(ev.info !== undefined && { info: ev.info }), + ...(ev.filename !== undefined && { filename: ev.filename }), + }, + type: 'm.room.message', + unsigned: {}, + } as IEventWithRoomId); + const item: ResultItem = { + rank: 1, + event: eventData, + context: { + events_before: [], + events_after: [], + profile_info: {}, + }, + }; + const arr = byRoom.get(ev.roomId) ?? []; + arr.push(item); + byRoom.set(ev.roomId, arr); + } + + const groups = Array.from(byRoom.entries()).map(([roomId, items]) => ({ + roomId, + // Sort items newest-first so items[0] is always the most recent — required + // for mergeSearchGroups' timestamp comparisons to be correct. + items: + order !== 'rank' + ? items.toSorted( + (a, b) => (b.event.origin_server_ts ?? 0) - (a.event.origin_server_ts ?? 0) + ) + : items, + })); + + // Sort groups newest-first so single-source fast-paths in mergeSearchGroups + // (which return the array unchanged) still produce correct recent order. + return order !== 'rank' + ? groups.toSorted( + (a, b) => + (b.items[0]?.event.origin_server_ts ?? 0) - (a.items[0]?.event.origin_server_ts ?? 0) + ) + : groups; +} export type ResultItem = { rank: number; @@ -26,6 +107,16 @@ export type SearchResult = { groups: ResultGroup[]; }; +export type SearchHasType = 'image' | 'file' | 'audio' | 'video' | 'link'; + +export const VALID_HAS_TYPES: SearchHasType[] = ['image', 'file', 'audio', 'video', 'link']; +export const HAS_TYPE_TO_MSGTYPE: Record = { + image: 'm.image', + file: 'm.file', + audio: 'm.audio', + video: 'm.video', +}; + const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groups: ResultGroup[] = []; @@ -68,19 +159,86 @@ export type MessageSearchParams = { order?: string; rooms?: string[]; senders?: string[]; + hasTypes?: SearchHasType[]; +}; + +const mergeSearchGroups = ( + serverGroups: ResultGroup[], + inMemoryGroups: ResultGroup[], + order?: string +): ResultGroup[] => { + if (inMemoryGroups.length === 0) return serverGroups; + if (serverGroups.length === 0) return inMemoryGroups; + + const all = [...serverGroups, ...inMemoryGroups]; + + if (order === 'rank') { + return all; + } + + return all.toSorted((a, b) => { + const aTs = a.items[0]?.event.origin_server_ts ?? 0; + const bTs = b.items[0]?.event.origin_server_ts ?? 0; + return bTs - aTs; + }); }; + export const useMessageSearch = (params: MessageSearchParams) => { const mx = useMatrixClient(); - const { term, order, rooms, senders } = params; + const searchIndex = useSearchIndex(); + const settings = useAtomValue(settingsAtom); + + const { term, order, rooms, senders, hasTypes } = params; const searchMessages = useCallback( async (nextBatch?: string) => { - if (!term) + const idbSearchAvailable = settings.idbSearchIndex && !!searchIndex?.ready; + const hasHasTypes = hasTypes && hasTypes.length > 0; + if (!(term || (idbSearchAvailable && (hasHasTypes || senders)))) return { highlights: [], groups: [], }; - const limit = 20; + + const isFirstPage = !nextBatch || nextBatch === ''; + + if (idbSearchAvailable) { + const idbEvents = await searchIndex.query(term ?? '', { + roomIds: rooms ?? [], + senders, + hasTypes: hasHasTypes ? hasTypes : undefined, + }); + + let foundGroups = idbEventsToGroups(mx, idbEvents, order); + return { + highlights: [], + groups: foundGroups, + }; + } + + const { encryptedRoomIds, serverRooms } = partitionRoomsByEncryption(mx, rooms); + let skipServerSearch = !serverRooms; + + let foundGroups: ResultGroup[] = []; + + if (isFirstPage && (rooms ?? []).length > 0) { + if (term || hasHasTypes) { + foundGroups = searchEncryptedRoomsInMemory( + mx, + term ?? '', + encryptedRoomIds, + senders, + hasTypes + ); + } + } + + if (skipServerSearch) { + return { + groups: foundGroups, + highlights: [], + }; + } const requestBody: ISearchRequestBody = { search_categories: { @@ -91,13 +249,12 @@ export const useMessageSearch = (params: MessageSearchParams) => { include_profile: false, }, filter: { - limit, - rooms, senders, + rooms: serverRooms, }, include_state: false, order_by: order as SearchOrderBy.Recent, - search_term: term, + search_term: term ?? '', }, }, }; @@ -106,9 +263,15 @@ export const useMessageSearch = (params: MessageSearchParams) => { body: requestBody, next_batch: nextBatch === '' ? undefined : nextBatch, }); - return parseSearchResult(r); + + const serverResult = parseSearchResult(r); + const filteredServerResult = { + ...serverResult, + groups: mergeSearchGroups(foundGroups, serverResult.groups), + }; + return filteredServerResult; }, - [mx, term, order, rooms, senders] + [mx, term, order, rooms, senders, hasTypes, settings, searchIndex] ); return searchMessages; diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 5aec609112..0be32bb161 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -62,7 +62,12 @@ import { useIsDirectRoom, useRoom } from '$hooks/useRoom'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; import { useSpaceOptionally } from '$hooks/useSpace'; -import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '$pages/pathUtils'; +import { + getDirectSearchPath, + getHomeSearchPath, + getSpaceSearchPath, + withSearchParam, +} from '$pages/pathUtils'; import { createLogger } from '$utils/debug'; import { getCanonicalAliasOrRoomId, @@ -364,6 +369,7 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { const direct = useIsDirectRoom(); const [customDMCards] = useSetting(settingsAtom, 'customDMCards'); const { microphone, video, sound } = useCallPreferences(); + const idbSearchAvailable = useSetting(settingsAtom, 'idbSearchIndex'); const [chat, setChat] = useAtom(callChatAtom); const [threadBrowserOpen, setThreadBrowserOpen] = useAtom( @@ -562,7 +568,9 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) { }; const path = space ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId)) - : getHomeSearchPath(); + : direct + ? getDirectSearchPath() + : getHomeSearchPath(); navigate(withSearchParam(path, searchParams)); }; @@ -679,25 +687,24 @@ export function RoomViewHeader({ callView }: Readonly<{ callView?: boolean }>) {
- {(!room.isCallRoom() || chat) && ( + {(!room.isCallRoom() || chat) && (!encryptedRoom || idbSearchAvailable) && ( <> - {!encryptedRoom && ( - - Search - - } - > - {(triggerRef) => ( - - {composerIcon(MagnifyingGlass)} - - )} - - )} + + Search + + } + > + {(triggerRef) => ( + + {composerIcon(MagnifyingGlass)} + + )} + + Type # for rooms, @ for DMs and * for spaces. Hotkey:{' '} {isMacOS() ? KeySymbol.Command : 'Ctrl'} + k - {' / '} - {isMacOS() ? KeySymbol.Command : 'Ctrl'} + f )} diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726d..ef71a18a85 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -15,6 +15,7 @@ import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; +import { SearchIndexCache } from './SearchIndexCache'; type DeveloperToolsProps = { requestBack?: () => void; @@ -127,6 +128,11 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} + {developerTools && ( + + + + )}
diff --git a/src/app/features/settings/developer-tools/SearchIndexCache.tsx b/src/app/features/settings/developer-tools/SearchIndexCache.tsx new file mode 100644 index 0000000000..9bb7e7c9e9 --- /dev/null +++ b/src/app/features/settings/developer-tools/SearchIndexCache.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Box, Button, Text } from 'folds'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useSearchIndex } from '$hooks/useSearchIndex'; +import type { SearchIndexState } from '$hooks/useSearchIndex'; +import { SequenceCardStyle } from '$features/settings/styles.css'; + +export function SearchIndexCache() { + const [idbSearchIndex] = useSetting(settingsAtom, 'idbSearchIndex'); + const searchIndex = useSearchIndex(); + + const [stats, setStats] = useState(null); + const [clearing, setClearing] = useState(false); + + const refreshStats = useCallback(async () => { + if (!searchIndex?.ready) return; + const s = await searchIndex.state(); + setStats(s); + }, [searchIndex]); + + useEffect(() => { + void refreshStats(); + const id = window.setInterval(() => void refreshStats(), 5000); + return () => window.clearInterval(id); + }, [refreshStats]); + + const handleClear = useCallback(async () => { + if (!searchIndex) return; + setClearing(true); + await searchIndex.clearIndex(); + setStats(null); + setClearing(false); + }, [searchIndex]); + + if (!idbSearchIndex) return null; + + return ( + + Message Search Index + + + {searchIndex?.isBackfilling && ( + + )} + void handleClear()} + variant="Secondary" + fill="Soft" + size="300" + radii="300" + outlined + disabled={clearing} + > + {clearing ? 'Clearing…' : 'Clear'} + + } + /> + + + ); +} diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 085355f40f..889485f5bf 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -12,6 +12,7 @@ import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; import { MSC4274MediaGalleries } from './MSC4274MediaGalleries'; +import { SearchIDBToggle } from './SearchIDB'; function PersonaToggle() { const [showPersonaSetting, setShowPersonaSetting] = useSetting( @@ -65,6 +66,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + diff --git a/src/app/features/settings/experimental/SearchIDB.tsx b/src/app/features/settings/experimental/SearchIDB.tsx new file mode 100644 index 0000000000..678bd36c91 --- /dev/null +++ b/src/app/features/settings/experimental/SearchIDB.tsx @@ -0,0 +1,102 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/common-settings/styles.css'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { Box, Input, Switch, Text, toRem } from 'folds'; +import { isKeyHotkey } from 'is-hotkey'; +import type { ChangeEventHandler, KeyboardEventHandler } from 'react'; +import { useState } from 'react'; + +function MaxResultsInput({ disabled }: { disabled: boolean }) { + const [maxCount, setMaxCount] = useSetting(settingsAtom, 'idbSearchMaxResults'); + const [inputValue, setInputValue] = useState(maxCount.toString()); + + const handleChange: ChangeEventHandler = (evt) => { + const val = evt.target.value; + setInputValue(val); + + const parsed = Number.parseInt(val, 10); + if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 10) { + setMaxCount(parsed); + } + }; + + const handleKeyDown: KeyboardEventHandler = (evt) => { + if (isKeyHotkey('escape', evt)) { + evt.stopPropagation(); + setInputValue(maxCount.toString()); + (evt.target as HTMLInputElement).blur(); + } + + if (isKeyHotkey('enter', evt)) { + (evt.target as HTMLInputElement).blur(); + } + }; + + return ( + + ); +} + +export function SearchIDBToggle() { + const [enabledIdbSearchIndex, setEnabledIdbSearchIndex] = useSetting( + settingsAtom, + 'idbSearchIndex' + ); + return ( + + Enable Local Message Indexing + + + } + /> + + + } + /> + + + ); +} diff --git a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx index ff1ea36caa..7fa0e997ef 100644 --- a/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx +++ b/src/app/features/settings/keyboard-shortcuts/KeyboardShortcuts.tsx @@ -30,7 +30,10 @@ function formatKey(key: string): string { const SHORTCUT_CATEGORIES: ShortcutCategory[] = [ { name: 'General', - shortcuts: [{ keys: 'Ctrl+F / ⌘+F', description: 'Search for messages' }], + shortcuts: [ + { keys: 'Ctrl+F / ⌘+F', description: 'Search for messages in the current room' }, + { keys: 'Ctrl+Shift+F / ⌘+Shift+F', description: 'Search for messages globally' }, + ], }, { name: 'Navigation', diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index e39fc9b33b..930c776924 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -215,6 +215,8 @@ const settingsLinkFocusIdsBySection: Record { const directMatch = useMatch({ @@ -20,3 +20,13 @@ export const useDirectCreateSelected = (): boolean => { return !!match; }; + +export const useDirectSearchSelected = (): boolean => { + const match = useMatch({ + path: getDirectSearchPath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/hooks/useSearchIndex.ts b/src/app/hooks/useSearchIndex.ts new file mode 100644 index 0000000000..ebcc45a3d3 --- /dev/null +++ b/src/app/hooks/useSearchIndex.ts @@ -0,0 +1,25 @@ +import type { SearchIndexEvent } from '$plugins/search-indexer/types'; +import { createContext, useContext } from 'react'; + +export type SearchIndexState = { + indexedEventsCount: number; + roomCount: number; + backfillingRoomCount: number; +}; + +export type SearchIndexContextType = { + clearIndex: () => Promise; + query: ( + term: string, + opts?: { roomIds?: string[]; senders?: string[]; hasTypes?: string[] } + ) => Promise; + state: () => Promise; + isBackfilling: boolean; + ready: boolean; +}; + +export const SearchIndexContext = createContext(null); + +export function useSearchIndex(): SearchIndexContextType | null { + return useContext(SearchIndexContext); +} diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 2a01c772c6..9853e72825 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -67,7 +67,7 @@ import { import { ClientBindAtoms, ClientLayout, ClientRoot, ClientRouteOutlet } from './client'; import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { Direct, DirectCreate, DirectRouteRoomProvider, DirectSearch } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites, Bookmarks } from './client/inbox'; @@ -270,6 +270,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > {mobile ? null : } />} } /> + } /> + @@ -917,6 +918,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { {children} - + ); } diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index c9a3bffdfe..2b8fd4d86a 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -24,6 +24,7 @@ import { getPhosphorSize, Plus, User, + MagnifyingGlass, } from '$components/icons/phosphor'; import { useVirtualizer } from '@tanstack/react-virtual'; import FocusTrap from 'focus-trap-react'; @@ -40,7 +41,7 @@ import { NavItem, NavItemContent, } from '$components/nav'; -import { getDirectCreatePath, getDirectRoomPath } from '$pages/pathUtils'; +import { getDirectCreatePath, getDirectRoomPath, getDirectSearchPath } from '$pages/pathUtils'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { VirtualTile } from '$components/virtualizer'; @@ -60,7 +61,7 @@ import { getRoomNotificationMode, useRoomsNotificationPreferencesContext, } from '$hooks/useRoomsNotificationPreferences'; -import { useDirectCreateSelected } from '$hooks/router/useDirectSelected'; +import { useDirectCreateSelected, useDirectSearchSelected } from '$hooks/router/useDirectSelected'; import { useDirectRooms } from './useDirectRooms'; import { SidebarResizer } from '$pages/client/sidebar/SidebarResizer'; import { useScreenSizeContext, ScreenSize } from '$hooks/useScreenSize'; @@ -211,6 +212,7 @@ export function Direct() { const [joinCallOnSingleClick] = useSetting(settingsAtom, 'joinCallOnSingleClick'); const createDirectSelected = useDirectCreateSelected(); + const directSearchSelected = useDirectSearchSelected(); const selectedRoomId = useSelectedRoom(); const noRoomToDisplay = directs.length === 0; @@ -311,6 +313,30 @@ export function Direct() { + + navigate(getDirectSearchPath())}> + + + + {menuIcon(MagnifyingGlass)} + + {!hideText && ( + + + Message Search + + + )} + + + + diff --git a/src/app/pages/client/direct/Search.tsx b/src/app/pages/client/direct/Search.tsx new file mode 100644 index 0000000000..bc6eb46205 --- /dev/null +++ b/src/app/pages/client/direct/Search.tsx @@ -0,0 +1,54 @@ +import { useRef } from 'react'; +import { Box, Icon, Icons, Text, Scroll, IconButton } from 'folds'; +import { Page, PageContent, PageContentCenter, PageHeader } from '$components/page'; +import { MessageSearch } from '$features/message-search'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { BackRouteHandler } from '$components/BackRouteHandler'; +import { useDirectRooms } from './useDirectRooms'; + +export function DirectSearch() { + const scrollRef = useRef(null); + const rooms = useDirectRooms(); + const screenSize = useScreenSizeContext(); + + return ( + + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Message Search + + + + + + + + + + + + + + + + ); +} diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index d247bbc03a..770aa28f28 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,3 +1,4 @@ export * from './Direct'; export * from './RoomProvider'; export * from './DirectCreate'; +export * from './Search'; diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 8df6b14ace..a33ed0ae90 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -29,6 +29,7 @@ import { SPACE_SEARCH_PATH, CREATE_PATH, INBOX_BOOKMARKS_PATH, + DIRECT_SEARCH_PATH, } from './paths'; export const joinPathComponent = (path: Path): string => path.pathname + path.search + path.hash; @@ -103,6 +104,7 @@ export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string export const getDirectPath = (): string => DIRECT_PATH; export const getDirectCreatePath = (): string => DIRECT_CREATE_PATH; +export const getDirectSearchPath = (): string => DIRECT_SEARCH_PATH; export const getDirectRoomPath = (roomIdOrAlias: string, eventId?: string): string => { const params = { roomIdOrAlias: encodeURIComponent(roomIdOrAlias), diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 2770cd3ee5..e5da05ba48 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -38,6 +38,7 @@ export type SearchPathSearchParams = { order?: string; rooms?: string; senders?: string; + has?: string; }; export const SEARCH_PATH_SEGMENT = 'search/'; @@ -59,6 +60,7 @@ export type DirectCreateSearchParams = { }; export const DIRECT_CREATE_PATH = `/direct/${CREATE_PATH_SEGMENT}`; export const DIRECT_ROOM_PATH = `/direct/${ROOM_PATH_SEGMENT}`; +export const DIRECT_SEARCH_PATH = `/direct/${SEARCH_PATH_SEGMENT}`; export const SPACE_PATH = '/:spaceIdOrAlias/'; export const SPACE_LOBBY_PATH = `/:spaceIdOrAlias/${LOBBY_PATH_SEGMENT}`; diff --git a/src/app/plugins/search-indexer/index.ts b/src/app/plugins/search-indexer/index.ts new file mode 100644 index 0000000000..9a6206d997 --- /dev/null +++ b/src/app/plugins/search-indexer/index.ts @@ -0,0 +1,334 @@ +import { Document } from 'flexsearch'; +import type { + BackfillState, + IndexWorkerMessageIn, + IndexWorkerMessageOut, + SearchIndexEvent, +} from './types'; +import { WorkerMessageTypeIn, WorkerMessageTypeOut } from './types'; + +export const HAS_TYPE_TO_MSGTYPE: Record = { + image: 'm.image', + file: 'm.file', + audio: 'm.audio', + video: 'm.video', +}; + +function openIdb(name: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(name, 1); + + req.onupgradeneeded = () => { + const db = req.result; + + db.createObjectStore('index'); + db.createObjectStore('backfill'); + }; + + req.addEventListener('success', () => resolve(req.result)); + req.addEventListener('error', () => reject(req.error)); + }); +} + +function idbGet(db: IDBDatabase, store: string, key: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readonly'); + const req = tx.objectStore(store).get(key); + req.addEventListener('success', () => resolve(req.result)); + req.addEventListener('error', () => reject(req.error)); + }); +} + +function idbPut(db: IDBDatabase, store: string, key: string, value: unknown): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).put(value, key); + req.addEventListener('success', () => resolve()); + req.addEventListener('error', () => reject(req.error)); + }); +} + +function idbGetAll(db: IDBDatabase, store: string): Promise<{ key: string; value: unknown }[]> { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readonly'); + const results: { key: string; value: unknown }[] = []; + const keysReq = tx.objectStore(store).openCursor(); + keysReq.addEventListener('success', () => { + const cursor = keysReq.result; + if (cursor) { + results.push({ key: cursor.key as string, value: cursor.value as unknown }); + cursor.continue(); + } else { + resolve(results); + } + }); + keysReq.addEventListener('error', () => reject(keysReq.error)); + }); +} + +function idbClear(db: IDBDatabase, store: string): Promise { + return new Promise((resolve, reject) => { + const tx = db.transaction(store, 'readwrite'); + const req = tx.objectStore(store).clear(); + req.addEventListener('success', () => resolve()); + req.addEventListener('error', () => reject(req.error)); + }); +} + +function getDocument(data?: Record): Document { + const document = new Document({ + document: { + id: 'eventId', + store: true, + index: ['body'], + }, + }); + + if (data) { + for (const [key, value] of Object.entries(data)) { + document.import(key, value); + } + } + + return document; +} + +let dirty = false; +const FLUSH_DEBOUNCE_MS = 5000; +let flushTimer: ReturnType | null = null; +let flushTries = 0; + +let db: IDBDatabase | null = null; +let document: Document | null = null; + +const roomQueues = new Map>(); + +async function flushIndex(): Promise { + if (!db || !document || !dirty) return; + try { + const doFlush = async () => { + if (!db || !document) return; + document.export(async (key, data) => { + if (db) await idbPut(db, 'index', key, data); + }); + + const roomQueuesData: Record> = {}; + for (const [roomId, queue] of roomQueues.entries()) { + roomQueuesData[roomId] = queue; + } + await idbPut(db, 'index', 'rooms', roomQueuesData); + dirty = false; + }; + if ('locks' in navigator) { + await navigator.locks.request('sable-search-index-writer', { mode: 'exclusive' }, doFlush); + } else { + await doFlush(); + } + } catch {} +} + +function scheduleFlush(): void { + if (flushTimer !== null && flushTries <= 100) { + clearTimeout(flushTimer); + flushTries += 1; + } + flushTimer = setTimeout(() => { + flushTimer = null; + flushTries = 0; + void flushIndex(); + }, FLUSH_DEBOUNCE_MS); +} + +function post(msg: IndexWorkerMessageOut): void { + // oxlint-disable-next-line unicorn/require-post-message-target-origin + self.postMessage(msg); +} + +function makeTypeFilter( + hasTypes: string[] | undefined +): ((ev: SearchIndexEvent) => boolean) | null { + if (!hasTypes || hasTypes.length === 0) return null; + const allowedMsgtypes = new Set( + hasTypes.filter((v) => v != 'link').map((t) => HAS_TYPE_TO_MSGTYPE[t]) + ); + const needsLink = hasTypes.includes('link'); + return (ev: SearchIndexEvent) => { + if (allowedMsgtypes.has(ev.msgtype)) return true; + if (needsLink && ev.hasLink) return true; + return false; + }; +} + +self.addEventListener('message', async (event: MessageEvent) => { + const msg = event.data; + + switch (msg.type) { + case WorkerMessageTypeIn.Init: + const dbName = `sable-search${msg.userId}`; + + db = await openIdb(dbName); + + const serialized = await idbGetAll(db, 'index'); + if (serialized) { + const records: Record = {}; + for (const { key, value } of serialized) { + records[key] = value as string; + } + document = getDocument(records); + const savedQueues = await idbGet>>( + db, + 'index', + 'rooms' + ); + if (savedQueues) { + for (const [roomId, queue] of Object.entries(savedQueues)) { + roomQueues.set(roomId, queue); + } + } + } else { + document = getDocument(); + } + + post({ + type: WorkerMessageTypeOut.Ready, + //@ts-expect-error flexsearch types are wrong for some reason + indexedEventCount: document.store.size, + roomCount: roomQueues.size, + }); + break; + + case WorkerMessageTypeIn.Query: + if (!document) { + post({ type: WorkerMessageTypeOut.QueryResult, id: msg.id, events: [] }); + return; + } + + const typeFilter = makeTypeFilter(msg.hasTypes); + + function matchesFilters(ev: SearchIndexEvent): boolean { + if (msg.type != WorkerMessageTypeIn.Query) return false; + if (msg.roomIds && msg.roomIds.length > 0 && !msg.roomIds.includes(ev.roomId)) return false; + if (msg.senders && msg.senders.length > 0 && !msg.senders.includes(ev.sender)) return false; + if (typeFilter && !typeFilter(ev)) return false; + return true; + } + + if (!msg.term) { + const results: SearchIndexEvent[] = + //@ts-expect-error flexsearch types are very bad + [...document.store.values()].filter((v) => matchesFilters(v)).slice(0, msg.maxResults); + + post({ type: WorkerMessageTypeOut.QueryResult, id: msg.id, events: results }); + return; + } + + let result = ( + await document.searchAsync(msg.term, { + //@ts-expect-error flexsearch types are very bad + limit: document.store.size, + enrich: true, + }) + ) + .flatMap((r) => r.result.map((v) => v.doc!)) + .filter((r) => r != undefined) + .filter((v) => matchesFilters(v)) + .slice(0, msg.maxResults); + + post({ type: WorkerMessageTypeOut.QueryResult, id: msg.id, events: result }); + + case WorkerMessageTypeIn.State: + if (!document) { + post({ type: WorkerMessageTypeOut.State, indexedEventCount: 0, roomCount: 0 }); + return; + } + post({ + type: WorkerMessageTypeOut.State, + //@ts-expect-error flexsearch types are very bad + indexedEventCount: document.store.size, + roomCount: roomQueues.size, + }); + break; + case WorkerMessageTypeIn.Clear: + if (!db) return; + document = getDocument(); + roomQueues.clear(); + dirty = false; + await idbClear(db, 'index'); + await idbClear(db, 'backfill'); + break; + case WorkerMessageTypeIn.SetBackfillState: + if (!db) return; + await idbPut(db, 'backfill', msg.roomId, msg.state); + break; + case WorkerMessageTypeIn.GetBackfillStates: + if (!db) { + post({ type: WorkerMessageTypeOut.BackfillStatesDone, states: {} }); + return; + } + + const rows = await idbGetAll(db, 'backfill'); + const states: Record = {}; + for (const { key, value } of rows) { + states[key] = value as BackfillState; + } + post({ type: WorkerMessageTypeOut.BackfillStatesDone, states }); + break; + case WorkerMessageTypeIn.Index: + if (!document) return; + + for (const ev of msg.events) { + if (!ev.eventId || !ev.body.trim()) continue; + if (document.contain(ev.eventId)) continue; + document.add(ev); + + let queue = roomQueues.get(ev.roomId); + if (!queue) { + queue = []; + roomQueues.set(ev.roomId, queue); + } + const lastEntry = queue[queue.length - 1]; + if (queue.length === 0 || (lastEntry !== undefined && lastEntry[1] <= ev.ts)) { + queue.push([ev.eventId, ev.ts]); + } else { + let lo = 0; + let hi = queue.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + const midEntry = queue[mid]; + if (midEntry !== undefined && midEntry[1] <= ev.ts) lo = mid + 1; + else hi = mid; + } + queue.splice(lo, 0, [ev.eventId, ev.ts]); + } + } + dirty = true; + scheduleFlush(); + break; + case WorkerMessageTypeIn.RedactEvents: + if (!document) return; + for (const ev of msg.eventIds) { + document.remove(ev); + } + dirty = true; + scheduleFlush(); + break; + case WorkerMessageTypeIn.EditEvents: + if (!document) return; + for (const id in msg.events) { + if (!msg.events[id]) continue; + document.set(id, msg.events[id]); + } + dirty = true; + scheduleFlush(); + break; + case WorkerMessageTypeIn.Flush: + void flushIndex().then(() => { + post({ + type: WorkerMessageTypeOut.FlushDone, + }); + }); + break; + default: + break; + } +}); diff --git a/src/app/plugins/search-indexer/types.ts b/src/app/plugins/search-indexer/types.ts new file mode 100644 index 0000000000..81b8136ba4 --- /dev/null +++ b/src/app/plugins/search-indexer/types.ts @@ -0,0 +1,114 @@ +import type { IFileInfo } from '$types/matrix/common'; + +export type SearchIndexEvent = { + eventId: string; + roomId: string; + sender: string; + + msgtype: string; + body: string; + ts: number; + + hasLink: boolean; + filename?: string; + url?: string; + info?: IFileInfo; + // oxlint-disable-next-line typescript/no-explicit-any + file?: any; +}; + +export enum WorkerMessageTypeIn { + Init, + GetBackfillStates, + SetBackfillState, + EditEvents, + RedactEvents, + Index, + Query, + State, + Clear, + Flush, +} + +export type IndexWorkerMessageIn = + | { + type: WorkerMessageTypeIn.Init; + userId: string; + } + | { + type: WorkerMessageTypeIn.GetBackfillStates; + } + | { + type: WorkerMessageTypeIn.Query; + id: string; + term?: string; + roomIds?: string[]; + senders?: string[]; + hasTypes?: string[]; + maxResults: number; + } + | { + type: WorkerMessageTypeIn.SetBackfillState; + roomId: string; + state: BackfillState; + } + | { + type: WorkerMessageTypeIn.RedactEvents; + eventIds: string[]; + } + | { + type: WorkerMessageTypeIn.EditEvents; + events: Record; + } + | { + type: WorkerMessageTypeIn.Index; + events: SearchIndexEvent[]; + } + | { + type: WorkerMessageTypeIn.State; + } + | { + type: WorkerMessageTypeIn.Clear; + } + | { + type: WorkerMessageTypeIn.Flush; + }; + +export enum WorkerMessageTypeOut { + Ready, + BackfillStatesDone, + QueryResult, + State, + FlushDone, +} + +export type IndexWorkerMessageOut = + | { + type: WorkerMessageTypeOut.Ready; + indexedEventCount: number; + roomCount: number; + } + | { + type: WorkerMessageTypeOut.BackfillStatesDone; + states: Record; + } + | { + type: WorkerMessageTypeOut.QueryResult; + id: string; + events: SearchIndexEvent[]; + } + | { + type: WorkerMessageTypeOut.State; + indexedEventCount: number; + roomCount: number; + } + | { + type: WorkerMessageTypeOut.FlushDone; + }; + +export type BackfillState = { + token: string | null; + done: boolean; + indexedCount: number; + oldestTs?: number; +}; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 26ddd14767..15102e36b7 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -151,6 +151,8 @@ export interface Settings { enableMSC4268CMD: boolean; enableMediaGalleries: boolean; settingsSyncEnabled: boolean; + idbSearchIndex: boolean; + idbSearchMaxResults: number; // Cosmetics! iconCompactSizePx: number; @@ -316,6 +318,8 @@ export const defaultSettings: Settings = { developerTools: false, settingsSyncEnabled: false, + idbSearchIndex: false, + idbSearchMaxResults: 1000, // Cosmetics! iconCompactSizePx: 16, diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 6d201f34a3..14f60fcc55 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -23,11 +23,13 @@ import { MsgType, KnownMembership, RoomType, + JoinRule, } from '$types/matrix-sdk'; import type { IRoomCreateContent, RoomToParents, UnreadInfo } from '$types/matrix/room'; import { NotificationType } from '$types/matrix/room'; import * as Sentry from '@sentry/react'; +import type { IconName, IconSrc } from 'folds'; export const getStateEvent = ( room: Room, @@ -510,6 +512,46 @@ export const getUnreadInfos = (mx: MatrixClient, options?: UnreadInfoOptions): U return unreadInfos; }; +export const getRoomIconSrc = ( + icons: Record, + roomType?: string, + joinRule?: JoinRule +): IconSrc => { + if (roomType === RoomType.Space) { + if (joinRule === JoinRule.Public) return icons.SpaceGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.SpaceLock; + } + return icons.Space; + } + + if (roomType === RoomType.UnstableCall) { + if (joinRule === JoinRule.Public) return icons.VolumeHighGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.VolumeHighLock; + } + return icons.VolumeHigh; + } + + if (joinRule === JoinRule.Public) return icons.HashGlobe; + if ( + joinRule === JoinRule.Invite || + joinRule === JoinRule.Knock || + joinRule === JoinRule.Private + ) { + return icons.HashLock; + } + return icons.Hash; +}; + export const getRoomAvatarUrl = ( mx: MatrixClient, room: Room,