diff --git a/res/css/_components.scss b/res/css/_components.scss index 33c17b64e7..01f8bbece3 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -113,6 +113,7 @@ @import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss"; @import "./views/dialogs/_SpaceSettingsDialog.scss"; +@import "./views/dialogs/_SpotlightDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_UntrustedDeviceDialog.scss"; diff --git a/res/css/views/avatars/_DecoratedRoomAvatar.scss b/res/css/views/avatars/_DecoratedRoomAvatar.scss index 60cd18520a..b93e2d602d 100644 --- a/res/css/views/avatars/_DecoratedRoomAvatar.scss +++ b/res/css/views/avatars/_DecoratedRoomAvatar.scss @@ -39,6 +39,7 @@ limitations under the License. content: ''; width: 8px; height: 8px; + right: 0; position: absolute; border-radius: 8px; } diff --git a/res/css/views/dialogs/_SpotlightDialog.scss b/res/css/views/dialogs/_SpotlightDialog.scss new file mode 100644 index 0000000000..4a6e7d6288 --- /dev/null +++ b/res/css/views/dialogs/_SpotlightDialog.scss @@ -0,0 +1,286 @@ +/* +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. +*/ + +.mx_SpotlightDialog_wrapper .mx_Dialog { + border-radius: 8px; + overflow-y: initial; + position: relative; + height: 60%; + padding: 0; + contain: unset; // needed for .mx_SpotlightDialog_keyboardPrompt to not be culled + + .mx_SpotlightDialog_keyboardPrompt { + position: absolute; + padding: 8px; + border-radius: 8px; + background-color: $background; + top: -60px; // relative to the top of the modal + left: 50%; + transform: translateX(-50%); + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + + > span > div { + display: inline-block; + padding: 2px 4px; + margin: 0 4px; + border-radius: 6px; + background-color: $quinary-content; + vertical-align: middle; + color: $tertiary-content; + } + } +} + +.mx_SpotlightDialog { + height: 100%; + display: flex; + flex-direction: column; + + .mx_Dialog_header { + margin-bottom: 0; + } + + .mx_SpotlightDialog_searchBox { + margin: 0; + border: none; + padding: 12px 16px; + border-bottom: 1px solid $system; + + > input { + display: block; + box-sizing: border-box; + background-color: transparent; + width: 100%; + height: 32px; + padding: 0; + color: $tertiary-content; + font-weight: normal; + font-size: $font-15px; + line-height: $font-24px; + } + } + + #mx_SpotlightDialog_content { + margin: 16px; + height: 100%; + overflow-y: auto; + + .mx_SpotlightDialog_section { + > h4 { + font-weight: $font-semi-bold; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + margin-top: 0; + margin-bottom: 8px; + } + + & + .mx_SpotlightDialog_section { + margin-top: 24px; + } + } + + .mx_SpotlightDialog_recentlyViewed { + > div { + display: flex; + white-space: nowrap; + overflow-x: hidden; + } + + .mx_AccessibleButton { + border-radius: 8px; + padding: 4px; + color: $primary-content; + font-size: $font-12px; + line-height: $font-15px; + display: inline-block; + width: 50px; + min-width: 50px; + box-sizing: border-box; + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + + .mx_DecoratedRoomAvatar { + margin: 0 5px 4px; // maintain centering + } + + & + .mx_AccessibleButton { + margin-left: 16px; + } + + &:hover, &[aria-selected=true] { + background-color: $quinary-content; + } + } + } + + .mx_SpotlightDialog_results, + .mx_SpotlightDialog_recentSearches, + .mx_SpotlightDialog_otherSearches { + .mx_AccessibleButton { + padding: 6px 4px; + border-radius: 8px; + font-size: $font-15px; + line-height: $font-24px; + color: $primary-content; + position: relative; + display: flex; + align-items: center; + white-space: nowrap; + text-overflow: ellipsis; + overflow: hidden; + + .mx_BaseAvatar { + margin-right: 8px; + display: inline-block; + height: 20px; + } + + &:hover, &[aria-selected=true] { + background-color: $system; + + .mx_SpotlightDialog_enterPrompt { + display: inline-block; + } + } + } + } + + .mx_SpotlightDialog_otherSearches { + .mx_SpotlightDialog_startChat, + .mx_SpotlightDialog_explorePublicRooms { + padding-left: 32px; + position: relative; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 20px; + height: 20px; + position: absolute; + left: 4px; + top: 50%; + transform: translateY(-50%); + } + } + + .mx_SpotlightDialog_startChat::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + .mx_SpotlightDialog_explorePublicRooms::before { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + + .mx_SpotlightDialog_otherSearches_messageSearchText { + font-size: $font-15px; + line-height: $font-24px; + } + + .mx_SpotlightDialog_otherSearches_messageSearchIcon { + display: inline-block; + margin-left: 8px; + width: 20px; + height: 20px; + background-color: $secondary-content; + vertical-align: text-bottom; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/search-inset.svg'); + } + } + + .mx_SpotlightDialog_result_details { + margin-left: 8px; + margin-right: 8px; + color: $tertiary-content; + font-size: $font-12px; + line-height: $font-15px; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .mx_SpotlightDialog_recentSearches { + overflow-y: hidden; + height: calc(100% - 190px); + + > h4 > .mx_AccessibleButton_kind_link { + padding: 0; + float: right; + font-weight: normal; + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + } + } + + .mx_SpotlightDialog_enterPrompt { + padding: 2px 4px; + font-size: $font-12px; + line-height: $font-15px; + color: $tertiary-content; + border-radius: 6px; + background-color: $quinary-content; + margin: 0 4px 0 auto; + display: none; + } + } + + .mx_SpotlightDialog_footer { + font-size: $font-12px; + line-height: $font-15px; + color: $secondary-content; + padding: 16px 16px 20px; + display: flex; + border-top: 1px solid $quinary-content; + + > span { + position: relative; + padding-left: 20px; + align-self: center; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + mask-image: url('$(res)/img/element-icons/room/room-summary.svg'); + width: 16px; + height: 16px; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + } + } + + .mx_AccessibleButton_kind_primary_outline { + padding: 4px 8px; + border-color: $secondary-content; + color: $secondary-content; + margin-left: auto; + } + } +} diff --git a/src/Rooms.ts b/src/Rooms.ts index fbea536faf..14f3571bab 100644 --- a/src/Rooms.ts +++ b/src/Rooms.ts @@ -162,7 +162,7 @@ export function roomContextDetailsText(room: Room): string { const dmPartner = DMRoomMap.shared().getUserIdForRoomId(room.roomId); if (dmPartner) { - return room.getMember(dmPartner)?.rawDisplayName; + return dmPartner; } const [parent, ...otherParents] = SpaceStore.instance.getKnownParents(room.roomId); diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 0ef4617294..cdd937bba3 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -43,8 +43,6 @@ import { FocusHandler, Ref } from "./roving/types"; * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex */ -const DOCUMENT_POSITION_PRECEDING = 2; - export interface IState { activeRef: Ref; refs: Ref[]; @@ -55,7 +53,7 @@ interface IContext { dispatch: Dispatch; } -const RovingTabIndexContext = createContext({ +export const RovingTabIndexContext = createContext({ state: { activeRef: null, refs: [], // list of refs in DOM order @@ -80,37 +78,29 @@ interface IAction { export const reducer = (state: IState, action: IAction) => { switch (action.type) { case Type.Register: { - let left = 0; - let right = state.refs.length - 1; - let index = state.refs.length; // by default append to the end - - // do a binary search to find the right slot - while (left <= right) { - index = Math.floor((left + right) / 2); - const ref = state.refs[index]; - - if (ref === action.payload.ref) { - return state; // already in refs, this should not happen - } - - if (action.payload.ref.current.compareDocumentPosition(ref.current) & DOCUMENT_POSITION_PRECEDING) { - left = ++index; - } else { - right = index - 1; - } - } - if (!state.activeRef) { // Our list of refs was empty, set activeRef to this first item state.activeRef = action.payload.ref; } - // update the refs list - if (index < state.refs.length) { - state.refs.splice(index, 0, action.payload.ref); - } else { - state.refs.push(action.payload.ref); - } + // Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert + state.refs.push(action.payload.ref); + state.refs.sort((a, b) => { + if (a === b) { + return 0; + } + + const position = a.current.compareDocumentPosition(b.current); + + if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { + return -1; + } else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) { + return 1; + } else { + return 0; + } + }); + return { ...state }; } diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 881533713b..0ac762409c 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -30,6 +30,9 @@ import { replaceableComponent } from "../../utils/replaceableComponent"; import SpaceStore from "../../stores/spaces/SpaceStore"; import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import { isMac } from "../../Keyboard"; +import SettingsStore from "../../settings/SettingsStore"; +import Modal from "../../Modal"; +import SpotlightDialog from "../views/dialogs/SpotlightDialog"; interface IProps { isMinimized: boolean; @@ -83,11 +86,19 @@ export default class RoomSearch extends React.PureComponent { SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput); } + private openSpotlight() { + Modal.createTrackedDialog("Spotlight", "", SpotlightDialog, {}, "mx_SpotlightDialog_wrapper", false, true); + } + private onAction = (payload: ActionPayload) => { if (payload.action === Action.ViewRoom && payload.clear_search) { this.clearInput(); } else if (payload.action === 'focus_room_filter' && this.inputRef.current) { - this.inputRef.current.focus(); + if (SettingsStore.getValue("feature_spotlight")) { + this.openSpotlight(); + } else { + this.inputRef.current.focus(); + } } }; @@ -107,6 +118,14 @@ export default class RoomSearch extends React.PureComponent { this.setState({ query: this.inputRef.current.value }); }; + private onMouseDown = (ev: React.MouseEvent) => { + if (SettingsStore.getValue("feature_spotlight")) { + ev.preventDefault(); + ev.stopPropagation(); + this.openSpotlight(); + } + }; + private onFocus = (ev: React.FocusEvent) => { this.setState({ focused: true }); ev.target.select(); @@ -162,11 +181,12 @@ export default class RoomSearch extends React.PureComponent { ref={this.inputRef} className={inputClasses} value={this.state.query} + onMouseDown={this.onMouseDown} onFocus={this.onFocus} onBlur={this.onBlur} onChange={this.onChange} onKeyDown={this.onKeyDown} - placeholder={_t("Filter")} + placeholder={SettingsStore.getValue("feature_spotlight") ? _t("Search") : _t("Filter")} autoComplete="off" /> ); diff --git a/src/components/structures/SearchBox.tsx b/src/components/structures/SearchBox.tsx index 697ab4e48b..ea36f41033 100644 --- a/src/components/structures/SearchBox.tsx +++ b/src/components/structures/SearchBox.tsx @@ -15,7 +15,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { createRef } from 'react'; +import React, { createRef, HTMLProps } from 'react'; import { throttle } from 'lodash'; import classNames from 'classnames'; @@ -25,7 +25,7 @@ import AccessibleButton from '../../components/views/elements/AccessibleButton'; import { replaceableComponent } from "../../utils/replaceableComponent"; import { Action } from '../../dispatcher/actions'; -interface IProps { +interface IProps extends HTMLProps { onSearch?: (query: string) => void; onCleared?: (source?: string) => void; onKeyDown?: (ev: React.KeyboardEvent) => void; @@ -135,11 +135,15 @@ export default class SearchBox extends React.Component { } public render(): JSX.Element { + /* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */ + const { onSearch, onCleared, onKeyDown, onFocus, onBlur, className = "", placeholder, blurredPlaceholder, + autoFocus, initialValue, collapsed, enableRoomSearchFocus, ...props } = this.props; + // check for collapsed here and // not at parent so we keep // searchTerm in our state // when collapsing and expanding - if (this.props.collapsed) { + if (collapsed) { return null; } const clearButton = (!this.state.blurred || this.state.searchTerm) ? @@ -153,13 +157,10 @@ export default class SearchBox extends React.Component { // show a shorter placeholder when blurred, if requested // this is used for the room filter field that has // the explore button next to it when blurred - const placeholder = this.state.blurred ? - (this.props.blurredPlaceholder || this.props.placeholder) : - this.props.placeholder; - const className = this.props.className || ""; return (
{ onChange={this.onChange} onKeyDown={this.onKeyDown} onBlur={this.onBlur} - placeholder={placeholder} + placeholder={this.state.blurred ? (blurredPlaceholder || placeholder) : placeholder} autoComplete="off" autoFocus={this.props.autoFocus} /> diff --git a/src/components/views/dialogs/SpotlightDialog.tsx b/src/components/views/dialogs/SpotlightDialog.tsx new file mode 100644 index 0000000000..5c4ca8397b --- /dev/null +++ b/src/components/views/dialogs/SpotlightDialog.tsx @@ -0,0 +1,540 @@ +/* +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, + 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 { IDialogProps } from "./IDialogProps"; +import { _t } from "../../../languageHandler"; +import BaseDialog from "./BaseDialog"; +import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore"; +import RoomAvatar from "../avatars/RoomAvatar"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { + findSiblingElement, + RovingAccessibleButton, + RovingAccessibleTooltipButton, + RovingTabIndexContext, + RovingTabIndexProvider, + Type, + useRovingTabIndex, +} from "../../../accessibility/RovingTabIndex"; +import { Key } from "../../../Keyboard"; +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 { roomContextDetailsText } from "../../../Rooms"; +import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; +import { Action } from "../../../dispatcher/actions"; +import Modal from "../../../Modal"; +import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; +import RoomViewStore from "../../../stores/RoomViewStore"; +import { showStartChatInviteDialog } from "../../../RoomInvite"; +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; + +const MAX_RECENT_SEARCHES = 10; +const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons + +const Option: React.FC> = ({ inputRef, ...props }) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return ; +}; + +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 roomContextDetails = roomContextDetailsText(room); + if (roomContextDetails) { + return
+ { roomContextDetails } +
; + } + + 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(() => { + const hierarchy = new RoomHierarchy(space, 50); + setHierarchy(hierarchy); + }, [space]); + useEffect(resetHierarchy, [resetHierarchy]); + + useEffect(() => { + 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]; +}; + +const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => { + const cli = MatrixClientPeg.get(); + const rovingContext = useContext(RovingTabIndexContext); + const [query, _setQuery] = useState(initialText); + const [recentSearches, clearRecentSearches] = useRecentSearches(); + + const results = useMemo(() => { + if (!query) return null; + + const trimmedQuery = query.trim(); + const lcQuery = trimmedQuery.toLowerCase(); + const normalizedQuery = normalize(trimmedQuery); + + return cli.getRooms().filter(r => { + return r.getCanonicalAlias()?.includes(lcQuery) || r.normalizedName.includes(normalizedQuery); + }); + }, [cli, query]); + + const activeSpace = SpaceStore.instance.activeSpaceRoom; + const [spaceResults, spaceResultsLoading] = useSpaceResults(activeSpace, query); + + const setQuery = (e: ChangeEvent): void => { + const newQuery = e.currentTarget.value; + _setQuery(newQuery); + if (!query !== !newQuery) { + setImmediate(() => { + // reset the activeRef when we start/stop querying as the view changes + 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) => { + 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: 'view_room', + room_id: roomId, + }); + onFinished(); + }; + + let content: JSX.Element; + if (results) { + const [people, rooms, spaces] = results.reduce((result, room: Room) => { + if (room.isSpaceRoom()) result[2].push(room); + else if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) result[1].push(room); + else result[0].push(room); + return result; + }, [[], [], []] as [Room[], Room[], Room[]]); + + const resultMapper = (room: Room): JSX.Element => ( + + ); + + 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 && } +
+
; + } + + content = <> + { peopleSection } + { roomsSection } + { spacesSection } + { spaceRoomsSection } +
+

{ _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.getRoomId()) + .slice(0, 10) + .map(room => ( + { + viewRoom(room.roomId); + }} + > + + { room.name } + + )) + } +
+
+ + { recentSearchesSection } + +
+

{ _t("Other searches") }

+
+ +
+
+ ; + } + + const onDialogKeyDown = (ev: KeyboardEvent) => { + if (ev.key === Key.ESCAPE) { + ev.stopPropagation(); + ev.preventDefault(); + onFinished(); + } + }; + + const onKeyDown = (ev: KeyboardEvent) => { + switch (ev.key) { + case Key.ARROW_UP: + case Key.ARROW_DOWN: + ev.stopPropagation(); + ev.preventDefault(); + + if (rovingContext.state.refs.length > 0) { + const idx = rovingContext.state.refs.indexOf(rovingContext.state.activeRef); + const ref = findSiblingElement(rovingContext.state.refs, idx + (ev.key === Key.ARROW_UP ? -1 : 1)); + + if (ref) { + rovingContext.dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + ref.current?.scrollIntoView({ + block: "nearest", + }); + } + } + break; + + case Key.ENTER: + ev.stopPropagation(); + ev.preventDefault(); + rovingContext.state.activeRef?.current?.click(); + break; + } + }; + + const activeDescendant = rovingContext.state.activeRef?.current?.id; + + return <> +
+ { _t("Use to scroll results", {}, { + arrows: () => <> +
+
+ , + }) } +
+ + +
+ +
+ +
+ { content } +
+ +
+ + { activeSpace + ? _t("Searching rooms and chats you're in and %(spaceName)s", { spaceName: activeSpace.name }) + : _t("Searching rooms and chats you're in") } + + { + Modal.createTrackedDialog("Spotlight Feedback", "", GenericFeatureFeedbackDialog, { + title: _t("Spotlight search feedback"), + subheading: _t("Thank you for trying Spotlight search. " + + "Your feedback will help inform the next versions."), + rageshakeLabel: "spotlight-feedback", + }); + }} + > + { _t("Feedback") } + +
+
+ ; +}; + +const RovingSpotlightDialog: React.FC = (props) => { + return + { () => } + ; +}; + +export default RovingSpotlightDialog; diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 4133198dd6..6d6894fc3b 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -193,7 +193,7 @@ export enum Action { SwitchSpace = "switch_space", /** - * Signals to the visible space hierarchy that a change has occurred an that it should refresh. + * Signals to the visible space hierarchy that a change has occurred and that it should refresh. */ UpdateSpaceHierarchy = "update_space_hierarchy", @@ -232,5 +232,5 @@ export enum Action { * The user rejected anonymous analytics (i.e. matomo, pre-posthog) from the toast * Payload: none */ - AnonymousAnalyticsReject = "anonymous_analytics_reject" + AnonymousAnalyticsReject = "anonymous_analytics_reject", } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 20f0a2b546..72dd21e9b2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -859,6 +859,7 @@ "New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)", "Meta Spaces": "Meta Spaces", "Use new room breadcrumbs": "Use new room breadcrumbs", + "New spotlight search experience": "New spotlight search experience", "Don't send read receipts": "Don't send read receipts", "Font size": "Font size", "Use custom size": "Use custom size", @@ -2706,6 +2707,19 @@ "Command Help": "Command Help", "Space settings": "Space settings", "Settings - %(spaceName)s": "Settings - %(spaceName)s", + "Spaces you're in": "Spaces you're in", + "Other rooms in %(spaceName)s": "Other rooms in %(spaceName)s", + "Use \"%(query)s\" to search": "Use \"%(query)s\" to search", + "Public rooms": "Public rooms", + "Other searches": "Other searches", + "To search messages, look for this icon at the top of a room ": "To search messages, look for this icon at the top of a room ", + "Recent searches": "Recent searches", + "Clear": "Clear", + "Use to scroll results": "Use to scroll results", + "Searching rooms and chats you're in and %(spaceName)s": "Searching rooms and chats you're in and %(spaceName)s", + "Searching rooms and chats you're in": "Searching rooms and chats you're in", + "Spotlight search feedback": "Spotlight search feedback", + "Thank you for trying Spotlight search. Your feedback will help inform the next versions.": "Thank you for trying Spotlight search. Your feedback will help inform the next versions.", "To help us prevent this in future, please send us logs.": "To help us prevent this in future, please send us logs.", "Missing session data": "Missing session data", "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.": "Some session data, including encrypted message keys, is missing. Sign out and sign in to fix this, restoring keys from backup.", @@ -3104,7 +3118,6 @@ "Set status": "Set status", "Clear status": "Clear status", "Set a new status": "Set a new status", - "Clear": "Clear", "Got an account? Sign in": "Got an account? Sign in", "New here? Create an account": "New here? Create an account", "Do not disturb": "Do not disturb", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 8d198877a6..a86f053512 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -360,6 +360,13 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td("Use new room breadcrumbs"), default: false, }, + "feature_spotlight": { + isFeature: true, + labsGroup: LabGroup.Rooms, + supportedLevels: LEVELS_FEATURE, + displayName: _td("New spotlight search experience"), + default: false, + }, "RoomList.backgroundImage": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, default: null, @@ -597,6 +604,11 @@ export const SETTINGS: {[setting: string]: ISetting} = { supportedLevels: [SettingLevel.ACCOUNT], default: [], }, + "SpotlightSearch.recentSearches": { + // not really a setting + supportedLevels: [SettingLevel.ACCOUNT], + default: [], // list of room IDs, most recent first + }, "room_directory_servers": { supportedLevels: [SettingLevel.ACCOUNT], default: [], diff --git a/test/accessibility/RovingTabIndex-test.tsx b/test/accessibility/RovingTabIndex-test.tsx index 9d9eefcc9a..b819de7222 100644 --- a/test/accessibility/RovingTabIndex-test.tsx +++ b/test/accessibility/RovingTabIndex-test.tsx @@ -226,17 +226,6 @@ describe("RovingTabIndex", () => { refs: [ref1], }); - state = reducer(state, { - type: Type.Register, - payload: { - ref: ref1, - }, - }); - expect(state).toStrictEqual({ - activeRef: ref1, - refs: [ref1], - }); - state = reducer(state, { type: Type.Register, payload: {