Integrate searching public rooms and people into the new search experience (#8707)

* Implement searching for public rooms and users in new search experience
* Implement loading indicator for spotlight results
* Moved spotlight dialog into own subfolder
* Extract search result avatar into separate component
* Build generic new dropdown menu component
* Build new network menu based on new network dropdown component
* Switch roomdirectory to use new network dropdown
* Replace old networkdropdown with new networkdropdown
* Added component for public room result details
* Extract hooks and subcomponents from SpotlightDialog
* Create new hook to get profile info based for an mxid
* Add hook to automatically re-request search results
* Add hook to prevent out-of-order search results
* Extract member sort algorithm from InviteDialog
* Keep sorting for non-room results stable
* Sort people suggestions using sort algorithm from InviteDialog
* Add copy/copied tooltip for invite link option in spotlight
* Clamp length of topic for public room results
* Add unit test for useDebouncedSearch
* Add unit test for useProfileInfo
* Create cypress test cases for spotlight dialog
* Add test for useLatestResult to prevent out-of-order results
This commit is contained in:
Janne Mareike Koschinski 2022-06-15 16:14:05 +02:00 committed by GitHub
parent 37298d7b1b
commit 5096e7b992
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 3520 additions and 1397 deletions

View file

@ -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<T extends any[]>(
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]);
}

View file

@ -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([]);
}];
};

View file

@ -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 = <T, R>(onResultChanged: (result: R) => void):
[(query: T | null) => void, (query: T | null, result: R) => void] => {
const ref = useRef<T | null>(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];
};

View file

@ -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<IProfileInfo | null>(null);
const [loading, setLoading] = useState(false);
const [updateQuery, updateResult] = useLatestResult<string, IProfileInfo | null>(setProfile);
const search = useCallback(async ({ query: term }: IProfileInfoOpts): Promise<boolean> => {
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;
};

View file

@ -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<IPublicRoomsChunkRoom[]>([]);
const [roomServer, setRoomServer] = useState<string | null | undefined>(undefined);
const [instanceId, setInstanceId] = useState<string | null | undefined>(undefined);
const [config, setConfigInternal] = useState<IPublicRoomDirectoryConfig | null | undefined>(undefined);
const [protocols, setProtocols] = useState<Protocols | null>(null);
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(false);
const [updateQuery, updateResult] = useLatestResult<IRoomDirectoryOptions, IPublicRoomsChunkRoom[]>(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<boolean> => {
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;

View file

@ -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<IHierarchyRoom[]>([]);
const [hierarchy, setHierarchy] = useState<RoomHierarchy>();
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];
};

View file

@ -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<boolean> => {
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,