Integrate searching public rooms and people into the new search experience (#8707)
* Implement searching for public rooms and users in new search experience * Implement loading indicator for spotlight results * Moved spotlight dialog into own subfolder * Extract search result avatar into separate component * Build generic new dropdown menu component * Build new network menu based on new network dropdown component * Switch roomdirectory to use new network dropdown * Replace old networkdropdown with new networkdropdown * Added component for public room result details * Extract hooks and subcomponents from SpotlightDialog * Create new hook to get profile info based for an mxid * Add hook to automatically re-request search results * Add hook to prevent out-of-order search results * Extract member sort algorithm from InviteDialog * Keep sorting for non-room results stable * Sort people suggestions using sort algorithm from InviteDialog * Add copy/copied tooltip for invite link option in spotlight * Clamp length of topic for public room results * Add unit test for useDebouncedSearch * Add unit test for useProfileInfo * Create cypress test cases for spotlight dialog * Add test for useLatestResult to prevent out-of-order results
This commit is contained in:
parent
37298d7b1b
commit
5096e7b992
38 changed files with 3520 additions and 1397 deletions
41
src/hooks/spotlight/useDebouncedCallback.ts
Normal file
41
src/hooks/spotlight/useDebouncedCallback.ts
Normal 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]);
|
||||
}
|
35
src/hooks/spotlight/useRecentSearches.ts
Normal file
35
src/hooks/spotlight/useRecentSearches.ts
Normal 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([]);
|
||||
}];
|
||||
};
|
35
src/hooks/useLatestResult.ts
Normal file
35
src/hooks/useLatestResult.ts
Normal 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];
|
||||
};
|
70
src/hooks/useProfileInfo.ts
Normal file
70
src/hooks/useProfileInfo.ts
Normal 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;
|
||||
};
|
|
@ -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;
|
||||
|
|
69
src/hooks/useSpaceResults.ts
Normal file
69
src/hooks/useSpaceResults.ts
Normal 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];
|
||||
};
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue