Integrate searching public rooms and people into the new search experience (#8707)
* Implement searching for public rooms and users in new search experience * Implement loading indicator for spotlight results * Moved spotlight dialog into own subfolder * Extract search result avatar into separate component * Build generic new dropdown menu component * Build new network menu based on new network dropdown component * Switch roomdirectory to use new network dropdown * Replace old networkdropdown with new networkdropdown * Added component for public room result details * Extract hooks and subcomponents from SpotlightDialog * Create new hook to get profile info based for an mxid * Add hook to automatically re-request search results * Add hook to prevent out-of-order search results * Extract member sort algorithm from InviteDialog * Keep sorting for non-room results stable * Sort people suggestions using sort algorithm from InviteDialog * Add copy/copied tooltip for invite link option in spotlight * Clamp length of topic for public room results * Add unit test for useDebouncedSearch * Add unit test for useProfileInfo * Create cypress test cases for spotlight dialog * Add test for useLatestResult to prevent out-of-order results
This commit is contained in:
parent
37298d7b1b
commit
5096e7b992
38 changed files with 3520 additions and 1397 deletions
183
src/components/structures/GenericDropdownMenu.tsx
Normal file
183
src/components/structures/GenericDropdownMenu.tsx
Normal file
|
@ -0,0 +1,183 @@
|
|||
/*
|
||||
Copyright 2022 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 classNames from "classnames";
|
||||
import React, { FunctionComponent, Key, PropsWithChildren, ReactNode } from "react";
|
||||
|
||||
import { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio";
|
||||
import { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import ContextMenu, { aboveLeftOf, ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu";
|
||||
|
||||
export type GenericDropdownMenuOption<T> = {
|
||||
key: T;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
adornment?: ReactNode;
|
||||
};
|
||||
|
||||
export type GenericDropdownMenuGroup<T> = GenericDropdownMenuOption<T> & {
|
||||
options: GenericDropdownMenuOption<T>[];
|
||||
};
|
||||
|
||||
export type GenericDropdownMenuItem<T> = GenericDropdownMenuGroup<T> | GenericDropdownMenuOption<T>;
|
||||
|
||||
export function GenericDropdownMenuOption<T extends Key>({
|
||||
label,
|
||||
description,
|
||||
onClick,
|
||||
isSelected,
|
||||
adornment,
|
||||
}: GenericDropdownMenuOption<T> & {
|
||||
onClick: (ev: ButtonEvent) => void;
|
||||
isSelected: boolean;
|
||||
}): JSX.Element {
|
||||
return <MenuItemRadio
|
||||
active={isSelected}
|
||||
className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--item"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="mx_GenericDropdownMenu_Option--label">
|
||||
<span>{ label }</span>
|
||||
<span>{ description }</span>
|
||||
</div>
|
||||
{ adornment }
|
||||
</MenuItemRadio>;
|
||||
}
|
||||
|
||||
export function GenericDropdownMenuGroup<T extends Key>({
|
||||
label,
|
||||
description,
|
||||
adornment,
|
||||
children,
|
||||
}: PropsWithChildren<GenericDropdownMenuOption<T>>): JSX.Element {
|
||||
return <>
|
||||
<div className="mx_GenericDropdownMenu_Option mx_GenericDropdownMenu_Option--header">
|
||||
<div className="mx_GenericDropdownMenu_Option--label">
|
||||
<span>{ label }</span>
|
||||
<span>{ description }</span>
|
||||
</div>
|
||||
{ adornment }
|
||||
</div>
|
||||
{ children }
|
||||
</>;
|
||||
}
|
||||
|
||||
function isGenericDropdownMenuGroup<T>(
|
||||
item: GenericDropdownMenuItem<T>,
|
||||
): item is GenericDropdownMenuGroup<T> {
|
||||
return "options" in item;
|
||||
}
|
||||
|
||||
type WithKeyFunction<T> = T extends Key ? {
|
||||
toKey?: (key: T) => Key;
|
||||
} : {
|
||||
toKey: (key: T) => Key;
|
||||
};
|
||||
|
||||
type IProps<T> = WithKeyFunction<T> & {
|
||||
value: T;
|
||||
options: (readonly GenericDropdownMenuOption<T>[] | readonly GenericDropdownMenuGroup<T>[]);
|
||||
onChange: (option: T) => void;
|
||||
selectedLabel: (option: GenericDropdownMenuItem<T> | null | undefined) => ReactNode;
|
||||
onOpen?: (ev: ButtonEvent) => void;
|
||||
onClose?: (ev: ButtonEvent) => void;
|
||||
className?: string;
|
||||
AdditionalOptions?: FunctionComponent<{
|
||||
menuDisplayed: boolean;
|
||||
closeMenu: () => void;
|
||||
openMenu: () => void;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function GenericDropdownMenu<T>(
|
||||
{ value, onChange, options, selectedLabel, onOpen, onClose, toKey, className, AdditionalOptions }: IProps<T>,
|
||||
): JSX.Element {
|
||||
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu<HTMLElement>();
|
||||
|
||||
const selected: GenericDropdownMenuItem<T> | null = options
|
||||
.flatMap(it => isGenericDropdownMenuGroup(it) ? [it, ...it.options] : [it])
|
||||
.find(option => toKey ? toKey(option.key) === toKey(value) : option.key === value);
|
||||
let contextMenuOptions: JSX.Element;
|
||||
if (options && isGenericDropdownMenuGroup(options[0])) {
|
||||
contextMenuOptions = <>
|
||||
{ options.map(group => (
|
||||
<GenericDropdownMenuGroup
|
||||
key={toKey?.(group.key) ?? group.key}
|
||||
label={group.label}
|
||||
description={group.description}
|
||||
adornment={group.adornment}
|
||||
>
|
||||
{ group.options.map(option => (
|
||||
<GenericDropdownMenuOption
|
||||
key={toKey?.(option.key) ?? option.key}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
onChange(option.key);
|
||||
closeMenu();
|
||||
onClose?.(ev);
|
||||
}}
|
||||
adornment={option.adornment}
|
||||
isSelected={option === selected}
|
||||
/>
|
||||
)) }
|
||||
</GenericDropdownMenuGroup>
|
||||
)) }
|
||||
</>;
|
||||
} else {
|
||||
contextMenuOptions = <>
|
||||
{ options.map(option => (
|
||||
<GenericDropdownMenuOption
|
||||
key={toKey?.(option.key) ?? option.key}
|
||||
label={option.label}
|
||||
description={option.description}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
onChange(option.key);
|
||||
closeMenu();
|
||||
onClose?.(ev);
|
||||
}}
|
||||
adornment={option.adornment}
|
||||
isSelected={option === selected}
|
||||
/>
|
||||
)) }
|
||||
</>;
|
||||
}
|
||||
const contextMenu = menuDisplayed ? <ContextMenu
|
||||
onFinished={closeMenu}
|
||||
chevronFace={ChevronFace.Top}
|
||||
wrapperClassName={classNames("mx_GenericDropdownMenu_wrapper", className)}
|
||||
{...aboveLeftOf(button.current.getBoundingClientRect())}
|
||||
>
|
||||
{ contextMenuOptions }
|
||||
{ AdditionalOptions && (
|
||||
<AdditionalOptions menuDisplayed={menuDisplayed} openMenu={openMenu} closeMenu={closeMenu} />
|
||||
) }
|
||||
</ContextMenu> : null;
|
||||
return <>
|
||||
<ContextMenuButton
|
||||
className="mx_GenericDropdownMenu_button"
|
||||
inputRef={button}
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
openMenu();
|
||||
onOpen?.(ev);
|
||||
}}
|
||||
>
|
||||
{ selectedLabel(selected) }
|
||||
</ContextMenuButton>
|
||||
{ contextMenu }
|
||||
</>;
|
||||
}
|
|
@ -27,9 +27,9 @@ import Modal from "../../Modal";
|
|||
import { _t } from '../../languageHandler';
|
||||
import SdkConfig from '../../SdkConfig';
|
||||
import { instanceForInstanceId, protocolNameForInstanceId, ALL_ROOMS, Protocols } from '../../utils/DirectoryUtils';
|
||||
import NetworkDropdown from "../views/directory/NetworkDropdown";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { IDialogProps } from "../views/dialogs/IDialogProps";
|
||||
import { IPublicRoomDirectoryConfig, NetworkDropdown } from "../views/directory/NetworkDropdown";
|
||||
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
|
||||
import ErrorDialog from "../views/dialogs/ErrorDialog";
|
||||
import QuestionDialog from "../views/dialogs/QuestionDialog";
|
||||
|
@ -54,16 +54,15 @@ interface IState {
|
|||
publicRooms: IPublicRoomsChunkRoom[];
|
||||
loading: boolean;
|
||||
protocolsLoading: boolean;
|
||||
error?: string;
|
||||
instanceId: string;
|
||||
roomServer: string;
|
||||
error?: string | null;
|
||||
serverConfig: IPublicRoomDirectoryConfig | null;
|
||||
filterString: string;
|
||||
}
|
||||
|
||||
export default class RoomDirectory extends React.Component<IProps, IState> {
|
||||
private unmounted = false;
|
||||
private nextBatch: string = null;
|
||||
private filterTimeout: number;
|
||||
private nextBatch: string | null = null;
|
||||
private filterTimeout: number | null;
|
||||
private protocols: Protocols;
|
||||
|
||||
constructor(props) {
|
||||
|
@ -77,10 +76,10 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
MatrixClientPeg.get().getThirdpartyProtocols().then((response) => {
|
||||
this.protocols = response;
|
||||
const myHomeserver = MatrixClientPeg.getHomeserverName();
|
||||
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
|
||||
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
|
||||
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY) ?? undefined;
|
||||
const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined;
|
||||
|
||||
let roomServer = myHomeserver;
|
||||
let roomServer: string | undefined = myHomeserver;
|
||||
if (
|
||||
SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) ||
|
||||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
|
||||
|
@ -88,7 +87,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
roomServer = lsRoomServer;
|
||||
}
|
||||
|
||||
let instanceId: string = null;
|
||||
let instanceId: string | undefined = undefined;
|
||||
if (roomServer === myHomeserver && (
|
||||
lsInstanceId === ALL_ROOMS ||
|
||||
Object.values(this.protocols).some(p => p.instances.some(i => i.instance_id === lsInstanceId))
|
||||
|
@ -97,11 +96,11 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// Refresh the room list only if validation failed and we had to change these
|
||||
if (this.state.instanceId !== instanceId || this.state.roomServer !== roomServer) {
|
||||
if (this.state.serverConfig?.instanceId !== instanceId ||
|
||||
this.state.serverConfig?.roomServer !== roomServer) {
|
||||
this.setState({
|
||||
protocolsLoading: false,
|
||||
instanceId,
|
||||
roomServer,
|
||||
serverConfig: roomServer ? { instanceId, roomServer } : null,
|
||||
});
|
||||
this.refreshRoomList();
|
||||
return;
|
||||
|
@ -127,12 +126,20 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
let serverConfig: IPublicRoomDirectoryConfig | null = null;
|
||||
const roomServer = localStorage.getItem(LAST_SERVER_KEY);
|
||||
if (roomServer) {
|
||||
serverConfig = {
|
||||
roomServer,
|
||||
instanceId: localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
this.state = {
|
||||
publicRooms: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
instanceId: localStorage.getItem(LAST_INSTANCE_KEY),
|
||||
roomServer: localStorage.getItem(LAST_SERVER_KEY),
|
||||
serverConfig,
|
||||
filterString: this.props.initialText || "",
|
||||
protocolsLoading,
|
||||
};
|
||||
|
@ -166,7 +173,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
|
||||
const filterString = this.state.filterString;
|
||||
const roomServer = this.state.roomServer;
|
||||
const roomServer = this.state.serverConfig?.roomServer;
|
||||
// remember the next batch token when we sent the request
|
||||
// too. If it's changed, appending to the list will corrupt it.
|
||||
const nextBatch = this.nextBatch;
|
||||
|
@ -174,17 +181,17 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
if (roomServer != MatrixClientPeg.getHomeserverName()) {
|
||||
opts.server = roomServer;
|
||||
}
|
||||
if (this.state.instanceId === ALL_ROOMS) {
|
||||
if (this.state.serverConfig?.instanceId === ALL_ROOMS) {
|
||||
opts.include_all_networks = true;
|
||||
} else if (this.state.instanceId) {
|
||||
opts.third_party_instance_id = this.state.instanceId as string;
|
||||
} else if (this.state.serverConfig?.instanceId) {
|
||||
opts.third_party_instance_id = this.state.serverConfig?.instanceId as string;
|
||||
}
|
||||
if (this.nextBatch) opts.since = this.nextBatch;
|
||||
if (filterString) opts.filter = { generic_search_term: filterString };
|
||||
return MatrixClientPeg.get().publicRooms(opts).then((data) => {
|
||||
if (
|
||||
filterString != this.state.filterString ||
|
||||
roomServer != this.state.roomServer ||
|
||||
roomServer != this.state.serverConfig?.roomServer ||
|
||||
nextBatch != this.nextBatch) {
|
||||
// if the filter or server has changed since this request was sent,
|
||||
// throw away the result (don't even clear the busy flag
|
||||
|
@ -197,7 +204,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
return false;
|
||||
}
|
||||
|
||||
this.nextBatch = data.next_batch;
|
||||
this.nextBatch = data.next_batch ?? null;
|
||||
this.setState((s) => ({
|
||||
...s,
|
||||
publicRooms: [...s.publicRooms, ...(data.chunk || [])],
|
||||
|
@ -207,7 +214,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}, (err) => {
|
||||
if (
|
||||
filterString != this.state.filterString ||
|
||||
roomServer != this.state.roomServer ||
|
||||
roomServer != this.state.serverConfig?.roomServer ||
|
||||
nextBatch != this.nextBatch) {
|
||||
// as above: we don't care about errors for old requests either
|
||||
return false;
|
||||
|
@ -227,6 +234,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
(err && err.message) ? err.message : _t('The homeserver may be unavailable or overloaded.')
|
||||
),
|
||||
});
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -279,7 +287,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
});
|
||||
};
|
||||
|
||||
private onOptionChange = (server: string, instanceId?: string) => {
|
||||
private onOptionChange = (serverConfig: IPublicRoomDirectoryConfig) => {
|
||||
// clear next batch so we don't try to load more rooms
|
||||
this.nextBatch = null;
|
||||
this.setState({
|
||||
|
@ -287,8 +295,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
// spend time filtering lots of rooms when we're about to
|
||||
// to clear the list anyway.
|
||||
publicRooms: [],
|
||||
roomServer: server,
|
||||
instanceId: instanceId,
|
||||
serverConfig,
|
||||
error: null,
|
||||
}, this.refreshRoomList);
|
||||
// We also refresh the room list each time even though this
|
||||
|
@ -299,9 +306,9 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
// Easiest to just blow away the state & re-fetch.
|
||||
|
||||
// We have to be careful here so that we don't set instanceId = "undefined"
|
||||
localStorage.setItem(LAST_SERVER_KEY, server);
|
||||
if (instanceId) {
|
||||
localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
|
||||
localStorage.setItem(LAST_SERVER_KEY, serverConfig.roomServer);
|
||||
if (serverConfig.instanceId) {
|
||||
localStorage.setItem(LAST_INSTANCE_KEY, serverConfig.instanceId);
|
||||
} else {
|
||||
localStorage.removeItem(LAST_INSTANCE_KEY);
|
||||
}
|
||||
|
@ -346,8 +353,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
const cli = MatrixClientPeg.get();
|
||||
try {
|
||||
joinRoomByAlias(cli, alias, {
|
||||
instanceId: this.state.instanceId,
|
||||
roomServer: this.state.roomServer,
|
||||
instanceId: this.state.serverConfig?.instanceId,
|
||||
roomServer: this.state.serverConfig?.roomServer,
|
||||
protocols: this.protocols,
|
||||
metricsTrigger: "RoomDirectory",
|
||||
});
|
||||
|
@ -380,7 +387,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
roomAlias,
|
||||
autoJoin,
|
||||
shouldPeek,
|
||||
roomServer: this.state.roomServer,
|
||||
roomServer: this.state.serverConfig?.roomServer,
|
||||
metricsTrigger: "RoomDirectory",
|
||||
});
|
||||
};
|
||||
|
@ -465,7 +472,7 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
|
||||
let listHeader;
|
||||
if (!this.state.protocolsLoading) {
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.instanceId);
|
||||
const protocolName = protocolNameForInstanceId(this.protocols, this.state.serverConfig?.instanceId);
|
||||
let instanceExpectedFieldType;
|
||||
if (
|
||||
protocolName &&
|
||||
|
@ -479,9 +486,9 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
let placeholder = _t('Find a room…');
|
||||
if (!this.state.instanceId || this.state.instanceId === ALL_ROOMS) {
|
||||
if (!this.state.serverConfig?.instanceId || this.state.serverConfig?.instanceId === ALL_ROOMS) {
|
||||
placeholder = _t("Find a room… (e.g. %(exampleRoom)s)", {
|
||||
exampleRoom: "#example:" + this.state.roomServer,
|
||||
exampleRoom: "#example:" + this.state.serverConfig?.roomServer,
|
||||
});
|
||||
} else if (instanceExpectedFieldType) {
|
||||
placeholder = instanceExpectedFieldType.placeholder;
|
||||
|
@ -489,8 +496,8 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
|
||||
let showJoinButton = this.stringLooksLikeId(this.state.filterString, instanceExpectedFieldType);
|
||||
if (protocolName) {
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.instanceId);
|
||||
if (getFieldsForThirdPartyLocation(
|
||||
const instance = instanceForInstanceId(this.protocols, this.state.serverConfig?.instanceId);
|
||||
if (!instance || getFieldsForThirdPartyLocation(
|
||||
this.state.filterString,
|
||||
this.protocols[protocolName],
|
||||
instance,
|
||||
|
@ -511,14 +518,13 @@ export default class RoomDirectory extends React.Component<IProps, IState> {
|
|||
/>
|
||||
<NetworkDropdown
|
||||
protocols={this.protocols}
|
||||
onOptionChange={this.onOptionChange}
|
||||
selectedServerName={this.state.roomServer}
|
||||
selectedInstanceId={this.state.instanceId}
|
||||
config={this.state.serverConfig}
|
||||
setConfig={this.onOptionChange}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
const explanation =
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.", null,
|
||||
_t("If you can't find the room you're looking for, ask for an invite or <a>create a new room</a>.", {},
|
||||
{ a: sub => (
|
||||
<AccessibleButton kind="link_inline" onClick={this.onCreateRoomClick}>
|
||||
{ sub }
|
||||
|
|
|
@ -31,7 +31,7 @@ import { UPDATE_SELECTED_SPACE } from "../../stores/spaces";
|
|||
import { IS_MAC, Key } from "../../Keyboard";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import Modal from "../../Modal";
|
||||
import SpotlightDialog from "../views/dialogs/SpotlightDialog";
|
||||
import SpotlightDialog from "../views/dialogs/spotlight/SpotlightDialog";
|
||||
import { ALTERNATE_KEY_NAME, KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
|
||||
import ToastStore from "../../stores/ToastStore";
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue