-
- >;
-};
-
-const RovingSpotlightDialog: React.FC = (props) => {
- return
- { () => }
- ;
-};
-
-export default RovingSpotlightDialog;
diff --git a/src/components/views/dialogs/spotlight/Option.tsx b/src/components/views/dialogs/spotlight/Option.tsx
new file mode 100644
index 0000000000..3e11e7c38f
--- /dev/null
+++ b/src/components/views/dialogs/spotlight/Option.tsx
@@ -0,0 +1,43 @@
+/*
+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, { ComponentProps, ReactNode } from "react";
+
+import { RovingAccessibleButton } from "../../../../accessibility/roving/RovingAccessibleButton";
+import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
+import AccessibleButton from "../../elements/AccessibleButton";
+
+interface OptionProps extends ComponentProps {
+ endAdornment?: ReactNode;
+}
+
+export const Option: React.FC = ({ inputRef, children, endAdornment, className, ...props }) => {
+ const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
+ return
+ { children }
+
↵
+ { endAdornment }
+ ;
+};
diff --git a/src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx b/src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx
new file mode 100644
index 0000000000..2ffcad349b
--- /dev/null
+++ b/src/components/views/dialogs/spotlight/PublicRoomResultDetails.tsx
@@ -0,0 +1,67 @@
+/*
+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 React from "react";
+import { IPublicRoomsChunkRoom } from "matrix-js-sdk/src/matrix";
+
+import { linkifyAndSanitizeHtml } from "../../../../HtmlUtils";
+import { _t } from "../../../../languageHandler";
+import { getDisplayAliasForRoom } from "../../../structures/RoomDirectory";
+
+const MAX_NAME_LENGTH = 80;
+const MAX_TOPIC_LENGTH = 800;
+
+export function PublicRoomResultDetails({ room }: { room: IPublicRoomsChunkRoom }): JSX.Element {
+ let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
+ if (name.length > MAX_NAME_LENGTH) {
+ name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
+ }
+
+ let topic = room.topic || '';
+ // Additional truncation based on line numbers is done via CSS,
+ // but to ensure that the DOM is not polluted with a huge string
+ // we give it a hard limit before rendering.
+ if (topic.length > MAX_TOPIC_LENGTH) {
+ topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/components/views/dialogs/spotlight/RoomResultDetails.tsx b/src/components/views/dialogs/spotlight/RoomResultDetails.tsx
new file mode 100644
index 0000000000..39465b9c73
--- /dev/null
+++ b/src/components/views/dialogs/spotlight/RoomResultDetails.tsx
@@ -0,0 +1,31 @@
+/*
+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 React from "react";
+import { Room } from "matrix-js-sdk/src/matrix";
+
+import { roomContextDetailsText, spaceContextDetailsText } from "../../../../utils/i18n-helpers";
+
+export const RoomResultDetails = ({ room }: { room: Room }) => {
+ const contextDetails = room.isSpaceRoom() ? spaceContextDetailsText(room) : roomContextDetailsText(room);
+ if (contextDetails) {
+ return
+ { contextDetails }
+
;
+ }
+
+ return null;
+};
diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx
new file mode 100644
index 0000000000..862cd4948a
--- /dev/null
+++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx
@@ -0,0 +1,1057 @@
+/*
+Copyright 2021-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 { sum } from "lodash";
+import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch";
+import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
+import { IPublicRoomsChunkRoom, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
+import { Room } from "matrix-js-sdk/src/models/room";
+import { normalize } from "matrix-js-sdk/src/utils";
+import React, {
+ ChangeEvent,
+ KeyboardEvent,
+ RefObject,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
+import sanitizeHtml from "sanitize-html";
+
+import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
+import { Ref } from "../../../../accessibility/roving/types";
+import {
+ findSiblingElement,
+ RovingTabIndexContext,
+ RovingTabIndexProvider,
+ Type,
+} from "../../../../accessibility/RovingTabIndex";
+import { mediaFromMxc } from "../../../../customisations/Media";
+import { Action } from "../../../../dispatcher/actions";
+import defaultDispatcher from "../../../../dispatcher/dispatcher";
+import { ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload";
+import { useDebouncedCallback } from "../../../../hooks/spotlight/useDebouncedCallback";
+import { useRecentSearches } from "../../../../hooks/spotlight/useRecentSearches";
+import { useProfileInfo } from "../../../../hooks/useProfileInfo";
+import { usePublicRoomDirectory } from "../../../../hooks/usePublicRoomDirectory";
+import { useSpaceResults } from "../../../../hooks/useSpaceResults";
+import { useUserDirectory } from "../../../../hooks/useUserDirectory";
+import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
+import { _t } from "../../../../languageHandler";
+import { MatrixClientPeg } from "../../../../MatrixClientPeg";
+import Modal from "../../../../Modal";
+import { PosthogAnalytics } from "../../../../PosthogAnalytics";
+import { getCachedRoomIDForAlias } from "../../../../RoomAliasCache";
+import { showStartChatInviteDialog } from "../../../../RoomInvite";
+import SdkConfig from "../../../../SdkConfig";
+import { SettingLevel } from "../../../../settings/SettingLevel";
+import SettingsStore from "../../../../settings/SettingsStore";
+import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore";
+import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore";
+import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
+import { RoomViewStore } from "../../../../stores/RoomViewStore";
+import { getMetaSpaceName } from "../../../../stores/spaces";
+import SpaceStore from "../../../../stores/spaces/SpaceStore";
+import { DirectoryMember, Member, startDm } from "../../../../utils/direct-messages";
+import DMRoomMap from "../../../../utils/DMRoomMap";
+import { makeUserPermalink } from "../../../../utils/permalinks/Permalinks";
+import { buildActivityScores, buildMemberScores, compareMembers } from "../../../../utils/SortMembers";
+import { copyPlaintext } from "../../../../utils/strings";
+import BaseAvatar from "../../avatars/BaseAvatar";
+import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar";
+import { SearchResultAvatar } from "../../avatars/SearchResultAvatar";
+import { BetaPill } from "../../beta/BetaCard";
+import { NetworkDropdown } from "../../directory/NetworkDropdown";
+import AccessibleButton from "../../elements/AccessibleButton";
+import Spinner from "../../elements/Spinner";
+import NotificationBadge from "../../rooms/NotificationBadge";
+import BaseDialog from "../BaseDialog";
+import BetaFeedbackDialog from "../BetaFeedbackDialog";
+import { IDialogProps } from "../IDialogProps";
+import { UserTab } from "../UserTab";
+import { Option } from "./Option";
+import { PublicRoomResultDetails } from "./PublicRoomResultDetails";
+import { RoomResultDetails } from "./RoomResultDetails";
+import { TooltipOption } from "./TooltipOption";
+
+const MAX_RECENT_SEARCHES = 10;
+const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
+const AVATAR_SIZE = 24;
+
+interface IProps extends IDialogProps {
+ initialText?: string;
+ initialFilter?: Filter;
+}
+
+function refIsForRecentlyViewed(ref: RefObject): boolean {
+ return ref.current?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
+}
+
+enum Section {
+ People,
+ Rooms,
+ Spaces,
+ Suggestions,
+ PublicRooms,
+}
+
+export enum Filter {
+ People,
+ PublicRooms,
+}
+
+function filterToLabel(filter: Filter): string {
+ switch (filter) {
+ case Filter.People: return _t("People");
+ case Filter.PublicRooms: return _t("Public rooms");
+ }
+}
+
+interface IBaseResult {
+ section: Section;
+ filter: Filter[];
+ query?: string[]; // extra fields to query match, stored as lowercase
+}
+
+interface IPublicRoomResult extends IBaseResult {
+ publicRoom: IPublicRoomsChunkRoom;
+}
+
+interface IRoomResult extends IBaseResult {
+ room: Room;
+}
+
+interface IMemberResult extends IBaseResult {
+ member: Member | RoomMember;
+}
+
+interface IResult extends IBaseResult {
+ avatar: JSX.Element;
+ name: string;
+ description?: string;
+ onClick?(): void;
+}
+
+type Result = IRoomResult | IPublicRoomResult | IMemberResult | IResult;
+
+const isRoomResult = (result: any): result is IRoomResult => !!result?.room;
+const isPublicRoomResult = (result: any): result is IPublicRoomResult => !!result?.publicRoom;
+const isMemberResult = (result: any): result is IMemberResult => !!result?.member;
+
+const toPublicRoomResult = (publicRoom: IPublicRoomsChunkRoom): IPublicRoomResult => ({
+ publicRoom,
+ section: Section.PublicRooms,
+ filter: [Filter.PublicRooms],
+ query: [
+ publicRoom.room_id.toLowerCase(),
+ publicRoom.canonical_alias?.toLowerCase(),
+ publicRoom.name?.toLowerCase(),
+ sanitizeHtml(publicRoom.topic?.toLowerCase() ?? "", { allowedTags: [] }),
+ ...(publicRoom.aliases?.map(it => it.toLowerCase()) || []),
+ ].filter(Boolean),
+});
+
+const toRoomResult = (room: Room): IRoomResult => {
+ const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
+ if (otherUserId) {
+ return {
+ room,
+ section: Section.People,
+ filter: [Filter.People],
+ query: [
+ otherUserId.toLowerCase(),
+ room.getMember(otherUserId)?.name.toLowerCase(),
+ ].filter(Boolean),
+ };
+ } else if (room.isSpaceRoom()) {
+ return {
+ room,
+ section: Section.Spaces,
+ filter: [],
+ };
+ } else {
+ return {
+ room,
+ section: Section.Rooms,
+ filter: [],
+ };
+ }
+};
+
+const toMemberResult = (member: Member | RoomMember): IMemberResult => ({
+ member,
+ section: Section.Suggestions,
+ filter: [Filter.People],
+ query: [
+ member.userId.toLowerCase(),
+ member.name.toLowerCase(),
+ ].filter(Boolean),
+});
+
+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 findVisibleRooms = (cli: MatrixClient) => {
+ return cli.getVisibleRooms().filter(room => {
+ // TODO we may want to put invites in their own list
+ return room.getMyMembership() === "join" || room.getMyMembership() == "invite";
+ });
+};
+
+const findVisibleRoomMembers = (cli: MatrixClient, filterDMs = true) => {
+ return Object.values(
+ findVisibleRooms(cli)
+ .filter(room => !filterDMs || !DMRoomMap.shared().getUserIdForRoomId(room.roomId))
+ .reduce((members, room) => {
+ for (const member of room.getJoinedMembers()) {
+ members[member.userId] = member;
+ }
+ return members;
+ }, {} as Record),
+ ).filter(it => it.userId !== cli.getUserId());
+};
+
+interface IDirectoryOpts {
+ limit: number;
+ query: string;
+}
+
+const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = null, onFinished }) => {
+ const inputRef = useRef();
+ const scrollContainerRef = useRef();
+ const cli = MatrixClientPeg.get();
+ const rovingContext = useContext(RovingTabIndexContext);
+ const [query, _setQuery] = useState(initialText);
+ const [recentSearches, clearRecentSearches] = useRecentSearches();
+ const [filter, setFilterInternal] = useState(initialFilter);
+ const setFilter = useCallback(
+ (filter: Filter | null) => {
+ setFilterInternal(filter);
+ inputRef.current?.focus();
+ scrollContainerRef.current?.scrollTo?.({ top: 0 });
+ },
+ [],
+ );
+ const memberComparator = useMemo(() => {
+ const activityScores = buildActivityScores(cli);
+ const memberScores = buildMemberScores(cli);
+ return compareMembers(activityScores, memberScores);
+ }, [cli]);
+
+ const ownInviteLink = makeUserPermalink(cli.getUserId());
+ const [inviteLinkCopied, setInviteLinkCopied] = useState(false);
+ const trimmedQuery = useMemo(() => query.trim(), [query]);
+
+ const { loading: publicRoomsLoading, publicRooms, protocols, config, setConfig, search: searchPublicRooms } =
+ usePublicRoomDirectory();
+ const { loading: peopleLoading, users, search: searchPeople } = useUserDirectory();
+ const { loading: profileLoading, profile, search: searchProfileInfo } = useProfileInfo();
+ const searchParams: [IDirectoryOpts] = useMemo(() => ([{
+ query: trimmedQuery,
+ limit: SECTION_LIMIT,
+ }]), [trimmedQuery]);
+ useDebouncedCallback(
+ filter === Filter.PublicRooms,
+ searchPublicRooms,
+ searchParams,
+ );
+ useDebouncedCallback(
+ filter === Filter.People,
+ searchPeople,
+ searchParams,
+ );
+ useDebouncedCallback(
+ filter === Filter.People,
+ searchProfileInfo,
+ searchParams,
+ );
+ const possibleResults = useMemo(
+ () => {
+ const roomMembers = findVisibleRoomMembers(cli);
+ const roomMemberIds = new Set(roomMembers.map(item => item.userId));
+ return [
+ ...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({
+ section: Section.Spaces,
+ filter: [],
+ avatar: ,
+ name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome),
+ onClick() {
+ SpaceStore.instance.setActiveSpace(spaceKey);
+ },
+ })),
+ ...findVisibleRooms(cli).map(toRoomResult),
+ ...roomMembers.map(toMemberResult),
+ ...users.filter(item => !roomMemberIds.has(item.userId)).map(toMemberResult),
+ ...(profile ? [new DirectoryMember(profile)] : []).map(toMemberResult),
+ ...publicRooms.map(toPublicRoomResult),
+ ].filter(result => filter === null || result.filter.includes(filter));
+ },
+ [cli, users, profile, publicRooms, filter],
+ );
+
+ const results = useMemo>(() => {
+ const results: Record = {
+ [Section.People]: [],
+ [Section.Rooms]: [],
+ [Section.Spaces]: [],
+ [Section.Suggestions]: [],
+ [Section.PublicRooms]: [],
+ };
+
+ // Group results in their respective sections
+ if (trimmedQuery) {
+ const lcQuery = trimmedQuery.toLowerCase();
+ const normalizedQuery = normalize(trimmedQuery);
+
+ 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 (isMemberResult(entry)) {
+ if (!entry.query?.some(q => q.includes(lcQuery))) return; // bail, does not match query
+ } else if (isPublicRoomResult(entry)) {
+ if (!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);
+ });
+ } else if (filter === Filter.PublicRooms) {
+ // return all results for public rooms if no query is given
+ possibleResults.forEach(entry => {
+ if (isPublicRoomResult(entry)) {
+ results[entry.section].push(entry);
+ }
+ });
+ } else if (filter === Filter.People) {
+ // return all results for people if no query is given
+ possibleResults.forEach(entry => {
+ if (isMemberResult(entry)) {
+ results[entry.section].push(entry);
+ }
+ });
+ }
+
+ // Sort results by most recent activity
+
+ const myUserId = cli.getUserId();
+ for (const resultArray of Object.values(results)) {
+ resultArray.sort((a: Result, b: Result) => {
+ if (isRoomResult(a) || isRoomResult(b)) {
+ // Room results should appear at the top of the list
+ if (!isRoomResult(b)) return -1;
+ if (!isRoomResult(a)) return -1;
+
+ return recentAlgorithm.getLastTs(b.room, myUserId) - recentAlgorithm.getLastTs(a.room, myUserId);
+ } else if (isMemberResult(a) || isMemberResult(b)) {
+ // Member results should appear just after room results
+ if (!isMemberResult(b)) return -1;
+ if (!isMemberResult(a)) return -1;
+
+ return memberComparator(a.member, b.member);
+ }
+ });
+ }
+
+ return results;
+ }, [trimmedQuery, filter, cli, possibleResults, memberComparator]);
+
+ const numResults = sum(Object.values(results).map(it => it.length));
+ 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);
+ };
+ useEffect(() => {
+ setImmediate(() => {
+ let ref: Ref;
+ if (rovingContext.state.refs) {
+ ref = rovingContext.state.refs[0];
+ }
+ rovingContext.dispatch({
+ type: Type.SetFocus,
+ payload: { ref },
+ });
+ ref?.current?.scrollIntoView?.({
+ block: "nearest",
+ });
+ });
+ // we intentionally ignore changes to the rovingContext for the purpose of this hook
+ // we only want to reset the focus whenever the results or filters change
+ // eslint-disable-next-line
+ }, [results, filter]);
+
+ 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 otherSearchesSection: JSX.Element;
+ if (trimmedQuery || filter !== Filter.PublicRooms) {
+ otherSearchesSection = (
+
+
+ >;
+};
+
+const RovingSpotlightDialog: React.FC = (props) => {
+ return
+ { () => }
+ ;
+};
+
+export default RovingSpotlightDialog;
diff --git a/src/components/views/dialogs/spotlight/TooltipOption.tsx b/src/components/views/dialogs/spotlight/TooltipOption.tsx
new file mode 100644
index 0000000000..f24ddc8f09
--- /dev/null
+++ b/src/components/views/dialogs/spotlight/TooltipOption.tsx
@@ -0,0 +1,39 @@
+/*
+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, { ComponentProps, ReactNode } from "react";
+
+import { RovingAccessibleTooltipButton } from "../../../../accessibility/roving/RovingAccessibleTooltipButton";
+import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
+import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
+
+interface TooltipOptionProps extends ComponentProps {
+ endAdornment?: ReactNode;
+}
+
+export const TooltipOption: React.FC = ({ inputRef, className, ...props }) => {
+ const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
+ return ;
+};
diff --git a/src/components/views/directory/NetworkDropdown.tsx b/src/components/views/directory/NetworkDropdown.tsx
index d3de216411..701e5e39b9 100644
--- a/src/components/views/directory/NetworkDropdown.tsx
+++ b/src/components/views/directory/NetworkDropdown.tsx
@@ -1,6 +1,5 @@
/*
-Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
-Copyright 2016, 2020 The Matrix.org Foundation C.I.C.
+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.
@@ -15,41 +14,29 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import React, { useEffect, useState } from "react";
-import { MatrixError } from "matrix-js-sdk/src/http-api";
+import { without } from "lodash";
+import React, { useCallback, useEffect, useState } from "react";
+import { MatrixError } from "matrix-js-sdk/src/matrix";
-import { MatrixClientPeg } from '../../../MatrixClientPeg';
-import { instanceForInstanceId, ALL_ROOMS, Protocols } from '../../../utils/DirectoryUtils';
-import ContextMenu, {
- ChevronFace,
- ContextMenuButton,
- MenuGroup,
- MenuItem,
- MenuItemRadio,
- useContextMenu,
-} from "../../structures/ContextMenu";
+import { MenuItemRadio } from "../../../accessibility/context_menu/MenuItemRadio";
import { _t } from "../../../languageHandler";
-import SdkConfig from "../../../SdkConfig";
-import { useSettingValue } from "../../../hooks/useSettings";
+import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
-import SettingsStore from "../../../settings/SettingsStore";
-import withValidation from "../elements/Validation";
+import SdkConfig from "../../../SdkConfig";
import { SettingLevel } from "../../../settings/SettingLevel";
+import SettingsStore from "../../../settings/SettingsStore";
+import { Protocols } from "../../../utils/DirectoryUtils";
+import { GenericDropdownMenu, GenericDropdownMenuItem } from "../../structures/GenericDropdownMenu";
import TextInputDialog from "../dialogs/TextInputDialog";
-import QuestionDialog from "../dialogs/QuestionDialog";
-import UIStore from "../../../stores/UIStore";
-import { compare } from "../../../utils/strings";
-import { SnakedObject } from "../../../utils/SnakedObject";
-import { IConfigOptions } from "../../../IConfigOptions";
+import AccessibleButton from "../elements/AccessibleButton";
+import withValidation from "../elements/Validation";
const SETTING_NAME = "room_directory_servers";
-const inPlaceOf = (elementRect: Pick) => ({
- right: UIStore.instance.windowWidth - elementRect.right,
- top: elementRect.top,
- chevronOffset: 0,
- chevronFace: ChevronFace.None,
-});
+export interface IPublicRoomDirectoryConfig {
+ roomServer: string;
+ instanceId?: string;
+}
const validServer = withValidation({
deriveData: async ({ value }) => {
@@ -74,228 +61,170 @@ const validServer = withValidation({
final: true,
test: async (_, { error }) => !error,
valid: () => _t("Looks good"),
- invalid: ({ error }) => error.errcode === "M_FORBIDDEN"
+ invalid: ({ error }) => error?.errcode === "M_FORBIDDEN"
? _t("You are not allowed to view this server's rooms list")
: _t("Can't find this server or its room list"),
},
],
});
-interface IProps {
- protocols: Protocols;
- selectedServerName: string;
- selectedInstanceId: string;
- onOptionChange(server: string, instanceId?: string): void;
+function useSettingsValueWithSetter(
+ settingName: string,
+ level: SettingLevel,
+ roomId: string | null = null,
+ excludeDefault = false,
+): [T, (value: T) => Promise] {
+ const [value, setValue] = useState(SettingsStore.getValue(settingName, roomId ?? undefined, excludeDefault));
+ const setter = useCallback(
+ async (value: T) => {
+ setValue(value);
+ SettingsStore.setValue(settingName, roomId, level, value);
+ },
+ [level, roomId, settingName],
+ );
+
+ useEffect(() => {
+ const ref = SettingsStore.watchSetting(settingName, roomId, () => {
+ setValue(SettingsStore.getValue(settingName, roomId, excludeDefault));
+ });
+ // clean-up
+ return () => {
+ SettingsStore.unwatchSetting(ref);
+ };
+ }, [settingName, roomId, excludeDefault]);
+
+ return [value, setter];
}
-// This dropdown sources homeservers from three places:
-// + your currently connected homeserver
-// + homeservers in config.json["roomDirectory"]
-// + homeservers in SettingsStore["room_directory_servers"]
-// if a server exists in multiple, only keep the top-most entry.
+interface ServerList {
+ allServers: string[];
+ homeServer: string;
+ userDefinedServers: string[];
+ setUserDefinedServers: (servers: string[]) => void;
+}
-const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, selectedInstanceId }: IProps) => {
- const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
- const _userDefinedServers: string[] = useSettingValue(SETTING_NAME);
- const [userDefinedServers, _setUserDefinedServers] = useState(_userDefinedServers);
+function removeAll(target: Set, ...toRemove: T[]) {
+ for (const value of toRemove) {
+ target.delete(value);
+ }
+}
- const handlerFactory = (server, instanceId) => {
- return () => {
- onOptionChange(server, instanceId);
- closeMenu();
- };
- };
+function useServers(): ServerList {
+ const [userDefinedServers, setUserDefinedServers] = useSettingsValueWithSetter(
+ SETTING_NAME,
+ SettingLevel.ACCOUNT,
+ );
- const setUserDefinedServers = servers => {
- _setUserDefinedServers(servers);
- SettingsStore.setValue(SETTING_NAME, null, SettingLevel.ACCOUNT, servers);
- };
- // keep local echo up to date with external changes
- useEffect(() => {
- _setUserDefinedServers(_userDefinedServers);
- }, [_userDefinedServers]);
+ const homeServer = MatrixClientPeg.getHomeserverName();
+ const configServers = new Set(
+ SdkConfig.getObject("room_directory")?.get("servers") ?? [],
+ );
+ removeAll(configServers, homeServer);
+ // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
+ const removableServers = new Set(userDefinedServers);
+ removeAll(removableServers, homeServer);
+ removeAll(removableServers, ...configServers);
- // we either show the button or the dropdown in its place.
- let content;
- if (menuDisplayed) {
- const roomDirectory = SdkConfig.getObject("room_directory")
- ?? new SnakedObject({ servers: [] });
-
- const hsName = MatrixClientPeg.getHomeserverName();
- const configServers = new Set(roomDirectory.get("servers"));
-
- // configured servers take preference over user-defined ones, if one occurs in both ignore the latter one.
- const removableServers = new Set(userDefinedServers.filter(s => !configServers.has(s) && s !== hsName));
- const servers = [
+ return {
+ allServers: [
// we always show our connected HS, this takes precedence over it being configured or user-defined
- hsName,
- ...Array.from(configServers).filter(s => s !== hsName).sort(),
+ homeServer,
+ ...Array.from(configServers).sort(),
...Array.from(removableServers).sort(),
- ];
+ ],
+ homeServer,
+ userDefinedServers: Array.from(removableServers).sort(),
+ setUserDefinedServers,
+ };
+}
- // For our own HS, we can use the instance_ids given in the third party protocols
- // response to get the server to filter the room list by network for us.
- // We can't get thirdparty protocols for remote server yet though, so for those
- // we can only show the default room list.
- const options = servers.map(server => {
- const serverSelected = server === selectedServerName;
- const entries = [];
+interface IProps {
+ protocols: Protocols | null;
+ config: IPublicRoomDirectoryConfig | null;
+ setConfig: (value: IPublicRoomDirectoryConfig | null) => void;
+}
- const protocolsList = server === hsName ? Object.values(protocols) : [];
- if (protocolsList.length > 0) {
- // add a fake protocol with ALL_ROOMS
- protocolsList.push({
- instances: [{
- fields: [],
- network_id: "",
- instance_id: ALL_ROOMS,
- desc: _t("All rooms"),
- }],
- location_fields: [],
- user_fields: [],
- field_types: {},
- icon: "",
- });
- }
+export const NetworkDropdown = ({ protocols, config, setConfig }: IProps) => {
+ const { allServers, homeServer, userDefinedServers, setUserDefinedServers } = useServers();
- protocolsList.forEach(({ instances=[] }) => {
- [...instances].sort((b, a) => {
- return compare(a.desc, b.desc);
- }).forEach(({ desc, instance_id: instanceId }) => {
- entries.push(
-
- { desc }
- );
- });
- });
+ const options: GenericDropdownMenuItem[] = allServers.map(roomServer => ({
+ key: { roomServer, instanceId: null },
+ label: roomServer,
+ description: roomServer === homeServer ? _t("Your server") : null,
+ options: [
+ {
+ key: { roomServer, instanceId: undefined },
+ label: _t("Matrix"),
+ },
+ ...(roomServer === homeServer && protocols ? Object.values(protocols)
+ .flatMap(protocol => protocol.instances)
+ .map(instance => ({
+ key: { roomServer, instanceId: instance.instance_id },
+ label: instance.desc,
+ })) : []),
+ ],
+ ...(userDefinedServers.includes(roomServer) ? ({
+ adornment: (
+ setUserDefinedServers(without(userDefinedServers, roomServer))}
+ />
+ ),
+ }) : {}),
+ }));
- let subtitle;
- if (server === hsName) {
- subtitle = (
-
- { _t("Your server") }
-
- );
- }
-
- let removeButton;
- if (removableServers.has(server)) {
- const onClick = async () => {
+ const addNewServer = useCallback(({ closeMenu }) => (
+ <>
+
+ {
closeMenu();
- const { finished } = Modal.createDialog(QuestionDialog, {
- title: _t("Are you sure?"),
- description: _t("Are you sure you want to remove %(serverName)s", {
- serverName: server,
- }, {
- b: serverName => { serverName },
- }),
- button: _t("Remove"),
+ const { finished } = Modal.createDialog(TextInputDialog, {
+ title: _t("Add a new server"),
+ description: _t("Enter the name of a new server you want to explore."),
+ button: _t("Add"),
+ hasCancel: false,
+ placeholder: _t("Server name"),
+ validator: validServer,
fixedWidth: false,
}, "mx_NetworkDropdown_dialog");
- const [ok] = await finished;
+ const [ok, newServer] = await finished;
if (!ok) return;
- // delete from setting
- setUserDefinedServers(servers.filter(s => s !== server));
-
- // the selected server is being removed, reset to our HS
- if (serverSelected) {
- onOptionChange(hsName, undefined);
+ if (!allServers.includes(newServer)) {
+ setUserDefinedServers([...userDefinedServers, newServer]);
+ setConfig({
+ roomServer: newServer,
+ });
}
- };
- removeButton = ;
- }
+ }}
+ >
+
+
+ { _t("Add new server…") }
+
+
+
+ >
+ ), [allServers, setConfig, setUserDefinedServers, userDefinedServers]);
- // ARIA: in actual fact the entire menu is one large radio group but for better screen reader support
- // we use group to notate server wrongly.
- return (
-
-
- { server }
- { removeButton }
-
- { subtitle }
-
-
- { _t("Matrix") }
-
- { entries }
-
- );
- });
-
- const onClick = async () => {
- closeMenu();
- const { finished } = Modal.createDialog(TextInputDialog, {
- title: _t("Add a new server"),
- description: _t("Enter the name of a new server you want to explore."),
- button: _t("Add"),
- hasCancel: false,
- placeholder: _t("Server name"),
- validator: validServer,
- fixedWidth: false,
- }, "mx_NetworkDropdown_dialog");
-
- const [ok, newServer] = await finished;
- if (!ok) return;
-
- if (!userDefinedServers.includes(newServer)) {
- setUserDefinedServers([...userDefinedServers, newServer]);
- }
-
- onOptionChange(newServer); // change filter to the new server
- };
-
- const buttonRect = handle.current.getBoundingClientRect();
- content =
-