/* Copyright 2021 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ import React, { ChangeEvent, ComponentProps, KeyboardEvent, RefObject, useCallback, useContext, useEffect, useMemo, useState, } from "react"; import { Room } from "matrix-js-sdk/src/models/room"; import { normalize } from "matrix-js-sdk/src/utils"; import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; import { RoomType } from "matrix-js-sdk/src/@types/event"; import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch"; import { IDialogProps } from "./IDialogProps"; import { _t } from "../../../languageHandler"; import BaseDialog from "./BaseDialog"; import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { findSiblingElement, RovingAccessibleButton, RovingAccessibleTooltipButton, RovingTabIndexContext, RovingTabIndexProvider, Type, useRovingTabIndex, } from "../../../accessibility/RovingTabIndex"; import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import DMRoomMap from "../../../utils/DMRoomMap"; import { mediaFromMxc } from "../../../customisations/Media"; import BaseAvatar from "../avatars/BaseAvatar"; import Spinner from "../elements/Spinner"; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; import { Action } from "../../../dispatcher/actions"; import Modal from "../../../Modal"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { RoomViewStore } from "../../../stores/RoomViewStore"; import { showStartChatInviteDialog } from "../../../RoomInvite"; import SettingsStore from "../../../settings/SettingsStore"; import { SettingLevel } from "../../../settings/SettingLevel"; import NotificationBadge from "../rooms/NotificationBadge"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { BetaPill } from "../beta/BetaCard"; import { UserTab } from "./UserTab"; import BetaFeedbackDialog from "./BetaFeedbackDialog"; import SdkConfig from "../../../SdkConfig"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { getMetaSpaceName } from "../../../stores/spaces"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { getCachedRoomIDForAlias } from "../../../RoomAliasCache"; import { roomContextDetailsText, spaceContextDetailsText } from "../../../utils/i18n-helpers"; import { RecentAlgorithm } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons const AVATAR_SIZE = 24; const Option: React.FC> = ({ inputRef, children, ...props }) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return { children }
; }; const TooltipOption: React.FC> = ({ inputRef, ...props }) => { const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); return ; }; const useRecentSearches = (): [Room[], () => void] => { const [rooms, setRooms] = useState(() => { const cli = MatrixClientPeg.get(); const recents = SettingsStore.getValue("SpotlightSearch.recentSearches", null); return recents.map(r => cli.getRoom(r)).filter(Boolean); }); return [rooms, () => { SettingsStore.setValue("SpotlightSearch.recentSearches", null, SettingLevel.ACCOUNT, []); setRooms([]); }]; }; const ResultDetails = ({ room }: { room: Room }) => { const contextDetails = room.isSpaceRoom() ? spaceContextDetailsText(room) : roomContextDetailsText(room); if (contextDetails) { return
{ contextDetails }
; } return null; }; interface IProps extends IDialogProps { initialText?: string; } const useSpaceResults = (space?: Room, query?: string): [IHierarchyRoom[], boolean] => { const [rooms, setRooms] = useState([]); const [hierarchy, setHierarchy] = useState(); const resetHierarchy = useCallback(() => { setHierarchy(space ? new RoomHierarchy(space, 50) : null); }, [space]); useEffect(resetHierarchy, [resetHierarchy]); useEffect(() => { if (!space || !hierarchy) return; // nothing to load let unmounted = false; (async () => { while (hierarchy?.canLoadMore && !unmounted && space === hierarchy.root) { await hierarchy.load(); if (hierarchy.canLoadMore) hierarchy.load(); // start next load so that the loading attribute is right setRooms(hierarchy.rooms); } })(); return () => { unmounted = true; }; }, [space, hierarchy]); const results = useMemo(() => { const trimmedQuery = query.trim(); const lcQuery = trimmedQuery.toLowerCase(); const normalizedQuery = normalize(trimmedQuery); const cli = MatrixClientPeg.get(); return rooms?.filter(r => { return r.room_type !== RoomType.Space && cli.getRoom(r.room_id)?.getMyMembership() !== "join" && ( normalize(r.name || "").includes(normalizedQuery) || (r.canonical_alias || "").includes(lcQuery) ); }); }, [rooms, query]); return [results, hierarchy?.loading ?? false]; }; function refIsForRecentlyViewed(ref: RefObject): boolean { return ref.current?.id.startsWith("mx_SpotlightDialog_button_recentlyViewed_"); } enum Section { People, Rooms, Spaces, } interface IBaseResult { section: Section; query?: string[]; // extra fields to query match, stored as lowercase } interface IRoomResult extends IBaseResult { room: Room; } interface IResult extends IBaseResult { avatar: JSX.Element; name: string; description?: string; onClick?(): void; } type Result = IRoomResult | IResult; const isRoomResult = (result: any): result is IRoomResult => !!result?.room; const recentAlgorithm = new RecentAlgorithm(); export const useWebSearchMetrics = (numResults: number, queryLength: number, viaSpotlight: boolean): void => { useEffect(() => { if (!queryLength) return; // send metrics after a 1s debounce const timeoutId = setTimeout(() => { PosthogAnalytics.instance.trackEvent({ eventName: "WebSearch", viaSpotlight, numResults, queryLength, }); }, 1000); return () => { clearTimeout(timeoutId); }; }, [numResults, queryLength, viaSpotlight]); }; const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => { const cli = MatrixClientPeg.get(); const rovingContext = useContext(RovingTabIndexContext); const [query, _setQuery] = useState(initialText); const [recentSearches, clearRecentSearches] = useRecentSearches(); const possibleResults = useMemo(() => [ ...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({ section: Section.Spaces, avatar: (
), name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome), onClick() { SpaceStore.instance.setActiveSpace(spaceKey); }, })), ...cli.getVisibleRooms().filter(room => { // TODO we may want to put invites in their own list return room.getMyMembership() === "join" || room.getMyMembership() == "invite"; }).map(room => { let section: Section; let query: string[]; const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); if (otherUserId) { section = Section.People; query = [ otherUserId.toLowerCase(), room.getMember(otherUserId)?.name.toLowerCase(), ].filter(Boolean); } else if (room.isSpaceRoom()) { section = Section.Spaces; } else { section = Section.Rooms; } return { room, section, query }; }), ], [cli]); const trimmedQuery = query.trim(); const [people, rooms, spaces] = useMemo<[Result[], Result[], Result[]] | []>(() => { if (!trimmedQuery) return []; const lcQuery = trimmedQuery.toLowerCase(); const normalizedQuery = normalize(trimmedQuery); const results: [Result[], Result[], Result[]] = [[], [], []]; // Group results in their respective sections possibleResults.forEach(entry => { if (isRoomResult(entry)) { if (!entry.room.normalizedName.includes(normalizedQuery) && !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && !entry.query?.some(q => q.includes(lcQuery)) ) return; // bail, does not match query } else { if (!entry.name.toLowerCase().includes(lcQuery) && !entry.query?.some(q => q.includes(lcQuery)) ) return; // bail, does not match query } results[entry.section].push(entry); }); // Sort results by most recent activity const myUserId = cli.getUserId(); for (const resultArray of results) { resultArray.sort((a: Result, b: Result) => { // This is not a room result, it should appear at the bottom of // the list if (!(a as IRoomResult).room) return 1; if (!(b as IRoomResult).room) return -1; const roomA = (a as IRoomResult).room; const roomB = (b as IRoomResult).room; return recentAlgorithm.getLastTs(roomB, myUserId) - recentAlgorithm.getLastTs(roomA, myUserId); }); } return results; }, [possibleResults, trimmedQuery, cli]); const numResults = trimmedQuery ? people.length + rooms.length + spaces.length : 0; useWebSearchMetrics(numResults, query.length, true); const activeSpace = SpaceStore.instance.activeSpaceRoom; const [spaceResults, spaceResultsLoading] = useSpaceResults(activeSpace, query); const setQuery = (e: ChangeEvent): void => { const newQuery = e.currentTarget.value; _setQuery(newQuery); setImmediate(() => { // reset the activeRef when we change query for best usability const ref = rovingContext.state.refs[0]; if (ref) { rovingContext.dispatch({ type: Type.SetFocus, payload: { ref }, }); ref.current?.scrollIntoView({ block: "nearest", }); } }); }; const viewRoom = (roomId: string, persist = false, viaKeyboard = false) => { if (persist) { const recents = new Set(SettingsStore.getValue("SpotlightSearch.recentSearches", null).reverse()); // remove & add the room to put it at the end recents.delete(roomId); recents.add(roomId); SettingsStore.setValue( "SpotlightSearch.recentSearches", null, SettingLevel.ACCOUNT, Array.from(recents).reverse().slice(0, MAX_RECENT_SEARCHES), ); } defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: roomId, metricsTrigger: "WebUnifiedSearch", metricsViaKeyboard: viaKeyboard, }); onFinished(); }; let content: JSX.Element; if (trimmedQuery) { const resultMapper = (result: Result): JSX.Element => { if (isRoomResult(result)) { return ( ); } // IResult case return ( ); }; let peopleSection: JSX.Element; if (people.length) { peopleSection =

{ _t("People") }

{ people.slice(0, SECTION_LIMIT).map(resultMapper) }
; } let roomsSection: JSX.Element; if (rooms.length) { roomsSection =

{ _t("Rooms") }

{ rooms.slice(0, SECTION_LIMIT).map(resultMapper) }
; } let spacesSection: JSX.Element; if (spaces.length) { spacesSection =

{ _t("Spaces you're in") }

{ spaces.slice(0, SECTION_LIMIT).map(resultMapper) }
; } let spaceRoomsSection: JSX.Element; if (spaceResults.length) { spaceRoomsSection =

{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }

{ spaceResults.slice(0, SECTION_LIMIT).map((room: IHierarchyRoom): JSX.Element => ( )) } { spaceResultsLoading && }
; } let joinRoomSection: JSX.Element; if (trimmedQuery.startsWith("#") && trimmedQuery.includes(":") && (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery))) ) { joinRoomSection =
; } content = <> { peopleSection } { roomsSection } { spacesSection } { spaceRoomsSection } { joinRoomSection }

{ _t('Use "%(query)s" to search', { query }) }

{ _t("Other searches") }

{ _t("To search messages, look for this icon at the top of a room ", {}, { icon: () =>
, }) }
; } else { let recentSearchesSection: JSX.Element; if (recentSearches.length) { recentSearchesSection = (

{ _t("Recent searches") } { _t("Clear") }

{ recentSearches.map(room => ( )) }
); } content = <>

{ _t("Recently viewed") }

{ BreadcrumbsStore.instance.rooms .filter(r => r.roomId !== RoomViewStore.instance.getRoomId()) .map(room => ( { viewRoom(room.roomId, false, ev.type !== "click"); }} > { room.name } )) }
{ recentSearchesSection }

{ _t("Other searches") }

; } const onDialogKeyDown = (ev: KeyboardEvent) => { const navigationAction = getKeyBindingsManager().getNavigationAction(ev); switch (navigationAction) { case KeyBindingAction.FilterRooms: ev.stopPropagation(); ev.preventDefault(); onFinished(); break; } const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev); switch (accessibilityAction) { case KeyBindingAction.Escape: ev.stopPropagation(); ev.preventDefault(); onFinished(); break; } }; const onKeyDown = (ev: KeyboardEvent) => { let ref: RefObject; const action = getKeyBindingsManager().getAccessibilityAction(ev); switch (action) { case KeyBindingAction.ArrowUp: case KeyBindingAction.ArrowDown: ev.stopPropagation(); ev.preventDefault(); if (rovingContext.state.refs.length > 0) { let refs = rovingContext.state.refs; if (!query) { // If the current selection is not in the recently viewed row then only include the // first recently viewed so that is the target when the user is switching into recently viewed. const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef) ? rovingContext.state.activeRef : refs.find(refIsForRecentlyViewed); // exclude all other recently viewed items from the list so up/down arrows skip them refs = refs.filter(ref => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref)); } const idx = refs.indexOf(rovingContext.state.activeRef); ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowUp ? -1 : 1)); } break; case KeyBindingAction.ArrowLeft: case KeyBindingAction.ArrowRight: // only handle these keys when we are in the recently viewed row of options if (!query && rovingContext.state.refs.length > 0 && refIsForRecentlyViewed(rovingContext.state.activeRef) ) { // we only intercept left/right arrows when the field is empty, and they'd do nothing anyway ev.stopPropagation(); ev.preventDefault(); const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed); const idx = refs.indexOf(rovingContext.state.activeRef); ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowLeft ? -1 : 1)); } break; case KeyBindingAction.Enter: ev.stopPropagation(); ev.preventDefault(); rovingContext.state.activeRef?.current?.click(); break; } if (ref) { rovingContext.dispatch({ type: Type.SetFocus, payload: { ref }, }); ref.current?.scrollIntoView({ block: "nearest", }); } }; const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => { Modal.createTrackedDialog("Spotlight Feedback", "feature_spotlight", BetaFeedbackDialog, { featureId: "feature_spotlight", }); } : null; const activeDescendant = rovingContext.state.activeRef?.current?.id; return <>
{ _t("Use to scroll", {}, { arrows: () => <>
{ !query &&
} { !query &&
} , }) }
{ content }
{ defaultDispatcher.dispatch({ action: Action.ViewUserSettings, initialTabId: UserTab.Labs, }); onFinished(); }} /> { openFeedback && _t("Results not as expected? Please give feedback.", {}, { a: sub => { sub } , }) } { openFeedback && { _t("Feedback") } }
; }; const RovingSpotlightDialog: React.FC = (props) => { return { () => } ; }; export default RovingSpotlightDialog;