{
/>
;
}
const explanation =
- _t("If you can't find the room you're looking for, ask for an invite or create a new room .", null,
+ _t("If you can't find the room you're looking for, ask for an invite or create a new room .", {},
{ a: sub => (
{ sub }
diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx
index b980d1739fe..8e7286a2052 100644
--- a/src/components/structures/RoomSearch.tsx
+++ b/src/components/structures/RoomSearch.tsx
@@ -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";
diff --git a/src/components/views/avatars/SearchResultAvatar.tsx b/src/components/views/avatars/SearchResultAvatar.tsx
new file mode 100644
index 00000000000..d9b56e6a04e
--- /dev/null
+++ b/src/components/views/avatars/SearchResultAvatar.tsx
@@ -0,0 +1,53 @@
+/*
+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 { RoomMember } from "matrix-js-sdk/src/matrix";
+
+import emailPillAvatar from "../../../../res/img/icon-email-pill-avatar.svg";
+import { mediaFromMxc } from "../../../customisations/Media";
+import { Member, ThreepidMember } from "../../../utils/direct-messages";
+import BaseAvatar from "./BaseAvatar";
+
+interface SearchResultAvatarProps {
+ user: Member | RoomMember;
+ size: number;
+}
+
+export function SearchResultAvatar({ user, size }: SearchResultAvatarProps): JSX.Element {
+ if ((user as ThreepidMember).isEmail) {
+ // we can’t show a real avatar here, but we try to create the exact same markup that a real avatar would have
+ // BaseAvatar makes the avatar, if it's not clickable but just for decoration, invisible to screenreaders by
+ // specifically setting an empty alt text, so we do the same.
+ return ;
+ } else {
+ const avatarUrl = user.getMxcAvatarUrl();
+ return ;
+ }
+}
diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx
index 118b9da9a02..1cc34db8753 100644
--- a/src/components/views/dialogs/InviteDialog.tsx
+++ b/src/components/views/dialogs/InviteDialog.tsx
@@ -28,6 +28,7 @@ import DMRoomMap from "../../../utils/DMRoomMap";
import SdkConfig from "../../../SdkConfig";
import * as Email from "../../../email";
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from "../../../utils/IdentityServerUtils";
+import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers";
import { abbreviateUrl } from "../../../utils/UrlUtils";
import IdentityAuthClient from "../../../IdentityAuthClient";
import { humanizeTime } from "../../../utils/humanize";
@@ -43,8 +44,9 @@ import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { mediaFromMxc } from "../../../customisations/Media";
import BaseAvatar from '../avatars/BaseAvatar';
+import { SearchResultAvatar } from "../avatars/SearchResultAvatar";
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
-import { compare, selectText } from '../../../utils/strings';
+import { selectText } from '../../../utils/strings';
import Field from '../elements/Field';
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
import Dialpad from '../voip/DialPad';
@@ -91,22 +93,7 @@ class DMUserTile extends React.PureComponent {
render() {
const avatarSize = 20;
- const avatar = (this.props.member as ThreepidMember).isEmail
- ?
- : ;
+ const avatar = ;
let closeButton;
if (this.props.onRemove) {
@@ -422,121 +409,15 @@ export default class InviteDialog extends React.PureComponent): {userId: string, user: RoomMember}[] {
- const maxConsideredMembers = 200;
- const joinedRooms = MatrixClientPeg.get().getRooms()
- .filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
-
- // Generates { userId: {member, rooms[]} }
- const memberRooms = joinedRooms.reduce((members, room) => {
- // Filter out DMs (we'll handle these in the recents section)
- if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
- return members; // Do nothing
- }
-
- const joinedMembers = room.getJoinedMembers().filter(u => !excludedTargetIds.has(u.userId));
- for (const member of joinedMembers) {
- // Filter out user IDs that are already in the room / should be excluded
- if (excludedTargetIds.has(member.userId)) {
- continue;
- }
-
- if (!members[member.userId]) {
- members[member.userId] = {
- member: member,
- // Track the room size of the 'picked' member so we can use the profile of
- // the smallest room (likely a DM).
- pickedMemberRoomSize: room.getJoinedMemberCount(),
- rooms: [],
- };
- }
-
- members[member.userId].rooms.push(room);
-
- if (room.getJoinedMemberCount() < members[member.userId].pickedMemberRoomSize) {
- members[member.userId].member = member;
- members[member.userId].pickedMemberRoomSize = room.getJoinedMemberCount();
- }
- }
- return members;
- }, {});
-
- // Generates { userId: {member, numRooms, score} }
- const memberScores = Object.values(memberRooms).reduce((scores, entry: {member: RoomMember, rooms: Room[]}) => {
- const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0);
- const maxRange = maxConsideredMembers * entry.rooms.length;
- scores[entry.member.userId] = {
- member: entry.member,
- numRooms: entry.rooms.length,
- score: Math.max(0, Math.pow(1 - (numMembersTotal / maxRange), 5)),
- };
- return scores;
- }, {});
-
- // Now that we have scores for being in rooms, boost those people who have sent messages
- // recently, as a way to improve the quality of suggestions. We do this by checking every
- // room to see who has sent a message in the last few hours, and giving them a score
- // which correlates to the freshness of their message. In theory, this results in suggestions
- // which are closer to "continue this conversation" rather than "this person exists".
- const trueJoinedRooms = MatrixClientPeg.get().getRooms().filter(r => r.getMyMembership() === 'join');
- const now = (new Date()).getTime();
- const earliestAgeConsidered = now - (60 * 60 * 1000); // 1 hour ago
- const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
- const lastSpoke = {}; // userId: timestamp
- const lastSpokeMembers = {}; // userId: room member
- for (const room of trueJoinedRooms) {
- // Skip low priority rooms and DMs
- const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
- if (Object.keys(room.tags).includes("m.lowpriority") || isDm) {
- continue;
- }
-
- const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
- for (let i = events.length - 1; i >= Math.max(0, events.length - maxMessagesConsidered); i--) {
- const ev = events[i];
- if (excludedTargetIds.has(ev.getSender())) {
- continue;
- }
- if (ev.getTs() <= earliestAgeConsidered) {
- break; // give up: all events from here on out are too old
- }
-
- if (!lastSpoke[ev.getSender()] || lastSpoke[ev.getSender()] < ev.getTs()) {
- lastSpoke[ev.getSender()] = ev.getTs();
- lastSpokeMembers[ev.getSender()] = room.getMember(ev.getSender());
- }
- }
- }
- for (const userId in lastSpoke) {
- const ts = lastSpoke[userId];
- const member = lastSpokeMembers[userId];
- if (!member) continue; // skip people we somehow don't have profiles for
-
- // Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
- // boost we'll try and award at least +1.0 for making the list, with +4.0 being
- // an approximate maximum for being selected.
- const distanceFromNow = Math.abs(now - ts); // abs to account for slight future messages
- const inverseTime = (now - earliestAgeConsidered) - distanceFromNow;
- const scoreBoost = Math.max(1, inverseTime / (15 * 60 * 1000)); // 15min segments to keep scores sane
-
- let record = memberScores[userId];
- if (!record) record = memberScores[userId] = { score: 0 };
- record.member = member;
- record.score += scoreBoost;
- }
-
- const members = Object.values(memberScores);
- members.sort((a, b) => {
- if (a.score === b.score) {
- if (a.numRooms === b.numRooms) {
- return compare(a.member.userId, b.member.userId);
- }
-
- return b.numRooms - a.numRooms;
- }
- return b.score - a.score;
- });
-
- return members.map(m => ({ userId: m.member.userId, user: m.member }));
+ const cli = MatrixClientPeg.get();
+ const activityScores = buildActivityScores(cli);
+ const memberScores = buildMemberScores(cli);
+ const memberComparator = compareMembers(activityScores, memberScores);
+
+ return Object.values(memberScores).map(({ member }) => member)
+ .filter(member => !excludedTargetIds.has(member.userId))
+ .sort(memberComparator)
+ .map(member => ({ userId: member.userId, user: member }));
}
private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
diff --git a/src/components/views/dialogs/SpotlightDialog.tsx b/src/components/views/dialogs/SpotlightDialog.tsx
deleted file mode 100644
index c94a5097caf..00000000000
--- a/src/components/views/dialogs/SpotlightDialog.tsx
+++ /dev/null
@@ -1,786 +0,0 @@
-/*
-Copyright 2021 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import React, {
- ChangeEvent,
- ComponentProps,
- KeyboardEvent,
- RefObject,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useState,
-} from "react";
-import { Room } from "matrix-js-sdk/src/models/room";
-import { normalize } from "matrix-js-sdk/src/utils";
-import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
-import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
-import { RoomType } from "matrix-js-sdk/src/@types/event";
-import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch";
-
-import { IDialogProps } from "./IDialogProps";
-import { _t } from "../../../languageHandler";
-import BaseDialog from "./BaseDialog";
-import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
-import defaultDispatcher from "../../../dispatcher/dispatcher";
-import {
- findSiblingElement,
- RovingAccessibleButton,
- RovingAccessibleTooltipButton,
- RovingTabIndexContext,
- RovingTabIndexProvider,
- Type,
- useRovingTabIndex,
-} from "../../../accessibility/RovingTabIndex";
-import AccessibleButton from "../elements/AccessibleButton";
-import { MatrixClientPeg } from "../../../MatrixClientPeg";
-import SpaceStore from "../../../stores/spaces/SpaceStore";
-import DMRoomMap from "../../../utils/DMRoomMap";
-import { mediaFromMxc } from "../../../customisations/Media";
-import BaseAvatar from "../avatars/BaseAvatar";
-import Spinner from "../elements/Spinner";
-import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
-import { Action } from "../../../dispatcher/actions";
-import Modal from "../../../Modal";
-import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
-import { RoomViewStore } from "../../../stores/RoomViewStore";
-import { showStartChatInviteDialog } from "../../../RoomInvite";
-import SettingsStore from "../../../settings/SettingsStore";
-import { SettingLevel } from "../../../settings/SettingLevel";
-import NotificationBadge from "../rooms/NotificationBadge";
-import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
-import { BetaPill } from "../beta/BetaCard";
-import { UserTab } from "./UserTab";
-import BetaFeedbackDialog from "./BetaFeedbackDialog";
-import SdkConfig from "../../../SdkConfig";
-import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
-import { getMetaSpaceName } from "../../../stores/spaces";
-import { getKeyBindingsManager } from "../../../KeyBindingsManager";
-import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
-import { PosthogAnalytics } from "../../../PosthogAnalytics";
-import { getCachedRoomIDForAlias } from "../../../RoomAliasCache";
-import { roomContextDetailsText, spaceContextDetailsText } from "../../../utils/i18n-helpers";
-import { RecentAlgorithm } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
-
-const MAX_RECENT_SEARCHES = 10;
-const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
-const AVATAR_SIZE = 24;
-
-const Option: React.FC> = ({ inputRef, children, ...props }) => {
- const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
- return
- { children }
- ↵
- ;
-};
-
-const TooltipOption: React.FC> = ({ inputRef, ...props }) => {
- const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
- return ;
-};
-
-const useRecentSearches = (): [Room[], () => void] => {
- const [rooms, setRooms] = useState(() => {
- const cli = MatrixClientPeg.get();
- const recents = SettingsStore.getValue("SpotlightSearch.recentSearches", null);
- return recents.map(r => cli.getRoom(r)).filter(Boolean);
- });
-
- return [rooms, () => {
- SettingsStore.setValue("SpotlightSearch.recentSearches", null, SettingLevel.ACCOUNT, []);
- setRooms([]);
- }];
-};
-
-const ResultDetails = ({ room }: { room: Room }) => {
- const contextDetails = room.isSpaceRoom() ? spaceContextDetailsText(room) : roomContextDetailsText(room);
- if (contextDetails) {
- return
- { contextDetails }
-
;
- }
-
- return null;
-};
-
-interface IProps extends IDialogProps {
- initialText?: string;
-}
-
-const useSpaceResults = (space?: Room, query?: string): [IHierarchyRoom[], boolean] => {
- const [rooms, setRooms] = useState([]);
- const [hierarchy, setHierarchy] = useState();
-
- const resetHierarchy = useCallback(() => {
- setHierarchy(space ? new RoomHierarchy(space, 50) : null);
- }, [space]);
- useEffect(resetHierarchy, [resetHierarchy]);
-
- useEffect(() => {
- if (!space || !hierarchy) return; // nothing to load
-
- let unmounted = false;
-
- (async () => {
- while (hierarchy?.canLoadMore && !unmounted && space === hierarchy.root) {
- await hierarchy.load();
- if (hierarchy.canLoadMore) hierarchy.load(); // start next load so that the loading attribute is right
- setRooms(hierarchy.rooms);
- }
- })();
-
- return () => {
- unmounted = true;
- };
- }, [space, hierarchy]);
-
- const results = useMemo(() => {
- const trimmedQuery = query.trim();
- const lcQuery = trimmedQuery.toLowerCase();
- const normalizedQuery = normalize(trimmedQuery);
-
- const cli = MatrixClientPeg.get();
- return rooms?.filter(r => {
- return r.room_type !== RoomType.Space &&
- cli.getRoom(r.room_id)?.getMyMembership() !== "join" &&
- (
- normalize(r.name || "").includes(normalizedQuery) ||
- (r.canonical_alias || "").includes(lcQuery)
- );
- });
- }, [rooms, query]);
-
- return [results, hierarchy?.loading ?? false];
-};
-
-function refIsForRecentlyViewed(ref: RefObject): boolean {
- return ref.current?.id.startsWith("mx_SpotlightDialog_button_recentlyViewed_");
-}
-
-enum Section {
- People,
- Rooms,
- Spaces,
-}
-
-interface IBaseResult {
- section: Section;
- query?: string[]; // extra fields to query match, stored as lowercase
-}
-
-interface IRoomResult extends IBaseResult {
- room: Room;
-}
-
-interface IResult extends IBaseResult {
- avatar: JSX.Element;
- name: string;
- description?: string;
- onClick?(): void;
-}
-
-type Result = IRoomResult | IResult;
-
-const isRoomResult = (result: any): result is IRoomResult => !!result?.room;
-
-const recentAlgorithm = new RecentAlgorithm();
-
-export const useWebSearchMetrics = (numResults: number, queryLength: number, viaSpotlight: boolean): void => {
- useEffect(() => {
- if (!queryLength) return;
-
- // send metrics after a 1s debounce
- const timeoutId = setTimeout(() => {
- PosthogAnalytics.instance.trackEvent({
- eventName: "WebSearch",
- viaSpotlight,
- numResults,
- queryLength,
- });
- }, 1000);
-
- return () => {
- clearTimeout(timeoutId);
- };
- }, [numResults, queryLength, viaSpotlight]);
-};
-
-const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => {
- const cli = MatrixClientPeg.get();
- const rovingContext = useContext(RovingTabIndexContext);
- const [query, _setQuery] = useState(initialText);
- const [recentSearches, clearRecentSearches] = useRecentSearches();
-
- const possibleResults = useMemo(() => [
- ...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({
- section: Section.Spaces,
- avatar: (
-
- ),
- name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome),
- onClick() {
- SpaceStore.instance.setActiveSpace(spaceKey);
- },
- })),
- ...cli.getVisibleRooms().filter(room => {
- // TODO we may want to put invites in their own list
- return room.getMyMembership() === "join" || room.getMyMembership() == "invite";
- }).map(room => {
- let section: Section;
- let query: string[];
-
- const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
- if (otherUserId) {
- section = Section.People;
- query = [
- otherUserId.toLowerCase(),
- room.getMember(otherUserId)?.name.toLowerCase(),
- ].filter(Boolean);
- } else if (room.isSpaceRoom()) {
- section = Section.Spaces;
- } else {
- section = Section.Rooms;
- }
-
- return { room, section, query };
- }),
- ], [cli]);
-
- const trimmedQuery = query.trim();
- const [people, rooms, spaces] = useMemo<[Result[], Result[], Result[]] | []>(() => {
- if (!trimmedQuery) return [];
-
- const lcQuery = trimmedQuery.toLowerCase();
- const normalizedQuery = normalize(trimmedQuery);
-
- const results: [Result[], Result[], Result[]] = [[], [], []];
-
- // Group results in their respective sections
- possibleResults.forEach(entry => {
- if (isRoomResult(entry)) {
- if (!entry.room.normalizedName.includes(normalizedQuery) &&
- !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) &&
- !entry.query?.some(q => q.includes(lcQuery))
- ) return; // bail, does not match query
- } else {
- if (!entry.name.toLowerCase().includes(lcQuery) &&
- !entry.query?.some(q => q.includes(lcQuery))
- ) return; // bail, does not match query
- }
-
- results[entry.section].push(entry);
- });
-
- // Sort results by most recent activity
-
- const myUserId = cli.getUserId();
- for (const resultArray of results) {
- resultArray.sort((a: Result, b: Result) => {
- // This is not a room result, it should appear at the bottom of
- // the list
- if (!(a as IRoomResult).room) return 1;
- if (!(b as IRoomResult).room) return -1;
-
- const roomA = (a as IRoomResult).room;
- const roomB = (b as IRoomResult).room;
-
- return recentAlgorithm.getLastTs(roomB, myUserId) - recentAlgorithm.getLastTs(roomA, myUserId);
- });
- }
-
- return results;
- }, [possibleResults, trimmedQuery, cli]);
-
- const numResults = trimmedQuery ? people.length + rooms.length + spaces.length : 0;
- useWebSearchMetrics(numResults, query.length, true);
-
- const activeSpace = SpaceStore.instance.activeSpaceRoom;
- const [spaceResults, spaceResultsLoading] = useSpaceResults(activeSpace, query);
-
- const setQuery = (e: ChangeEvent): void => {
- const newQuery = e.currentTarget.value;
- _setQuery(newQuery);
-
- setImmediate(() => {
- // reset the activeRef when we change query for best usability
- const ref = rovingContext.state.refs[0];
- if (ref) {
- rovingContext.dispatch({
- type: Type.SetFocus,
- payload: { ref },
- });
- ref.current?.scrollIntoView({
- block: "nearest",
- });
- }
- });
- };
-
- const viewRoom = (roomId: string, persist = false, viaKeyboard = false) => {
- if (persist) {
- const recents = new Set(SettingsStore.getValue("SpotlightSearch.recentSearches", null).reverse());
- // remove & add the room to put it at the end
- recents.delete(roomId);
- recents.add(roomId);
-
- SettingsStore.setValue(
- "SpotlightSearch.recentSearches",
- null,
- SettingLevel.ACCOUNT,
- Array.from(recents).reverse().slice(0, MAX_RECENT_SEARCHES),
- );
- }
-
- defaultDispatcher.dispatch({
- action: Action.ViewRoom,
- room_id: roomId,
- metricsTrigger: "WebUnifiedSearch",
- metricsViaKeyboard: viaKeyboard,
- });
- onFinished();
- };
-
- let content: JSX.Element;
- if (trimmedQuery) {
- const resultMapper = (result: Result): JSX.Element => {
- if (isRoomResult(result)) {
- return (
- {
- viewRoom(result.room.roomId, true, ev.type !== "click");
- }}
- >
-
- { result.room.name }
-
-
-
- );
- }
-
- // IResult case
- return (
-
- { result.avatar }
- { result.name }
- { result.description }
-
- );
- };
-
- 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 => (
-
{
- viewRoom(room.room_id, true, ev.type !== "click");
- }}
- >
-
- { room.name || room.canonical_alias }
- { room.name && room.canonical_alias &&
- { room.canonical_alias }
-
}
-
- )) }
- { spaceResultsLoading &&
}
-
-
;
- }
-
- let joinRoomSection: JSX.Element;
- if (trimmedQuery.startsWith("#") &&
- trimmedQuery.includes(":") &&
- (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery)))
- ) {
- joinRoomSection =
-
- {
- defaultDispatcher.dispatch({
- action: Action.ViewRoom,
- room_alias: trimmedQuery,
- auto_join: true,
- metricsTrigger: "WebUnifiedSearch",
- metricsViaKeyboard: ev.type !== "click",
- });
- onFinished();
- }}
- >
- { _t("Join %(roomAddress)s", {
- roomAddress: trimmedQuery,
- }) }
-
-
-
;
- }
-
- content = <>
- { peopleSection }
- { roomsSection }
- { spacesSection }
- { spaceRoomsSection }
- { joinRoomSection }
-
-
{ _t('Use "%(query)s" to search', { query }) }
-
- {
- defaultDispatcher.dispatch({
- action: Action.ViewRoomDirectory,
- initialText: query,
- });
- onFinished();
- }}
- >
- { _t("Public rooms") }
-
- {
- showStartChatInviteDialog(query);
- onFinished();
- }}
- >
- { _t("People") }
-
-
-
-
-
{ _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 => (
- {
- viewRoom(room.roomId, true, ev.type !== "click");
- }}
- >
-
- { room.name }
-
-
-
- )) }
-
-
- );
- }
-
- content = <>
-
-
{ _t("Recently viewed") }
-
- { BreadcrumbsStore.instance.rooms
- .filter(r => r.roomId !== RoomViewStore.instance.getRoomId())
- .map(room => (
- {
- viewRoom(room.roomId, false, ev.type !== "click");
- }}
- >
-
- { room.name }
-
- ))
- }
-
-
-
- { recentSearchesSection }
-
-
-
{ _t("Other searches") }
-
- {
- defaultDispatcher.fire(Action.ViewRoomDirectory);
- onFinished();
- }}
- >
- { _t("Explore public rooms") }
-
-
-
- >;
- }
-
- const onDialogKeyDown = (ev: KeyboardEvent) => {
- const navigationAction = getKeyBindingsManager().getNavigationAction(ev);
- switch (navigationAction) {
- case KeyBindingAction.FilterRooms:
- ev.stopPropagation();
- ev.preventDefault();
- onFinished();
- break;
- }
-
- const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
- switch (accessibilityAction) {
- case KeyBindingAction.Escape:
- ev.stopPropagation();
- ev.preventDefault();
- onFinished();
- break;
- }
- };
-
- const onKeyDown = (ev: KeyboardEvent) => {
- let ref: RefObject;
-
- const action = getKeyBindingsManager().getAccessibilityAction(ev);
-
- switch (action) {
- case KeyBindingAction.ArrowUp:
- case KeyBindingAction.ArrowDown:
- ev.stopPropagation();
- ev.preventDefault();
-
- if (rovingContext.state.refs.length > 0) {
- let refs = rovingContext.state.refs;
- if (!query) {
- // If the current selection is not in the recently viewed row then only include the
- // first recently viewed so that is the target when the user is switching into recently viewed.
- const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef)
- ? rovingContext.state.activeRef
- : refs.find(refIsForRecentlyViewed);
- // exclude all other recently viewed items from the list so up/down arrows skip them
- refs = refs.filter(ref => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref));
- }
-
- const idx = refs.indexOf(rovingContext.state.activeRef);
- ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowUp ? -1 : 1));
- }
- break;
-
- case KeyBindingAction.ArrowLeft:
- case KeyBindingAction.ArrowRight:
- // only handle these keys when we are in the recently viewed row of options
- if (!query &&
- rovingContext.state.refs.length > 0 &&
- refIsForRecentlyViewed(rovingContext.state.activeRef)
- ) {
- // we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
- ev.stopPropagation();
- ev.preventDefault();
-
- const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed);
- const idx = refs.indexOf(rovingContext.state.activeRef);
- ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowLeft ? -1 : 1));
- }
- break;
- case KeyBindingAction.Enter:
- ev.stopPropagation();
- ev.preventDefault();
- rovingContext.state.activeRef?.current?.click();
- break;
- }
-
- if (ref) {
- rovingContext.dispatch({
- type: Type.SetFocus,
- payload: { ref },
- });
- ref.current?.scrollIntoView({
- block: "nearest",
- });
- }
- };
-
- const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => {
- Modal.createDialog(BetaFeedbackDialog, {
- featureId: "feature_spotlight",
- });
- } : null;
-
- const activeDescendant = rovingContext.state.activeRef?.current?.id;
-
- return <>
-
- { _t("Use
to scroll", {}, {
- arrows: () => <>
-
↓
-
↑
- { !query &&
←
}
- { !query &&
→
}
- >,
- }) }
-
-
-
-
-
-
-
-
- { content }
-
-
-
-
{
- defaultDispatcher.dispatch({
- action: Action.ViewUserSettings,
- initialTabId: UserTab.Labs,
- });
- onFinished();
- }} />
- { openFeedback && _t("Results not as expected? Please give feedback .", {}, {
- a: sub =>
- { sub }
- ,
- }) }
- { openFeedback &&
- { _t("Feedback") }
- }
-
-
- >;
-};
-
-const RovingSpotlightDialog: React.FC = (props) => {
- return
- { () => }
- ;
-};
-
-export default RovingSpotlightDialog;
diff --git a/src/components/views/dialogs/spotlight/Option.tsx b/src/components/views/dialogs/spotlight/Option.tsx
new file mode 100644
index 00000000000..3e11e7c38fd
--- /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 00000000000..2ffcad349b0
--- /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 (
+
+
+ { name }
+
+ { room.canonical_alias ?? room.room_id }
+
+
+
+
+ { _t("%(count)s Members", {
+ count: room.num_joined_members,
+ }) }
+
+ { topic && (
+ <>
+ ·
+
+ >
+ ) }
+
+
+ );
+}
diff --git a/src/components/views/dialogs/spotlight/RoomResultDetails.tsx b/src/components/views/dialogs/spotlight/RoomResultDetails.tsx
new file mode 100644
index 00000000000..39465b9c735
--- /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 00000000000..862cd4948a4
--- /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 = (
+
+
+ { trimmedQuery
+ ? _t('Use "%(query)s" to search', { query })
+ : _t("Search for") }
+
+
+ { (filter !== Filter.PublicRooms) && (
+ setFilter(Filter.PublicRooms)}
+ >
+ { filterToLabel(Filter.PublicRooms) }
+
+ ) }
+ { (filter !== Filter.People) && (
+ setFilter(Filter.People)}
+ >
+ { filterToLabel(Filter.People) }
+
+ ) }
+
+
+ );
+ }
+
+ let content: JSX.Element;
+ if (trimmedQuery || filter !== null) {
+ const resultMapper = (result: Result): JSX.Element => {
+ if (isRoomResult(result)) {
+ return (
+ {
+ viewRoom(result.room.roomId, true, ev?.type !== "click");
+ }}
+ >
+
+ { result.room.name }
+
+
+
+ );
+ }
+ if (isMemberResult(result)) {
+ return (
+ {
+ startDm(cli, [result.member]);
+ }}
+ >
+
+ { result.member instanceof RoomMember ? result.member.rawDisplayName : result.member.name }
+
+ { result.member.userId }
+
+
+ );
+ }
+ if (isPublicRoomResult(result)) {
+ const clientRoom = cli.getRoom(result.publicRoom.room_id);
+ const listener = (ev) => {
+ viewRoom(result.publicRoom.room_id, true, ev.type !== "click");
+ };
+ return (
+
+ { _t(clientRoom ? "View" : "Join") }
+ }
+ >
+
+
+
+ );
+ }
+
+ // IResult case
+ return (
+
+ { result.avatar }
+ { result.name }
+ { result.description }
+
+ );
+ };
+
+ let peopleSection: JSX.Element;
+ if (results[Section.People].length) {
+ peopleSection = (
+
+
{ _t("Recent Conversations") }
+
+ { results[Section.People].slice(0, SECTION_LIMIT).map(resultMapper) }
+
+
+ );
+ }
+
+ let suggestionsSection: JSX.Element;
+ if (results[Section.Suggestions].length && filter === Filter.People) {
+ suggestionsSection = (
+
+
{ _t("Suggestions") }
+
+ { results[Section.Suggestions].slice(0, SECTION_LIMIT).map(resultMapper) }
+
+
+ );
+ }
+
+ let roomsSection: JSX.Element;
+ if (results[Section.Rooms].length) {
+ roomsSection = (
+
+
{ _t("Rooms") }
+
+ { results[Section.Rooms].slice(0, SECTION_LIMIT).map(resultMapper) }
+
+
+ );
+ }
+
+ let spacesSection: JSX.Element;
+ if (results[Section.Spaces].length) {
+ spacesSection = (
+
+
{ _t("Spaces you're in") }
+
+ { results[Section.Spaces].slice(0, SECTION_LIMIT).map(resultMapper) }
+
+
+ );
+ }
+
+ let publicRoomsSection: JSX.Element;
+ if (filter === Filter.PublicRooms) {
+ publicRoomsSection = (
+
+
+
{ _t("Suggestions") }
+
+
+
+ { results[Section.PublicRooms].slice(0, SECTION_LIMIT).map(resultMapper) }
+
+
+ );
+ }
+
+ let spaceRoomsSection: JSX.Element;
+ if (spaceResults.length && activeSpace && filter === null) {
+ spaceRoomsSection = (
+
+
{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }
+
+ { spaceResults.slice(0, SECTION_LIMIT).map((room: IHierarchyRoom): JSX.Element => (
+
{
+ viewRoom(room.room_id, true, ev?.type !== "click");
+ }}
+ >
+
+ { room.name || room.canonical_alias }
+ { room.name && room.canonical_alias && (
+
+ { room.canonical_alias }
+
+ ) }
+
+ )) }
+ { spaceResultsLoading &&
}
+
+
+ );
+ }
+
+ let joinRoomSection: JSX.Element;
+ if (trimmedQuery.startsWith("#") &&
+ trimmedQuery.includes(":") &&
+ (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery)))
+ ) {
+ joinRoomSection = (
+
+
+ {
+ defaultDispatcher.dispatch({
+ action: Action.ViewRoom,
+ room_alias: trimmedQuery,
+ auto_join: true,
+ metricsTrigger: "WebUnifiedSearch",
+ metricsViaKeyboard: ev?.type !== "click",
+ });
+ onFinished();
+ }}
+ >
+ { _t("Join %(roomAddress)s", {
+ roomAddress: trimmedQuery,
+ }) }
+
+
+
+ );
+ }
+
+ let hiddenResultsSection: JSX.Element;
+ if (filter === Filter.People) {
+ hiddenResultsSection = (
+
+
{ _t('Some results may be hidden for privacy') }
+
+ { _t("If you can't see who you're looking for, send them your invite link.") }
+
+
{ setInviteLinkCopied(true); copyPlaintext(ownInviteLink); }}
+ onHideTooltip={() => setInviteLinkCopied(false)}
+ title={inviteLinkCopied ? _t("Copied!") : _t("Copy")}
+ >
+
+ { _t("Copy invite link") }
+
+
+
+ );
+ } else if (trimmedQuery && filter === Filter.PublicRooms) {
+ hiddenResultsSection = (
+
+
{ _t('Some results may be hidden') }
+
+ { _t("If you can't find the room you're looking for, " +
+ "ask for an invite or create a new room.") }
+
+
defaultDispatcher.dispatch({
+ action: 'view_create_room',
+ public: true,
+ defaultName: trimmedQuery,
+ })}
+ >
+
+ { _t("Create new Room") }
+
+
+
+ );
+ }
+
+ let groupChatSection: JSX.Element;
+ if (filter === Filter.People) {
+ groupChatSection = (
+
+
{ _t('Other options') }
+ showStartChatInviteDialog(trimmedQuery)}
+ >
+ { _t("Start a group chat") }
+
+
+ );
+ }
+
+ let messageSearchSection: JSX.Element;
+ if (filter === null) {
+ messageSearchSection = (
+
+
{ _t("Other searches") }
+
+ { _t(
+ "To search messages, look for this icon at the top of a room
",
+ {},
+ { icon: () =>
},
+ ) }
+
+
+ );
+ }
+
+ content = <>
+ { peopleSection }
+ { suggestionsSection }
+ { roomsSection }
+ { spacesSection }
+ { spaceRoomsSection }
+ { publicRoomsSection }
+ { joinRoomSection }
+ { hiddenResultsSection }
+ { otherSearchesSection }
+ { groupChatSection }
+ { messageSearchSection }
+ >;
+ } else {
+ let recentSearchesSection: JSX.Element;
+ if (recentSearches.length) {
+ recentSearchesSection = (
+
+
+ { _t("Recent searches") }
+
+ { _t("Clear") }
+
+
+
+ { recentSearches.map(room => (
+ {
+ viewRoom(room.roomId, true, ev?.type !== "click");
+ }}
+ >
+
+ { room.name }
+
+
+
+ )) }
+
+
+ );
+ }
+
+ content = <>
+
+
{ _t("Recently viewed") }
+
+ { BreadcrumbsStore.instance.rooms
+ .filter(r => r.roomId !== RoomViewStore.instance.getRoomId())
+ .map(room => (
+ {
+ viewRoom(room.roomId, false, ev.type !== "click");
+ }}
+ >
+
+ { room.name }
+
+ ))
+ }
+
+
+
+ { recentSearchesSection }
+ { otherSearchesSection }
+ >;
+ }
+
+ const onDialogKeyDown = (ev: KeyboardEvent) => {
+ const navigationAction = getKeyBindingsManager().getNavigationAction(ev);
+ switch (navigationAction) {
+ case KeyBindingAction.FilterRooms:
+ ev.stopPropagation();
+ ev.preventDefault();
+ onFinished();
+ break;
+ }
+
+ const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
+ switch (accessibilityAction) {
+ case KeyBindingAction.Escape:
+ ev.stopPropagation();
+ ev.preventDefault();
+ onFinished();
+ break;
+ }
+ };
+
+ const onKeyDown = (ev: KeyboardEvent) => {
+ let ref: RefObject;
+
+ const action = getKeyBindingsManager().getAccessibilityAction(ev);
+
+ switch (action) {
+ case KeyBindingAction.Backspace:
+ if (!query && filter !== null) {
+ ev.stopPropagation();
+ ev.preventDefault();
+ setFilter(null);
+ }
+ break;
+ case KeyBindingAction.ArrowUp:
+ case KeyBindingAction.ArrowDown:
+ ev.stopPropagation();
+ ev.preventDefault();
+
+ if (rovingContext.state.refs.length > 0) {
+ let refs = rovingContext.state.refs;
+ if (!query && !filter !== null) {
+ // If the current selection is not in the recently viewed row then only include the
+ // first recently viewed so that is the target when the user is switching into recently viewed.
+ const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef)
+ ? rovingContext.state.activeRef
+ : refs.find(refIsForRecentlyViewed);
+ // exclude all other recently viewed items from the list so up/down arrows skip them
+ refs = refs.filter(ref => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref));
+ }
+
+ const idx = refs.indexOf(rovingContext.state.activeRef);
+ ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowUp ? -1 : 1));
+ }
+ break;
+
+ case KeyBindingAction.ArrowLeft:
+ case KeyBindingAction.ArrowRight:
+ // only handle these keys when we are in the recently viewed row of options
+ if (!query && !filter !== null &&
+ rovingContext.state.refs.length > 0 &&
+ refIsForRecentlyViewed(rovingContext.state.activeRef)
+ ) {
+ // we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
+ ev.stopPropagation();
+ ev.preventDefault();
+
+ const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed);
+ const idx = refs.indexOf(rovingContext.state.activeRef);
+ ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowLeft ? -1 : 1));
+ }
+ break;
+ case KeyBindingAction.Enter:
+ ev.stopPropagation();
+ ev.preventDefault();
+ rovingContext.state.activeRef?.current?.click();
+ break;
+ }
+
+ if (ref) {
+ rovingContext.dispatch({
+ type: Type.SetFocus,
+ payload: { ref },
+ });
+ ref.current?.scrollIntoView({
+ block: "nearest",
+ });
+ }
+ };
+
+ const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => {
+ Modal.createDialog(BetaFeedbackDialog, {
+ featureId: "feature_spotlight",
+ });
+ } : null;
+
+ const activeDescendant = rovingContext.state.activeRef?.current?.id;
+
+ return <>
+
+ { _t("Use
to scroll", {}, {
+ arrows: () => <>
+
↓
+
↑
+ { !filter !== null && !query &&
←
}
+ { !filter !== null && !query &&
→
}
+ >,
+ }) }
+
+
+
+
+ { filter !== null && (
+
+
{ filterToLabel(filter) }
+
setFilter(null)}
+ />
+
+ ) }
+
+ { (publicRoomsLoading || peopleLoading || profileLoading) && (
+
+ ) }
+
+
+
+ { content }
+
+
+
+
{
+ defaultDispatcher.dispatch({
+ action: Action.ViewUserSettings,
+ initialTabId: UserTab.Labs,
+ });
+ onFinished();
+ }} />
+ { openFeedback && _t("Results not as expected? Please give feedback .", {}, {
+ a: sub =>
+ { sub }
+ ,
+ }) }
+ { openFeedback &&
+ { _t("Feedback") }
+ }
+
+
+ >;
+};
+
+const RovingSpotlightDialog: React.FC = (props) => {
+ return
+ { () => }
+ ;
+};
+
+export default RovingSpotlightDialog;
diff --git a/src/components/views/dialogs/spotlight/TooltipOption.tsx b/src/components/views/dialogs/spotlight/TooltipOption.tsx
new file mode 100644
index 00000000000..f24ddc8f09b
--- /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 d3de216411a..701e5e39b98 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;
-}
-
-// 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.
-
-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 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],
+ );
- const handlerFactory = (server, instanceId) => {
+ useEffect(() => {
+ const ref = SettingsStore.watchSetting(settingName, roomId, () => {
+ setValue(SettingsStore.getValue(settingName, roomId, excludeDefault));
+ });
+ // clean-up
return () => {
- onOptionChange(server, instanceId);
- closeMenu();
+ SettingsStore.unwatchSetting(ref);
};
- };
+ }, [settingName, roomId, excludeDefault]);
- 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]);
+ return [value, setter];
+}
- // we either show the button or the dropdown in its place.
- let content;
- if (menuDisplayed) {
- const roomDirectory = SdkConfig.getObject("room_directory")
- ?? new SnakedObject({ servers: [] });
+interface ServerList {
+ allServers: string[];
+ homeServer: string;
+ userDefinedServers: string[];
+ setUserDefinedServers: (servers: string[]) => void;
+}
- const hsName = MatrixClientPeg.getHomeserverName();
- const configServers = new Set(roomDirectory.get("servers"));
+function removeAll(target: Set, ...toRemove: T[]) {
+ for (const value of toRemove) {
+ target.delete(value);
+ }
+}
- // 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 = [
+function useServers(): ServerList {
+ const [userDefinedServers, setUserDefinedServers] = useSettingsValueWithSetter(
+ SETTING_NAME,
+ SettingLevel.ACCOUNT,
+ );
+
+ 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);
+
+ 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(),
- ];
-
- // 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 = [];
-
- 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: "",
- });
- }
-
- protocolsList.forEach(({ instances=[] }) => {
- [...instances].sort((b, a) => {
- return compare(a.desc, b.desc);
- }).forEach(({ desc, instance_id: instanceId }) => {
- entries.push(
-
- { desc }
- );
- });
- });
+ ],
+ homeServer,
+ userDefinedServers: Array.from(removableServers).sort(),
+ setUserDefinedServers,
+ };
+}
- let subtitle;
- if (server === hsName) {
- subtitle = (
-
- { _t("Your server") }
-
- );
- }
+interface IProps {
+ protocols: Protocols | null;
+ config: IPublicRoomDirectoryConfig | null;
+ setConfig: (value: IPublicRoomDirectoryConfig | null) => void;
+}
- let removeButton;
- if (removableServers.has(server)) {
- const onClick = async () => {
+export const NetworkDropdown = ({ protocols, config, setConfig }: IProps) => {
+ const { allServers, homeServer, userDefinedServers, setUserDefinedServers } = useServers();
+
+ 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))}
+ />
+ ),
+ }) : {}),
+ }));
+
+ 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 = ;
- }
-
- // 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 =
-
- { options }
-
- { _t("Add a new server...") }
-
-
- ;
- } else {
- let currentValue;
- if (selectedInstanceId === ALL_ROOMS) {
- currentValue = _t("All rooms");
- } else if (selectedInstanceId) {
- const instance = instanceForInstanceId(protocols, selectedInstanceId);
- currentValue = _t("%(networkName)s rooms", {
- networkName: instance.desc,
- });
- } else {
- currentValue = _t("Matrix rooms");
- }
-
- content =
-
- { currentValue }
-
- ({ selectedServerName })
-
- ;
- }
-
- return
- { content }
-
;
+ }}
+ >
+
+
+ { _t("Add new server…") }
+
+
+
+ >
+ ), [allServers, setConfig, setUserDefinedServers, userDefinedServers]);
+
+ return (
+
+ config ? `${config.roomServer}-${config.instanceId}` : "null"}
+ options={options}
+ onChange={(option) => setConfig(option)}
+ selectedLabel={option => option?.key ? _t("Show: %(instance)s rooms (%(server)s)", {
+ server: option.key.roomServer,
+ instance: option.key.instanceId ? option.label : "Matrix",
+ }) : _t("Show: Matrix rooms")}
+ AdditionalOptions={addNewServer}
+ />
+ );
};
-
-export default NetworkDropdown;
diff --git a/src/components/views/rooms/RoomListHeader.tsx b/src/components/views/rooms/RoomListHeader.tsx
index 4e390828171..8cba91def02 100644
--- a/src/components/views/rooms/RoomListHeader.tsx
+++ b/src/components/views/rooms/RoomListHeader.tsx
@@ -54,7 +54,7 @@ import TooltipTarget from "../elements/TooltipTarget";
import { BetaPill } from "../beta/BetaCard";
import PosthogTrackers from "../../../PosthogTrackers";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
-import { useWebSearchMetrics } from "../dialogs/SpotlightDialog";
+import { useWebSearchMetrics } from "../dialogs/spotlight/SpotlightDialog";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
diff --git a/src/hooks/spotlight/useDebouncedCallback.ts b/src/hooks/spotlight/useDebouncedCallback.ts
new file mode 100644
index 00000000000..9548ce5e0c3
--- /dev/null
+++ b/src/hooks/spotlight/useDebouncedCallback.ts
@@ -0,0 +1,41 @@
+/*
+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 { useEffect } from "react";
+
+const DEBOUNCE_TIMEOUT = 100;
+
+export function useDebouncedCallback(
+ enabled: boolean,
+ callback: (...params: T) => void,
+ params: T,
+) {
+ useEffect(() => {
+ let handle: number | null = null;
+ const doSearch = () => {
+ handle = null;
+ callback(...params);
+ };
+ if (enabled !== false) {
+ handle = setTimeout(doSearch, DEBOUNCE_TIMEOUT);
+ return () => {
+ if (handle) {
+ clearTimeout(handle);
+ }
+ };
+ }
+ }, [enabled, callback, params]);
+}
diff --git a/src/hooks/spotlight/useRecentSearches.ts b/src/hooks/spotlight/useRecentSearches.ts
new file mode 100644
index 00000000000..1ae43be69e8
--- /dev/null
+++ b/src/hooks/spotlight/useRecentSearches.ts
@@ -0,0 +1,35 @@
+/*
+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 { useState } from "react";
+import { Room } from "matrix-js-sdk/src/matrix";
+
+import { MatrixClientPeg } from "../../MatrixClientPeg";
+import { SettingLevel } from "../../settings/SettingLevel";
+import SettingsStore from "../../settings/SettingsStore";
+
+export 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([]);
+ }];
+};
diff --git a/src/hooks/useLatestResult.ts b/src/hooks/useLatestResult.ts
new file mode 100644
index 00000000000..fde3ab89dd9
--- /dev/null
+++ b/src/hooks/useLatestResult.ts
@@ -0,0 +1,35 @@
+/*
+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 { useCallback, useRef } from "react";
+
+/**
+ * Hook to prevent a slower response to an earlier query overwriting the result to a faster response of a later query
+ * @param onResultChanged
+ */
+export const useLatestResult = (onResultChanged: (result: R) => void):
+ [(query: T | null) => void, (query: T | null, result: R) => void] => {
+ const ref = useRef(null);
+ const setQuery = useCallback((query: T | null) => {
+ ref.current = query;
+ }, []);
+ const setResult = useCallback((query: T | null, result: R) => {
+ if (ref.current === query) {
+ onResultChanged(result);
+ }
+ }, [onResultChanged]);
+ return [setQuery, setResult];
+};
diff --git a/src/hooks/useProfileInfo.ts b/src/hooks/useProfileInfo.ts
new file mode 100644
index 00000000000..ed70d4bc425
--- /dev/null
+++ b/src/hooks/useProfileInfo.ts
@@ -0,0 +1,70 @@
+/*
+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 { useCallback, useState } from "react";
+
+import { MatrixClientPeg } from "../MatrixClientPeg";
+import { useLatestResult } from "./useLatestResult";
+
+export interface IProfileInfoOpts {
+ query?: string;
+}
+
+export interface IProfileInfo {
+ user_id: string;
+ avatar_url?: string;
+ display_name?: string;
+}
+
+export const useProfileInfo = () => {
+ const [profile, setProfile] = useState(null);
+
+ const [loading, setLoading] = useState(false);
+
+ const [updateQuery, updateResult] = useLatestResult(setProfile);
+
+ const search = useCallback(async ({ query: term }: IProfileInfoOpts): Promise => {
+ updateQuery(term);
+ if (!term?.length || !term.startsWith('@') || !term.includes(':')) {
+ setProfile(null);
+ return true;
+ }
+
+ setLoading(true);
+ try {
+ const result = await MatrixClientPeg.get().getProfileInfo(term);
+ updateResult(term, {
+ user_id: term,
+ avatar_url: result.avatar_url,
+ display_name: result.displayname,
+ });
+ return true;
+ } catch (e) {
+ console.error("Could not fetch profile info for params", { term }, e);
+ updateResult(term, null);
+ return false;
+ } finally {
+ setLoading(false);
+ }
+ }, [updateQuery, updateResult]);
+
+ return {
+ ready: true,
+ loading,
+ profile,
+ search,
+ } as const;
+};
diff --git a/src/hooks/usePublicRoomDirectory.ts b/src/hooks/usePublicRoomDirectory.ts
index 24cc8f541a1..fd0b7643725 100644
--- a/src/hooks/usePublicRoomDirectory.ts
+++ b/src/hooks/usePublicRoomDirectory.ts
@@ -14,14 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { useCallback, useEffect, useState } from "react";
-import { IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
import { IRoomDirectoryOptions } from "matrix-js-sdk/src/@types/requests";
+import { IProtocol, IPublicRoomsChunkRoom } from "matrix-js-sdk/src/client";
+import { useCallback, useEffect, useState } from "react";
+import { IPublicRoomDirectoryConfig } from "../components/views/directory/NetworkDropdown";
import { MatrixClientPeg } from "../MatrixClientPeg";
import SdkConfig from "../SdkConfig";
import SettingsStore from "../settings/SettingsStore";
import { Protocols } from "../utils/DirectoryUtils";
+import { useLatestResult } from "./useLatestResult";
export const ALL_ROOMS = "ALL_ROOMS";
const LAST_SERVER_KEY = "mx_last_room_directory_server";
@@ -37,13 +39,15 @@ let thirdParty: Protocols;
export const usePublicRoomDirectory = () => {
const [publicRooms, setPublicRooms] = useState([]);
- const [roomServer, setRoomServer] = useState(undefined);
- const [instanceId, setInstanceId] = useState(undefined);
+ const [config, setConfigInternal] = useState(undefined);
+
const [protocols, setProtocols] = useState(null);
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
+ const [updateQuery, updateResult] = useLatestResult(setPublicRooms);
+
async function initProtocols() {
if (!MatrixClientPeg.get()) {
// We may not have a client yet when invoked from welcome page
@@ -57,12 +61,11 @@ export const usePublicRoomDirectory = () => {
}
}
- function setConfig(server: string, instanceId?: string) {
+ function setConfig(config: IPublicRoomDirectoryConfig) {
if (!ready) {
throw new Error("public room configuration not initialised yet");
} else {
- setRoomServer(server);
- setInstanceId(instanceId ?? null);
+ setConfigInternal(config);
}
}
@@ -70,21 +73,16 @@ export const usePublicRoomDirectory = () => {
limit = 20,
query,
}: IPublicRoomsOpts): Promise => {
- if (!query?.length) {
- setPublicRooms([]);
- return true;
- }
-
const opts: IRoomDirectoryOptions = { limit };
- if (roomServer != MatrixClientPeg.getHomeserverName()) {
- opts.server = roomServer;
+ if (config?.roomServer != MatrixClientPeg.getHomeserverName()) {
+ opts.server = config?.roomServer;
}
- if (instanceId === ALL_ROOMS) {
+ if (config?.instanceId === ALL_ROOMS) {
opts.include_all_networks = true;
- } else if (instanceId) {
- opts.third_party_instance_id = instanceId;
+ } else if (config?.instanceId) {
+ opts.third_party_instance_id = config.instanceId;
}
if (query) {
@@ -93,19 +91,20 @@ export const usePublicRoomDirectory = () => {
};
}
+ updateQuery(opts);
try {
setLoading(true);
const { chunk } = await MatrixClientPeg.get().publicRooms(opts);
- setPublicRooms(chunk);
+ updateResult(opts, chunk);
return true;
} catch (e) {
console.error("Could not fetch public rooms for params", opts, e);
- setPublicRooms([]);
+ updateResult(opts, []);
return false;
} finally {
setLoading(false);
}
- }, [roomServer, instanceId]);
+ }, [config, updateQuery, updateResult]);
useEffect(() => {
initProtocols();
@@ -118,9 +117,9 @@ export const usePublicRoomDirectory = () => {
const myHomeserver = MatrixClientPeg.getHomeserverName();
const lsRoomServer = localStorage.getItem(LAST_SERVER_KEY);
- const lsInstanceId = localStorage.getItem(LAST_INSTANCE_KEY);
+ const lsInstanceId: string | undefined = localStorage.getItem(LAST_INSTANCE_KEY) ?? undefined;
- let roomServer = myHomeserver;
+ let roomServer: string = myHomeserver;
if (
SdkConfig.getObject("room_directory")?.get("servers")?.includes(lsRoomServer) ||
SettingsStore.getValue("room_directory_servers")?.includes(lsRoomServer)
@@ -128,7 +127,7 @@ export const usePublicRoomDirectory = () => {
roomServer = lsRoomServer;
}
- let instanceId: string | null = null;
+ let instanceId: string | undefined = undefined;
if (roomServer === myHomeserver && (
lsInstanceId === ALL_ROOMS ||
Object.values(protocols).some((p: IProtocol) => {
@@ -139,25 +138,24 @@ export const usePublicRoomDirectory = () => {
}
setReady(true);
- setInstanceId(instanceId);
- setRoomServer(roomServer);
+ setConfigInternal({ roomServer, instanceId });
}, [protocols]);
useEffect(() => {
- localStorage.setItem(LAST_SERVER_KEY, roomServer);
- }, [roomServer]);
-
- useEffect(() => {
- localStorage.setItem(LAST_INSTANCE_KEY, instanceId);
- }, [instanceId]);
+ localStorage.setItem(LAST_SERVER_KEY, config?.roomServer);
+ if (config?.instanceId) {
+ localStorage.setItem(LAST_INSTANCE_KEY, config?.instanceId);
+ } else {
+ localStorage.removeItem(LAST_INSTANCE_KEY);
+ }
+ }, [config]);
return {
ready,
loading,
publicRooms,
protocols,
- roomServer,
- instanceId,
+ config,
search,
setConfig,
} as const;
diff --git a/src/hooks/useSpaceResults.ts b/src/hooks/useSpaceResults.ts
new file mode 100644
index 00000000000..7497689b28d
--- /dev/null
+++ b/src/hooks/useSpaceResults.ts
@@ -0,0 +1,69 @@
+/*
+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 { useCallback, useEffect, useMemo, useState } from "react";
+import { Room, RoomType } from "matrix-js-sdk/src/matrix";
+import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
+import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
+import { normalize } from "matrix-js-sdk/src/utils";
+
+import { MatrixClientPeg } from "../MatrixClientPeg";
+
+export const useSpaceResults = (space?: Room, query?: string): [IHierarchyRoom[], boolean] => {
+ const [rooms, setRooms] = useState([]);
+ const [hierarchy, setHierarchy] = useState();
+
+ const resetHierarchy = useCallback(() => {
+ setHierarchy(space ? new RoomHierarchy(space, 50) : null);
+ }, [space]);
+ useEffect(resetHierarchy, [resetHierarchy]);
+
+ useEffect(() => {
+ if (!space || !hierarchy) return; // nothing to load
+
+ let unmounted = false;
+
+ (async () => {
+ while (hierarchy?.canLoadMore && !unmounted && space === hierarchy.root) {
+ await hierarchy.load();
+ if (hierarchy.canLoadMore) hierarchy.load(); // start next load so that the loading attribute is right
+ setRooms(hierarchy.rooms);
+ }
+ })();
+
+ return () => {
+ unmounted = true;
+ };
+ }, [space, hierarchy]);
+
+ const results = useMemo(() => {
+ const trimmedQuery = query.trim();
+ const lcQuery = trimmedQuery.toLowerCase();
+ const normalizedQuery = normalize(trimmedQuery);
+
+ const cli = MatrixClientPeg.get();
+ return rooms?.filter(r => {
+ return r.room_type !== RoomType.Space &&
+ cli.getRoom(r.room_id)?.getMyMembership() !== "join" &&
+ (
+ normalize(r.name || "").includes(normalizedQuery) ||
+ (r.canonical_alias || "").includes(lcQuery)
+ );
+ });
+ }, [rooms, query]);
+
+ return [results, hierarchy?.loading ?? false];
+};
diff --git a/src/hooks/useUserDirectory.ts b/src/hooks/useUserDirectory.ts
index cb7307af2ac..10df6bc1c82 100644
--- a/src/hooks/useUserDirectory.ts
+++ b/src/hooks/useUserDirectory.ts
@@ -18,6 +18,7 @@ import { useCallback, useState } from "react";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { DirectoryMember } from "../utils/direct-messages";
+import { useLatestResult } from "./useLatestResult";
export interface IUserDirectoryOpts {
limit: number;
@@ -29,10 +30,15 @@ export const useUserDirectory = () => {
const [loading, setLoading] = useState(false);
+ const [updateQuery, updateResult] = useLatestResult<{ term: string, limit?: number }, DirectoryMember[]>(setUsers);
+
const search = useCallback(async ({
limit = 20,
query: term,
}: IUserDirectoryOpts): Promise => {
+ const opts = { limit, term };
+ updateQuery(opts);
+
if (!term?.length) {
setUsers([]);
return true;
@@ -40,20 +46,17 @@ export const useUserDirectory = () => {
try {
setLoading(true);
- const { results } = await MatrixClientPeg.get().searchUserDirectory({
- limit,
- term,
- });
- setUsers(results.map(user => new DirectoryMember(user)));
+ const { results } = await MatrixClientPeg.get().searchUserDirectory(opts);
+ updateResult(opts, results.map(user => new DirectoryMember(user)));
return true;
} catch (e) {
console.error("Could not fetch user in user directory for params", { limit, term }, e);
- setUsers([]);
+ updateResult(opts, []);
return false;
} finally {
setLoading(false);
}
- }, []);
+ }, [updateQuery, updateResult]);
return {
ready: true,
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 810d387e4af..64ba5dfea42 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -2383,15 +2383,14 @@
"You are not allowed to view this server's rooms list": "You are not allowed to view this server's rooms list",
"Can't find this server or its room list": "Can't find this server or its room list",
"Your server": "Your server",
- "Are you sure you want to remove %(serverName)s ": "Are you sure you want to remove %(serverName)s ",
- "Remove server": "Remove server",
"Matrix": "Matrix",
+ "Remove server “%(roomServer)s”": "Remove server “%(roomServer)s”",
"Add a new server": "Add a new server",
"Enter the name of a new server you want to explore.": "Enter the name of a new server you want to explore.",
"Server name": "Server name",
- "Add a new server...": "Add a new server...",
- "%(networkName)s rooms": "%(networkName)s rooms",
- "Matrix rooms": "Matrix rooms",
+ "Add new server…": "Add new server…",
+ "Show: %(instance)s rooms (%(server)s)": "Show: %(instance)s rooms (%(server)s)",
+ "Show: Matrix rooms": "Show: Matrix rooms",
"Add existing space": "Add existing space",
"Want to add a new space instead?": "Want to add a new space instead?",
"Create a new space": "Create a new space",
@@ -2759,18 +2758,6 @@
"This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.": "This groups your chats with members of this space. Turning this off will hide those chats from your view of %(spaceName)s.",
"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",
- "Join %(roomAddress)s": "Join %(roomAddress)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": "Use to scroll",
- "Search Dialog": "Search Dialog",
- "Results not as expected? Please give feedback .": "Results not as expected? Please give feedback .",
"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.",
@@ -2810,6 +2797,30 @@
"Allow this widget to verify your identity": "Allow this widget to verify your identity",
"The widget will verify your user ID, but won't be able to perform actions for you:": "The widget will verify your user ID, but won't be able to perform actions for you:",
"Remember this": "Remember this",
+ "%(count)s Members|other": "%(count)s Members",
+ "%(count)s Members|one": "%(count)s Member",
+ "Public rooms": "Public rooms",
+ "Use \"%(query)s\" to search": "Use \"%(query)s\" to search",
+ "Search for": "Search for",
+ "Spaces you're in": "Spaces you're in",
+ "Other rooms in %(spaceName)s": "Other rooms in %(spaceName)s",
+ "Join %(roomAddress)s": "Join %(roomAddress)s",
+ "Some results may be hidden for privacy": "Some results may be hidden for privacy",
+ "If you can't see who you're looking for, send them your invite link.": "If you can't see who you're looking for, send them your invite link.",
+ "Copy invite link": "Copy invite link",
+ "Some results may be hidden": "Some results may be hidden",
+ "If you can't find the room you're looking for, ask for an invite or create a new room.": "If you can't find the room you're looking for, ask for an invite or create a new room.",
+ "Create new Room": "Create new Room",
+ "Other options": "Other options",
+ "Start a group chat": "Start a group chat",
+ "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": "Use to scroll",
+ "Search Dialog": "Search Dialog",
+ "Remove search filter for %(filter)s": "Remove search filter for %(filter)s",
+ "Results not as expected? Please give feedback .": "Results not as expected? Please give feedback .",
"Wrong file type": "Wrong file type",
"Looks good!": "Looks good!",
"Wrong Security Key": "Wrong Security Key",
diff --git a/src/utils/DirectoryUtils.ts b/src/utils/DirectoryUtils.ts
index f8b9e858d12..429c54dd4fb 100644
--- a/src/utils/DirectoryUtils.ts
+++ b/src/utils/DirectoryUtils.ts
@@ -23,7 +23,7 @@ export type Protocols = Record;
// Find a protocol 'instance' with a given instance_id
// in the supplied protocols dict
-export function instanceForInstanceId(protocols: Protocols, instanceId: string): IInstance {
+export function instanceForInstanceId(protocols: Protocols, instanceId: string | null | undefined): IInstance | null {
if (!instanceId) return null;
for (const proto of Object.keys(protocols)) {
if (!protocols[proto].instances && protocols[proto].instances instanceof Array) continue;
@@ -31,11 +31,12 @@ export function instanceForInstanceId(protocols: Protocols, instanceId: string):
if (instance.instance_id == instanceId) return instance;
}
}
+ return null;
}
// given an instance_id, return the name of the protocol for
// that instance ID in the supplied protocols dict
-export function protocolNameForInstanceId(protocols: Protocols, instanceId: string): string {
+export function protocolNameForInstanceId(protocols: Protocols, instanceId: string | null | undefined): string | null {
if (!instanceId) return null;
for (const proto of Object.keys(protocols)) {
if (!protocols[proto].instances && protocols[proto].instances instanceof Array) continue;
@@ -43,4 +44,5 @@ export function protocolNameForInstanceId(protocols: Protocols, instanceId: stri
if (instance.instance_id == instanceId) return proto;
}
}
+ return null;
}
diff --git a/src/utils/SortMembers.ts b/src/utils/SortMembers.ts
new file mode 100644
index 00000000000..74d6388c93f
--- /dev/null
+++ b/src/utils/SortMembers.ts
@@ -0,0 +1,110 @@
+/*
+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 { groupBy, mapValues, maxBy, minBy, sumBy, takeRight } from "lodash";
+import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
+
+import { Member } from "./direct-messages";
+import DMRoomMap from "./DMRoomMap";
+import { compare } from "./strings";
+
+export const compareMembers = (
+ activityScores: Record,
+ memberScores: Record,
+) => (a: Member | RoomMember, b: Member | RoomMember): number => {
+ const aActivityScore = activityScores[a.userId]?.score ?? 0;
+ const aMemberScore = memberScores[a.userId]?.score ?? 0;
+ const aScore = aActivityScore + aMemberScore;
+ const aNumRooms = memberScores[a.userId]?.numRooms ?? 0;
+
+ const bActivityScore = activityScores[b.userId]?.score ?? 0;
+ const bMemberScore = memberScores[b.userId]?.score ?? 0;
+ const bScore = bActivityScore + bMemberScore;
+ const bNumRooms = memberScores[b.userId]?.numRooms ?? 0;
+
+ if (aScore === bScore) {
+ if (aNumRooms === bNumRooms) {
+ return compare(a.userId, b.userId);
+ }
+
+ return bNumRooms - aNumRooms;
+ }
+ return bScore - aScore;
+};
+
+function joinedRooms(cli: MatrixClient): Room[] {
+ return cli.getRooms()
+ .filter(r => r.getMyMembership() === 'join')
+ // Skip low priority rooms and DMs
+ .filter(r => !DMRoomMap.shared().getUserIdForRoomId(r.roomId))
+ .filter(r => !Object.keys(r.tags).includes("m.lowpriority"));
+}
+
+interface IActivityScore {
+ lastSpoke: number;
+ score: number;
+}
+
+// Score people based on who have sent messages recently, as a way to improve the quality of suggestions.
+// We do this by checking every room to see who has sent a message in the last few hours, and giving them
+// a score which correlates to the freshness of their message. In theory, this results in suggestions
+// which are closer to "continue this conversation" rather than "this person exists".
+export function buildActivityScores(cli: MatrixClient): { [key: string]: IActivityScore } {
+ const now = new Date().getTime();
+ const earliestAgeConsidered = now - (60 * 60 * 1000); // 1 hour ago
+ const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
+ const events = joinedRooms(cli)
+ .flatMap(room => takeRight(room.getLiveTimeline().getEvents(), maxMessagesConsidered))
+ .filter(ev => ev.getTs() > earliestAgeConsidered);
+ const senderEvents = groupBy(events, ev => ev.getSender());
+ return mapValues(senderEvents, events => {
+ const lastEvent = maxBy(events, ev => ev.getTs());
+ const distanceFromNow = Math.abs(now - lastEvent.getTs()); // abs to account for slight future messages
+ const inverseTime = (now - earliestAgeConsidered) - distanceFromNow;
+ return {
+ lastSpoke: lastEvent.getTs(),
+ // Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
+ // score we'll try and award at least 1.0 for making the list, with 4.0 being
+ // an approximate maximum for being selected.
+ score: Math.max(1, inverseTime / (15 * 60 * 1000)), // 15min segments to keep scores sane
+ };
+ });
+}
+
+interface IMemberScore {
+ member: RoomMember;
+ score: number;
+ numRooms: number;
+}
+
+export function buildMemberScores(cli: MatrixClient): { [key: string]: IMemberScore } {
+ const maxConsideredMembers = 200;
+ const consideredRooms = joinedRooms(cli).filter(room => room.getJoinedMemberCount() < maxConsideredMembers);
+ const memberPeerEntries = consideredRooms
+ .flatMap(room =>
+ room.getJoinedMembers().map(member =>
+ ({ member, roomSize: room.getJoinedMemberCount() })));
+ const userMeta = groupBy(memberPeerEntries, ({ member }) => member.userId);
+ return mapValues(userMeta, roomMemberships => {
+ const maximumPeers = maxConsideredMembers * roomMemberships.length;
+ const totalPeers = sumBy(roomMemberships, entry => entry.roomSize);
+ return {
+ member: minBy(roomMemberships, entry => entry.roomSize).member,
+ numRooms: roomMemberships.length,
+ score: Math.max(0, Math.pow(1 - (totalPeers / maximumPeers), 5)),
+ };
+ });
+}
diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx
new file mode 100644
index 00000000000..7c8bbc6bc73
--- /dev/null
+++ b/test/components/views/dialogs/SpotlightDialog-test.tsx
@@ -0,0 +1,292 @@
+/*
+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 { mount } from "enzyme";
+import { IProtocol, IPublicRoomsChunkRoom, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
+import { sleep } from "matrix-js-sdk/src/utils";
+import React from "react";
+import { act } from "react-dom/test-utils";
+import sanitizeHtml from "sanitize-html";
+
+import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog";
+import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
+import { stubClient } from "../../../test-utils";
+
+interface IUserChunkMember {
+ user_id: string;
+ display_name?: string;
+ avatar_url?: string;
+}
+
+interface MockClientOptions {
+ userId?: string;
+ homeserver?: string;
+ thirdPartyProtocols?: Record;
+ rooms?: IPublicRoomsChunkRoom[];
+ members?: RoomMember[];
+ users?: IUserChunkMember[];
+}
+
+function mockClient(
+ {
+ userId = "testuser",
+ homeserver = "example.tld",
+ thirdPartyProtocols = {},
+ rooms = [],
+ members = [],
+ users = [],
+ }: MockClientOptions = {},
+): MatrixClient {
+ stubClient();
+ const cli = MatrixClientPeg.get();
+ MatrixClientPeg.getHomeserverName = jest.fn(() => homeserver);
+ cli.getUserId = jest.fn(() => userId);
+ cli.getHomeserverUrl = jest.fn(() => homeserver);
+ cli.getThirdpartyProtocols = jest.fn(() => Promise.resolve(thirdPartyProtocols));
+ cli.publicRooms = jest.fn((options) => {
+ const searchTerm = options?.filter?.generic_search_term?.toLowerCase();
+ const chunk = rooms.filter(it =>
+ !searchTerm ||
+ it.room_id.toLowerCase().includes(searchTerm) ||
+ it.name?.toLowerCase().includes(searchTerm) ||
+ sanitizeHtml(it?.topic, { allowedTags: [] }).toLowerCase().includes(searchTerm) ||
+ it.canonical_alias?.toLowerCase().includes(searchTerm) ||
+ it.aliases?.find(alias => alias.toLowerCase().includes(searchTerm)));
+ return Promise.resolve({
+ chunk,
+ total_room_count_estimate: chunk.length,
+ });
+ });
+ cli.searchUserDirectory = jest.fn(({ term, limit }) => {
+ const searchTerm = term?.toLowerCase();
+ const results = users.filter(it => !searchTerm ||
+ it.user_id.toLowerCase().includes(searchTerm) ||
+ it.display_name.toLowerCase().includes(searchTerm));
+ return Promise.resolve({
+ results: results.slice(0, limit ?? +Infinity),
+ limited: limit && limit < results.length,
+ });
+ });
+ cli.getProfileInfo = jest.fn(async (userId) => {
+ const member = members.find(it => it.userId === userId);
+ if (member) {
+ return Promise.resolve({
+ displayname: member.rawDisplayName,
+ avatar_url: member.getMxcAvatarUrl(),
+ });
+ } else {
+ return Promise.reject();
+ }
+ });
+ return cli;
+}
+
+describe("Spotlight Dialog", () => {
+ const testPerson: IUserChunkMember = {
+ user_id: "@janedoe:matrix.org",
+ display_name: "Jane Doe",
+ avatar_url: undefined,
+ };
+
+ const testPublicRoom: IPublicRoomsChunkRoom = {
+ room_id: "@room247:matrix.org",
+ name: "Room #247",
+ topic: "We hope you'll have a shining experience!",
+ world_readable: false,
+ num_joined_members: 1,
+ guest_can_join: false,
+ };
+
+ beforeEach(() => {
+ mockClient({ rooms: [testPublicRoom], users: [testPerson] });
+ });
+
+ describe("should apply filters supplied via props", () => {
+ it("without filter", async () => {
+ const wrapper = mount(
+ null} />,
+ );
+ await act(async () => {
+ await sleep(200);
+ });
+ wrapper.update();
+
+ const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeFalsy();
+
+ wrapper.unmount();
+ });
+ it("with public room filter", async () => {
+ const wrapper = mount(
+ null} />,
+ );
+ await act(async () => {
+ await sleep(200);
+ });
+ wrapper.update();
+
+ const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeTruthy();
+ expect(filterChip.text()).toEqual("Public rooms");
+
+ const content = wrapper.find("#mx_SpotlightDialog_content");
+ const options = content.find("div.mx_SpotlightDialog_option");
+ expect(options.length).toBe(1);
+ expect(options.first().text()).toContain(testPublicRoom.name);
+
+ wrapper.unmount();
+ });
+ it("with people filter", async () => {
+ const wrapper = mount(
+ null} />,
+ );
+ await act(async () => {
+ await sleep(200);
+ });
+ wrapper.update();
+
+ const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeTruthy();
+ expect(filterChip.text()).toEqual("People");
+
+ const content = wrapper.find("#mx_SpotlightDialog_content");
+ const options = content.find("div.mx_SpotlightDialog_option");
+ expect(options.length).toBeGreaterThanOrEqual(1);
+ expect(options.first().text()).toContain(testPerson.display_name);
+
+ wrapper.unmount();
+ });
+ });
+
+ describe("should apply manually selected filter", () => {
+ it("with public rooms", async () => {
+ const wrapper = mount(
+ null} />,
+ );
+ await act(async () => {
+ await sleep(1);
+ });
+ wrapper.update();
+ wrapper.find("#mx_SpotlightDialog_button_explorePublicRooms").first().simulate("click");
+ await act(async () => {
+ await sleep(200);
+ });
+ wrapper.update();
+
+ const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeTruthy();
+ expect(filterChip.text()).toEqual("Public rooms");
+
+ const content = wrapper.find("#mx_SpotlightDialog_content");
+ const options = content.find("div.mx_SpotlightDialog_option");
+ expect(options.length).toBe(1);
+ expect(options.first().text()).toContain(testPublicRoom.name);
+
+ wrapper.unmount();
+ });
+ it("with people", async () => {
+ const wrapper = mount(
+ null} />,
+ );
+ await act(async () => {
+ await sleep(1);
+ });
+ wrapper.update();
+ wrapper.find("#mx_SpotlightDialog_button_startChat").first().simulate("click");
+ await act(async () => {
+ await sleep(200);
+ });
+ wrapper.update();
+
+ const filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeTruthy();
+ expect(filterChip.text()).toEqual("People");
+
+ const content = wrapper.find("#mx_SpotlightDialog_content");
+ const options = content.find("div.mx_SpotlightDialog_option");
+ expect(options.length).toBeGreaterThanOrEqual(1);
+ expect(options.first().text()).toContain(testPerson.display_name);
+
+ wrapper.unmount();
+ });
+ });
+
+ describe("should allow clearing filter manually", () => {
+ it("with public room filter", async () => {
+ const wrapper = mount(
+ null} />,
+ );
+ await act(async () => {
+ await sleep(200);
+ });
+ wrapper.update();
+
+ let filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeTruthy();
+ expect(filterChip.text()).toEqual("Public rooms");
+
+ filterChip.find("div.mx_SpotlightDialog_filter--close").simulate("click");
+ await act(async () => {
+ await sleep(1);
+ });
+ wrapper.update();
+
+ filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeFalsy();
+
+ wrapper.unmount();
+ });
+ it("with people filter", async () => {
+ const wrapper = mount(
+ null} />,
+ );
+ await act(async () => {
+ await sleep(200);
+ });
+ wrapper.update();
+
+ let filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeTruthy();
+ expect(filterChip.text()).toEqual("People");
+
+ filterChip.find("div.mx_SpotlightDialog_filter--close").simulate("click");
+ await act(async () => {
+ await sleep(1);
+ });
+ wrapper.update();
+
+ filterChip = wrapper.find("div.mx_SpotlightDialog_filter");
+ expect(filterChip.exists()).toBeFalsy();
+
+ wrapper.unmount();
+ });
+ });
+});
diff --git a/test/hooks/useDebouncedCallback-test.tsx b/test/hooks/useDebouncedCallback-test.tsx
new file mode 100644
index 00000000000..8aa28cb8f5d
--- /dev/null
+++ b/test/hooks/useDebouncedCallback-test.tsx
@@ -0,0 +1,179 @@
+/*
+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 { mount } from "enzyme";
+import { sleep } from "matrix-js-sdk/src/utils";
+import React from "react";
+import { act } from "react-dom/test-utils";
+
+import { useDebouncedCallback } from "../../src/hooks/spotlight/useDebouncedCallback";
+
+function DebouncedCallbackComponent({ enabled, params, callback }) {
+ useDebouncedCallback(enabled, callback, params);
+ return
+ { JSON.stringify(params) }
+
;
+}
+
+describe("useDebouncedCallback", () => {
+ it("should be able to handle empty parameters", async () => {
+ const params = [];
+ const callback = jest.fn();
+
+ const wrapper = mount( );
+ await act(async () => {
+ await sleep(1);
+ wrapper.setProps({ enabled: true, params, callback });
+ return act(() => sleep(500));
+ });
+
+ expect(wrapper.text()).toContain(JSON.stringify(params));
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call the callback with the parameters", async () => {
+ const params = ["USER NAME"];
+ const callback = jest.fn();
+
+ const wrapper = mount( );
+ await act(async () => {
+ await sleep(1);
+ wrapper.setProps({ enabled: true, params, callback });
+ return act(() => sleep(500));
+ });
+
+ expect(wrapper.text()).toContain(JSON.stringify(params));
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith(...params);
+ });
+
+ it("should handle multiple parameters", async () => {
+ const params = [4, 8, 15, 16, 23, 42];
+ const callback = jest.fn();
+
+ const wrapper = mount( );
+ await act(async () => {
+ await sleep(1);
+ wrapper.setProps({ enabled: true, params, callback });
+ return act(() => sleep(500));
+ });
+
+ expect(wrapper.text()).toContain(JSON.stringify(params));
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith(...params);
+ });
+
+ it("should debounce quick changes", async () => {
+ const queries = [
+ "U",
+ "US",
+ "USE",
+ "USER",
+ "USER ",
+ "USER N",
+ "USER NM",
+ "USER NMA",
+ "USER NM",
+ "USER N",
+ "USER NA",
+ "USER NAM",
+ "USER NAME",
+ ];
+ const callback = jest.fn();
+
+ const wrapper = mount( );
+ await act(async () => {
+ await sleep(1);
+ for (const query of queries) {
+ wrapper.setProps({ enabled: true, params: [query], callback });
+ await sleep(50);
+ }
+ return act(() => sleep(500));
+ });
+
+ const query = queries[queries.length - 1];
+ expect(wrapper.text()).toContain(JSON.stringify(query));
+ expect(callback).toHaveBeenCalledTimes(1);
+ expect(callback).toHaveBeenCalledWith(query);
+ });
+
+ it("should not debounce slow changes", async () => {
+ const queries = [
+ "U",
+ "US",
+ "USE",
+ "USER",
+ "USER ",
+ "USER N",
+ "USER NM",
+ "USER NMA",
+ "USER NM",
+ "USER N",
+ "USER NA",
+ "USER NAM",
+ "USER NAME",
+ ];
+ const callback = jest.fn();
+
+ const wrapper = mount( );
+ await act(async () => {
+ await sleep(1);
+ for (const query of queries) {
+ wrapper.setProps({ enabled: true, params: [query], callback });
+ await sleep(200);
+ }
+ return act(() => sleep(500));
+ });
+
+ const query = queries[queries.length - 1];
+ expect(wrapper.text()).toContain(JSON.stringify(query));
+ expect(callback).toHaveBeenCalledTimes(queries.length);
+ expect(callback).toHaveBeenCalledWith(query);
+ });
+
+ it("should not call the callback if it’s disabled", async () => {
+ const queries = [
+ "U",
+ "US",
+ "USE",
+ "USER",
+ "USER ",
+ "USER N",
+ "USER NM",
+ "USER NMA",
+ "USER NM",
+ "USER N",
+ "USER NA",
+ "USER NAM",
+ "USER NAME",
+ ];
+ const callback = jest.fn();
+
+ const wrapper = mount( );
+ await act(async () => {
+ await sleep(1);
+ for (const query of queries) {
+ wrapper.setProps({ enabled: false, params: [query], callback });
+ await sleep(200);
+ }
+ return act(() => sleep(500));
+ });
+
+ const query = queries[queries.length - 1];
+ expect(wrapper.text()).toContain(JSON.stringify(query));
+ expect(callback).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/test/hooks/useLatestResult-test.tsx b/test/hooks/useLatestResult-test.tsx
new file mode 100644
index 00000000000..159c5f3e2f7
--- /dev/null
+++ b/test/hooks/useLatestResult-test.tsx
@@ -0,0 +1,91 @@
+/*
+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 { mount } from "enzyme";
+import { sleep } from "matrix-js-sdk/src/utils";
+import React, { useEffect, useState } from "react";
+import { act } from "react-dom/test-utils";
+
+import { useLatestResult } from "../../src/hooks/useLatestResult";
+
+function LatestResultsComponent({ query, doRequest }) {
+ const [value, setValueInternal] = useState(0);
+ const [updateQuery, updateResult] = useLatestResult(setValueInternal);
+ useEffect(() => {
+ updateQuery(query);
+ doRequest(query).then(it => {
+ updateResult(query, it);
+ });
+ }, [doRequest, query, updateQuery, updateResult]);
+
+ return
+ { value }
+
;
+}
+
+describe("useLatestResult", () => {
+ it("should return results", async () => {
+ const doRequest = async (query) => {
+ await sleep(20);
+ return query;
+ };
+
+ const wrapper = mount( );
+ await act(async () => {
+ await sleep(25);
+ });
+ expect(wrapper.text()).toContain("0");
+ wrapper.setProps({ doRequest, query: 1 });
+ await act(async () => {
+ await sleep(15);
+ });
+ wrapper.setProps({ doRequest, query: 2 });
+ await act(async () => {
+ await sleep(15);
+ });
+ expect(wrapper.text()).toContain("0");
+ await act(async () => {
+ await sleep(15);
+ });
+ expect(wrapper.text()).toContain("2");
+ });
+
+ it("should prevent out-of-order results", async () => {
+ const doRequest = async (query) => {
+ await sleep(query);
+ return query;
+ };
+
+ const wrapper = mount( );
+ await act(async () => {
+ await sleep(5);
+ });
+ expect(wrapper.text()).toContain("0");
+ wrapper.setProps({ doRequest, query: 50 });
+ await act(async () => {
+ await sleep(5);
+ });
+ wrapper.setProps({ doRequest, query: 1 });
+ await act(async () => {
+ await sleep(5);
+ });
+ expect(wrapper.text()).toContain("1");
+ await act(async () => {
+ await sleep(50);
+ });
+ expect(wrapper.text()).toContain("1");
+ });
+});
diff --git a/test/hooks/useProfileInfo-test.tsx b/test/hooks/useProfileInfo-test.tsx
new file mode 100644
index 00000000000..e77d7c28344
--- /dev/null
+++ b/test/hooks/useProfileInfo-test.tsx
@@ -0,0 +1,154 @@
+/*
+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 { mount } from "enzyme";
+import { sleep } from "matrix-js-sdk/src/utils";
+import React from "react";
+import { act } from "react-dom/test-utils";
+
+import { useProfileInfo } from "../../src/hooks/useProfileInfo";
+import { MatrixClientPeg } from "../../src/MatrixClientPeg";
+import { stubClient } from "../test-utils/test-utils";
+
+function ProfileInfoComponent({ onClick }) {
+ const profileInfo = useProfileInfo();
+
+ const {
+ ready,
+ loading,
+ profile,
+ } = profileInfo;
+
+ return onClick(profileInfo)}>
+ { (!ready || loading) && `ready: ${ready}, loading: ${loading}` }
+ { profile && (
+ `Name: ${profile.display_name}`
+ ) }
+
;
+}
+
+describe("useProfileInfo", () => {
+ let cli;
+
+ beforeEach(() => {
+ stubClient();
+ cli = MatrixClientPeg.get();
+ cli.getProfileInfo = (query) => {
+ return Promise.resolve({
+ avatar_url: undefined,
+ displayname: query,
+ });
+ };
+ });
+
+ it("should display user profile when searching", async () => {
+ const query = "@user:home.server";
+
+ const wrapper = mount( {
+ hook.search({
+ limit: 1,
+ query,
+ });
+ }} />);
+
+ await act(async () => {
+ await sleep(1);
+ wrapper.simulate("click");
+ return act(() => sleep(1));
+ });
+
+ expect(wrapper.text()).toContain(query);
+ });
+
+ it("should work with empty queries", async () => {
+ const wrapper = mount( {
+ hook.search({
+ limit: 1,
+ query: "",
+ });
+ }} />);
+
+ await act(async () => {
+ await sleep(1);
+ wrapper.simulate("click");
+ return act(() => sleep(1));
+ });
+
+ expect(wrapper.text()).toBe("");
+ });
+
+ it("should treat invalid mxids as empty queries", async () => {
+ const queries = [
+ "@user",
+ "user@home.server",
+ ];
+
+ for (const query of queries) {
+ const wrapper = mount( {
+ hook.search({
+ limit: 1,
+ query,
+ });
+ }} />);
+
+ await act(async () => {
+ await sleep(1);
+ wrapper.simulate("click");
+ return act(() => sleep(1));
+ });
+
+ expect(wrapper.text()).toBe("");
+ }
+ });
+
+ it("should recover from a server exception", async () => {
+ cli.getProfileInfo = () => { throw new Error("Oops"); };
+ const query = "@user:home.server";
+
+ const wrapper = mount( {
+ hook.search({
+ limit: 1,
+ query,
+ });
+ }} />);
+ await act(async () => {
+ await sleep(1);
+ wrapper.simulate("click");
+ return act(() => sleep(1));
+ });
+
+ expect(wrapper.text()).toBe("");
+ });
+
+ it("should be able to handle an empty result", async () => {
+ cli.getProfileInfo = () => null;
+ const query = "@user:home.server";
+
+ const wrapper = mount( {
+ hook.search({
+ limit: 1,
+ query,
+ });
+ }} />);
+ await act(async () => {
+ await sleep(1);
+ wrapper.simulate("click");
+ return act(() => sleep(1));
+ });
+
+ expect(wrapper.text()).toBe("");
+ });
+});
diff --git a/test/hooks/useUserDirectory-test.tsx b/test/hooks/useUserDirectory-test.tsx
index bcd2861dfee..44721e36435 100644
--- a/test/hooks/useUserDirectory-test.tsx
+++ b/test/hooks/useUserDirectory-test.tsx
@@ -96,7 +96,7 @@ describe("useUserDirectory", () => {
expect(wrapper.text()).toBe("ready: true, loading: false");
});
- it("should work with empty queries", async () => {
+ it("should recover from a server exception", async () => {
cli.searchUserDirectory = () => { throw new Error("Oops"); };
const query = "Bob";