Spotlight search labs (#7116)

This commit is contained in:
Michael Telatynski 2021-12-10 11:50:01 +00:00 committed by GitHub
parent c56833816a
commit 914b61239c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 907 additions and 54 deletions

View file

@ -113,6 +113,7 @@
@import "./views/dialogs/_ShareDialog.scss"; @import "./views/dialogs/_ShareDialog.scss";
@import "./views/dialogs/_SlashCommandHelpDialog.scss"; @import "./views/dialogs/_SlashCommandHelpDialog.scss";
@import "./views/dialogs/_SpaceSettingsDialog.scss"; @import "./views/dialogs/_SpaceSettingsDialog.scss";
@import "./views/dialogs/_SpotlightDialog.scss";
@import "./views/dialogs/_TabbedIntegrationManagerDialog.scss"; @import "./views/dialogs/_TabbedIntegrationManagerDialog.scss";
@import "./views/dialogs/_TermsDialog.scss"; @import "./views/dialogs/_TermsDialog.scss";
@import "./views/dialogs/_UntrustedDeviceDialog.scss"; @import "./views/dialogs/_UntrustedDeviceDialog.scss";

View file

@ -39,6 +39,7 @@ limitations under the License.
content: ''; content: '';
width: 8px; width: 8px;
height: 8px; height: 8px;
right: 0;
position: absolute; position: absolute;
border-radius: 8px; border-radius: 8px;
} }

View file

@ -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;
}
}
}

View file

@ -162,7 +162,7 @@ export function roomContextDetailsText(room: Room): string {
const dmPartner = DMRoomMap.shared().getUserIdForRoomId(room.roomId); const dmPartner = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (dmPartner) { if (dmPartner) {
return room.getMember(dmPartner)?.rawDisplayName; return dmPartner;
} }
const [parent, ...otherParents] = SpaceStore.instance.getKnownParents(room.roomId); const [parent, ...otherParents] = SpaceStore.instance.getKnownParents(room.roomId);

View file

@ -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 * 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 { export interface IState {
activeRef: Ref; activeRef: Ref;
refs: Ref[]; refs: Ref[];
@ -55,7 +53,7 @@ interface IContext {
dispatch: Dispatch<IAction>; dispatch: Dispatch<IAction>;
} }
const RovingTabIndexContext = createContext<IContext>({ export const RovingTabIndexContext = createContext<IContext>({
state: { state: {
activeRef: null, activeRef: null,
refs: [], // list of refs in DOM order refs: [], // list of refs in DOM order
@ -80,37 +78,29 @@ interface IAction {
export const reducer = (state: IState, action: IAction) => { export const reducer = (state: IState, action: IAction) => {
switch (action.type) { switch (action.type) {
case Type.Register: { 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) { if (!state.activeRef) {
// Our list of refs was empty, set activeRef to this first item // Our list of refs was empty, set activeRef to this first item
state.activeRef = action.payload.ref; state.activeRef = action.payload.ref;
} }
// update the refs list // Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
if (index < state.refs.length) {
state.refs.splice(index, 0, action.payload.ref);
} else {
state.refs.push(action.payload.ref); 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 }; return { ...state };
} }

View file

@ -30,6 +30,9 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
import SpaceStore from "../../stores/spaces/SpaceStore"; import SpaceStore from "../../stores/spaces/SpaceStore";
import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { isMac } from "../../Keyboard"; import { isMac } from "../../Keyboard";
import SettingsStore from "../../settings/SettingsStore";
import Modal from "../../Modal";
import SpotlightDialog from "../views/dialogs/SpotlightDialog";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
@ -83,12 +86,20 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput); SpaceStore.instance.off(UPDATE_SELECTED_SPACE, this.clearInput);
} }
private openSpotlight() {
Modal.createTrackedDialog("Spotlight", "", SpotlightDialog, {}, "mx_SpotlightDialog_wrapper", false, true);
}
private onAction = (payload: ActionPayload) => { private onAction = (payload: ActionPayload) => {
if (payload.action === Action.ViewRoom && payload.clear_search) { if (payload.action === Action.ViewRoom && payload.clear_search) {
this.clearInput(); this.clearInput();
} else if (payload.action === 'focus_room_filter' && this.inputRef.current) { } else if (payload.action === 'focus_room_filter' && this.inputRef.current) {
if (SettingsStore.getValue("feature_spotlight")) {
this.openSpotlight();
} else {
this.inputRef.current.focus(); this.inputRef.current.focus();
} }
}
}; };
private clearInput = () => { private clearInput = () => {
@ -107,6 +118,14 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
this.setState({ query: this.inputRef.current.value }); this.setState({ query: this.inputRef.current.value });
}; };
private onMouseDown = (ev: React.MouseEvent<HTMLInputElement>) => {
if (SettingsStore.getValue("feature_spotlight")) {
ev.preventDefault();
ev.stopPropagation();
this.openSpotlight();
}
};
private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => { private onFocus = (ev: React.FocusEvent<HTMLInputElement>) => {
this.setState({ focused: true }); this.setState({ focused: true });
ev.target.select(); ev.target.select();
@ -162,11 +181,12 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
ref={this.inputRef} ref={this.inputRef}
className={inputClasses} className={inputClasses}
value={this.state.query} value={this.state.query}
onMouseDown={this.onMouseDown}
onFocus={this.onFocus} onFocus={this.onFocus}
onBlur={this.onBlur} onBlur={this.onBlur}
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
placeholder={_t("Filter")} placeholder={SettingsStore.getValue("feature_spotlight") ? _t("Search") : _t("Filter")}
autoComplete="off" autoComplete="off"
/> />
); );

View file

@ -15,7 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { createRef } from 'react'; import React, { createRef, HTMLProps } from 'react';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
@ -25,7 +25,7 @@ import AccessibleButton from '../../components/views/elements/AccessibleButton';
import { replaceableComponent } from "../../utils/replaceableComponent"; import { replaceableComponent } from "../../utils/replaceableComponent";
import { Action } from '../../dispatcher/actions'; import { Action } from '../../dispatcher/actions';
interface IProps { interface IProps extends HTMLProps<HTMLInputElement> {
onSearch?: (query: string) => void; onSearch?: (query: string) => void;
onCleared?: (source?: string) => void; onCleared?: (source?: string) => void;
onKeyDown?: (ev: React.KeyboardEvent) => void; onKeyDown?: (ev: React.KeyboardEvent) => void;
@ -135,11 +135,15 @@ export default class SearchBox extends React.Component<IProps, IState> {
} }
public render(): JSX.Element { 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 // check for collapsed here and
// not at parent so we keep // not at parent so we keep
// searchTerm in our state // searchTerm in our state
// when collapsing and expanding // when collapsing and expanding
if (this.props.collapsed) { if (collapsed) {
return null; return null;
} }
const clearButton = (!this.state.blurred || this.state.searchTerm) ? const clearButton = (!this.state.blurred || this.state.searchTerm) ?
@ -153,13 +157,10 @@ export default class SearchBox extends React.Component<IProps, IState> {
// show a shorter placeholder when blurred, if requested // show a shorter placeholder when blurred, if requested
// this is used for the room filter field that has // this is used for the room filter field that has
// the explore button next to it when blurred // 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 ( return (
<div className={classNames("mx_SearchBox", "mx_textinput", { "mx_SearchBox_blurred": this.state.blurred })}> <div className={classNames("mx_SearchBox", "mx_textinput", { "mx_SearchBox_blurred": this.state.blurred })}>
<input <input
{...props}
key="searchfield" key="searchfield"
type="text" type="text"
ref={this.search} ref={this.search}
@ -169,7 +170,7 @@ export default class SearchBox extends React.Component<IProps, IState> {
onChange={this.onChange} onChange={this.onChange}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
onBlur={this.onBlur} onBlur={this.onBlur}
placeholder={placeholder} placeholder={this.state.blurred ? (blurredPlaceholder || placeholder) : placeholder}
autoComplete="off" autoComplete="off"
autoFocus={this.props.autoFocus} autoFocus={this.props.autoFocus}
/> />

View file

@ -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<ComponentProps<typeof RovingAccessibleButton>> = ({ inputRef, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleButton
{...props}
onFocus={onFocus}
inputRef={ref}
tabIndex={-1}
aria-selected={isActive}
role="option"
/>;
};
const TooltipOption: React.FC<ComponentProps<typeof RovingAccessibleTooltipButton>> = ({ inputRef, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return <AccessibleTooltipButton
{...props}
onFocus={onFocus}
inputRef={ref}
tabIndex={-1}
aria-selected={isActive}
role="option"
/>;
};
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 <div className="mx_SpotlightDialog_result_details">
{ roomContextDetails }
</div>;
}
return null;
};
interface IProps extends IDialogProps {
initialText?: string;
}
const useSpaceResults = (space?: Room, query?: string): [IHierarchyRoom[], boolean] => {
const [rooms, setRooms] = useState<IHierarchyRoom[]>([]);
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
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<IProps> = ({ initialText = "", onFinished }) => {
const cli = MatrixClientPeg.get();
const rovingContext = useContext(RovingTabIndexContext);
const [query, _setQuery] = useState(initialText);
const [recentSearches, clearRecentSearches] = useRecentSearches();
const results = useMemo<Room[] | null>(() => {
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<HTMLInputElement>): 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 => (
<Option
id={`mx_SpotlightDialog_button_result_${room.roomId}`}
key={room.roomId}
onClick={() => {
viewRoom(room.roomId, true);
}}
>
<RoomAvatar room={room} width={20} height={20} />
{ room.name }
<ResultDetails room={room} />
<div className="mx_SpotlightDialog_enterPrompt"></div>
</Option>
);
let peopleSection: JSX.Element;
if (people.length) {
peopleSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("People") }</h4>
<div>
{ people.slice(0, SECTION_LIMIT).map(resultMapper) }
</div>
</div>;
}
let roomsSection: JSX.Element;
if (rooms.length) {
roomsSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("Rooms") }</h4>
<div>
{ rooms.slice(0, SECTION_LIMIT).map(resultMapper) }
</div>
</div>;
}
let spacesSection: JSX.Element;
if (spaces.length) {
spacesSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("Spaces you're in") }</h4>
<div>
{ spaces.slice(0, SECTION_LIMIT).map(resultMapper) }
</div>
</div>;
}
let spaceRoomsSection: JSX.Element;
if (spaceResults.length) {
spaceRoomsSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
<h4>{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }</h4>
<div>
{ spaceResults.slice(0, SECTION_LIMIT).map((room: IHierarchyRoom): JSX.Element => (
<Option
id={`mx_SpotlightDialog_button_result_${room.room_id}`}
key={room.room_id}
onClick={() => {
viewRoom(room.room_id, true);
}}
>
<BaseAvatar
name={room.name}
idName={room.room_id}
url={room.avatar_url ? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(20) : null}
width={20}
height={20}
/>
{ room.name || room.canonical_alias }
{ room.name && room.canonical_alias && <div className="mx_SpotlightDialog_result_details">
{ room.canonical_alias }
</div> }
<div className="mx_SpotlightDialog_enterPrompt"></div>
</Option>
)) }
{ spaceResultsLoading && <Spinner /> }
</div>
</div>;
}
content = <>
{ peopleSection }
{ roomsSection }
{ spacesSection }
{ spaceRoomsSection }
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
<h4>{ _t('Use "%(query)s" to search', { query }) }</h4>
<div>
<Option
id="mx_SpotlightDialog_button_explorePublicRooms"
className="mx_SpotlightDialog_explorePublicRooms"
onClick={() => {
defaultDispatcher.dispatch({
action: Action.ViewRoomDirectory,
initialText: query,
});
onFinished();
}}
>
{ _t("Public rooms") }
<div className="mx_SpotlightDialog_enterPrompt"></div>
</Option>
<Option
id="mx_SpotlightDialog_button_startChat"
className="mx_SpotlightDialog_startChat"
onClick={() => {
showStartChatInviteDialog(query);
onFinished();
}}
>
{ _t("People") }
<div className="mx_SpotlightDialog_enterPrompt"></div>
</Option>
</div>
</div>
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
<h4>{ _t("Other searches") }</h4>
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
{ _t("To search messages, look for this icon at the top of a room <icon/>", {}, {
icon: () => <div className="mx_SpotlightDialog_otherSearches_messageSearchIcon" />,
}) }
</div>
</div>
</>;
} else {
let recentSearchesSection: JSX.Element;
if (recentSearches.length) {
recentSearchesSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_recentSearches" role="group">
<h4>
{ _t("Recent searches") }
<AccessibleButton kind="link" onClick={clearRecentSearches}>
{ _t("Clear") }
</AccessibleButton>
</h4>
<div>
{ recentSearches.map(room => (
<Option
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}`}
key={room.roomId}
onClick={() => {
viewRoom(room.roomId, true);
}}
>
<RoomAvatar room={room} width={20} height={20} />
{ room.name }
<div className="mx_SpotlightDialog_enterPrompt"></div>
</Option>
)) }
</div>
</div>
);
}
content = <>
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_recentlyViewed" role="group">
<h4>{ _t("Recently viewed") }</h4>
<div>
{ BreadcrumbsStore.instance.rooms
.filter(r => r.roomId !== RoomViewStore.getRoomId())
.slice(0, 10)
.map(room => (
<TooltipOption
id={`mx_SpotlightDialog_button_recentlyViewed_${room.roomId}`}
title={room.name}
key={room.roomId}
onClick={() => {
viewRoom(room.roomId);
}}
>
<DecoratedRoomAvatar room={room} avatarSize={32} />
{ room.name }
</TooltipOption>
))
}
</div>
</div>
{ recentSearchesSection }
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
<h4>{ _t("Other searches") }</h4>
<div>
<Option
id="mx_SpotlightDialog_button_explorePublicRooms"
className="mx_SpotlightDialog_explorePublicRooms"
onClick={() => {
defaultDispatcher.fire(Action.ViewRoomDirectory);
onFinished();
}}
>
{ _t("Explore public rooms") }
<div className="mx_SpotlightDialog_enterPrompt"></div>
</Option>
</div>
</div>
</>;
}
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 <>
<div className="mx_SpotlightDialog_keyboardPrompt">
{ _t("Use <arrows/> to scroll results", {}, {
arrows: () => <>
<div></div>
<div></div>
</>,
}) }
</div>
<BaseDialog
className="mx_SpotlightDialog"
onFinished={onFinished}
hasCancel={false}
onKeyDown={onDialogKeyDown}
>
<div className="mx_SpotlightDialog_searchBox mx_textinput">
<input
autoFocus
type="text"
autoComplete="off"
placeholder={_t("Search")}
value={query}
onChange={setQuery}
onKeyDown={onKeyDown}
aria-owns="mx_SpotlightDialog_content"
aria-activedescendant={activeDescendant}
/>
</div>
<div id="mx_SpotlightDialog_content" role="listbox" aria-activedescendant={activeDescendant}>
{ content }
</div>
<div className="mx_SpotlightDialog_footer">
<span>
{ activeSpace
? _t("Searching rooms and chats you're in and %(spaceName)s", { spaceName: activeSpace.name })
: _t("Searching rooms and chats you're in") }
</span>
<AccessibleButton
kind="primary_outline"
onClick={() => {
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") }
</AccessibleButton>
</div>
</BaseDialog>
</>;
};
const RovingSpotlightDialog: React.FC<IProps> = (props) => {
return <RovingTabIndexProvider>
{ () => <SpotlightDialog {...props} /> }
</RovingTabIndexProvider>;
};
export default RovingSpotlightDialog;

View file

@ -193,7 +193,7 @@ export enum Action {
SwitchSpace = "switch_space", 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", UpdateSpaceHierarchy = "update_space_hierarchy",
@ -232,5 +232,5 @@ export enum Action {
* The user rejected anonymous analytics (i.e. matomo, pre-posthog) from the toast * The user rejected anonymous analytics (i.e. matomo, pre-posthog) from the toast
* Payload: none * Payload: none
*/ */
AnonymousAnalyticsReject = "anonymous_analytics_reject" AnonymousAnalyticsReject = "anonymous_analytics_reject",
} }

View file

@ -859,6 +859,7 @@
"New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)", "New layout switcher (with message bubbles)": "New layout switcher (with message bubbles)",
"Meta Spaces": "Meta Spaces", "Meta Spaces": "Meta Spaces",
"Use new room breadcrumbs": "Use new room breadcrumbs", "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", "Don't send read receipts": "Don't send read receipts",
"Font size": "Font size", "Font size": "Font size",
"Use custom size": "Use custom size", "Use custom size": "Use custom size",
@ -2706,6 +2707,19 @@
"Command Help": "Command Help", "Command Help": "Command Help",
"Space settings": "Space settings", "Space settings": "Space settings",
"Settings - %(spaceName)s": "Settings - %(spaceName)s", "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 <icon/>": "To search messages, look for this icon at the top of a room <icon/>",
"Recent searches": "Recent searches",
"Clear": "Clear",
"Use <arrows/> to scroll results": "Use <arrows/> 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 <a>send us logs</a>.": "To help us prevent this in future, please <a>send us logs</a>.", "To help us prevent this in future, please <a>send us logs</a>.": "To help us prevent this in future, please <a>send us logs</a>.",
"Missing session data": "Missing session data", "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.", "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", "Set status": "Set status",
"Clear status": "Clear status", "Clear status": "Clear status",
"Set a new status": "Set a new status", "Set a new status": "Set a new status",
"Clear": "Clear",
"Got an account? <a>Sign in</a>": "Got an account? <a>Sign in</a>", "Got an account? <a>Sign in</a>": "Got an account? <a>Sign in</a>",
"New here? <a>Create an account</a>": "New here? <a>Create an account</a>", "New here? <a>Create an account</a>": "New here? <a>Create an account</a>",
"Do not disturb": "Do not disturb", "Do not disturb": "Do not disturb",

View file

@ -360,6 +360,13 @@ export const SETTINGS: {[setting: string]: ISetting} = {
displayName: _td("Use new room breadcrumbs"), displayName: _td("Use new room breadcrumbs"),
default: false, default: false,
}, },
"feature_spotlight": {
isFeature: true,
labsGroup: LabGroup.Rooms,
supportedLevels: LEVELS_FEATURE,
displayName: _td("New spotlight search experience"),
default: false,
},
"RoomList.backgroundImage": { "RoomList.backgroundImage": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null, default: null,
@ -597,6 +604,11 @@ export const SETTINGS: {[setting: string]: ISetting} = {
supportedLevels: [SettingLevel.ACCOUNT], supportedLevels: [SettingLevel.ACCOUNT],
default: [], default: [],
}, },
"SpotlightSearch.recentSearches": {
// not really a setting
supportedLevels: [SettingLevel.ACCOUNT],
default: [], // list of room IDs, most recent first
},
"room_directory_servers": { "room_directory_servers": {
supportedLevels: [SettingLevel.ACCOUNT], supportedLevels: [SettingLevel.ACCOUNT],
default: [], default: [],

View file

@ -226,17 +226,6 @@ describe("RovingTabIndex", () => {
refs: [ref1], refs: [ref1],
}); });
state = reducer(state, {
type: Type.Register,
payload: {
ref: ref1,
},
});
expect(state).toStrictEqual({
activeRef: ref1,
refs: [ref1],
});
state = reducer(state, { state = reducer(state, {
type: Type.Register, type: Type.Register,
payload: { payload: {