element-portable/src/components/views/dialogs/spotlight/SpotlightDialog.tsx
Michael Telatynski 01304439ee
Make tsc faster again (#28678)
* Stash initial work to bring TSC from over 6 mins to under 1 minute

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Stabilise types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Fix incorrect props to AccessibleButton

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Swap AccessibleButton element types to match the props they provide

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Changed my mind, remove spurious previously ignored props

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update snapshots

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-12-06 17:49:32 +00:00

1302 lines
57 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch";
import classNames from "classnames";
import { capitalize, sum } from "lodash";
import {
IPublicRoomsChunkRoom,
MatrixClient,
RoomMember,
RoomType,
Room,
HierarchyRoom,
JoinRule,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { normalize } from "matrix-js-sdk/src/utils";
import React, { ChangeEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import sanitizeHtml from "sanitize-html";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
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 { PosthogAnalytics } from "../../../../PosthogAnalytics";
import { getCachedRoomIDForAlias } from "../../../../RoomAliasCache";
import { showStartChatInviteDialog } from "../../../../RoomInvite";
import { SettingLevel } from "../../../../settings/SettingLevel";
import SettingsStore from "../../../../settings/SettingsStore";
import { BreadcrumbsStore } from "../../../../stores/BreadcrumbsStore";
import { RoomNotificationState } from "../../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore";
import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { SdkContextClass } from "../../../../contexts/SDKContext";
import { getMetaSpaceName } from "../../../../stores/spaces";
import SpaceStore from "../../../../stores/spaces/SpaceStore";
import { DirectoryMember, Member, startDmOnFirstMessage } 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 { NetworkDropdown } from "../../directory/NetworkDropdown";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import Spinner from "../../elements/Spinner";
import NotificationBadge from "../../rooms/NotificationBadge";
import BaseDialog from "../BaseDialog";
import { Option } from "./Option";
import { PublicRoomResultDetails } from "./PublicRoomResultDetails";
import { RoomResultContextMenus } from "./RoomResultContextMenus";
import { RoomContextDetails } from "../../rooms/RoomContextDetails";
import { TooltipOption } from "./TooltipOption";
import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom";
import RoomAvatar from "../../avatars/RoomAvatar";
import { useFeatureEnabled } from "../../../../hooks/useSettings";
import { filterBoolean } from "../../../../utils/arrays";
import { transformSearchTerm } from "../../../../utils/SearchInput";
import { Filter } from "./Filter";
const MAX_RECENT_SEARCHES = 10;
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
const AVATAR_SIZE = "24px";
interface IProps {
initialText?: string;
initialFilter?: Filter;
onFinished(): void;
}
function nodeIsForRecentlyViewed(node?: HTMLElement): boolean {
return node?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
}
function getRoomTypes(filter: Filter | null): Set<RoomType | null> {
const roomTypes = new Set<RoomType | null>();
if (filter === Filter.PublicRooms) roomTypes.add(null);
if (filter === Filter.PublicSpaces) roomTypes.add(RoomType.Space);
return roomTypes;
}
enum Section {
People,
Rooms,
Spaces,
Suggestions,
PublicRoomsAndSpaces,
}
function filterToLabel(filter: Filter): string {
switch (filter) {
case Filter.People:
return _t("common|people");
case Filter.PublicRooms:
return _t("spotlight_dialog|public_rooms_label");
case Filter.PublicSpaces:
return _t("spotlight_dialog|public_spaces_label");
}
}
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;
/**
* If the result is from a filtered server API then we set true here to avoid locally culling it in our own filters
*/
alreadyFiltered: boolean;
}
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.PublicRoomsAndSpaces,
filter: [Filter.PublicRooms, Filter.PublicSpaces],
query: filterBoolean([
publicRoom.room_id.toLowerCase(),
publicRoom.canonical_alias?.toLowerCase(),
publicRoom.name?.toLowerCase(),
sanitizeHtml(publicRoom.topic?.toLowerCase() ?? "", { allowedTags: [] }),
...(publicRoom.aliases?.map((it) => it.toLowerCase()) || []),
]),
});
const toRoomResult = (room: Room): IRoomResult => {
const myUserId = MatrixClientPeg.safeGet().getUserId();
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
if (otherUserId) {
const otherMembers = room.getMembers().filter((it) => it.userId !== myUserId);
const query = [
...otherMembers.map((it) => it.name.toLowerCase()),
...otherMembers.map((it) => it.userId.toLowerCase()),
].filter(Boolean);
return {
room,
section: Section.People,
filter: [Filter.People],
query,
};
} else if (room.isSpaceRoom()) {
return {
room,
section: Section.Spaces,
filter: [],
};
} else {
return {
room,
section: Section.Rooms,
filter: [],
};
}
};
const toMemberResult = (member: Member | RoomMember, alreadyFiltered: boolean): IMemberResult => ({
alreadyFiltered,
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 = window.setTimeout(() => {
PosthogAnalytics.instance.trackEvent<WebSearchEvent>({
eventName: "WebSearch",
viaSpotlight,
numResults,
queryLength,
});
}, 1000);
return () => {
clearTimeout(timeoutId);
};
}, [numResults, queryLength, viaSpotlight]);
};
const findVisibleRooms = (cli: MatrixClient, msc3946ProcessDynamicPredecessor: boolean): Room[] => {
return cli.getVisibleRooms(msc3946ProcessDynamicPredecessor).filter((room) => {
// Do not show local rooms
if (isLocalRoom(room)) return false;
// TODO we may want to put invites in their own list
return room.getMyMembership() === KnownMembership.Join || room.getMyMembership() == KnownMembership.Invite;
});
};
const findVisibleRoomMembers = (visibleRooms: Room[], cli: MatrixClient, filterDMs = true): RoomMember[] => {
return Object.values(
visibleRooms
.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<string, RoomMember>,
),
).filter((it) => it.userId !== cli.getUserId());
};
const roomAriaUnreadLabel = (room: Room, notification: RoomNotificationState): string | undefined => {
if (notification.hasMentions) {
return _t("a11y|n_unread_messages_mentions", {
count: notification.count,
});
} else if (notification.hasUnreadCount) {
return _t("a11y|n_unread_messages", {
count: notification.count,
});
} else if (notification.isUnread) {
return _t("a11y|unread_messages");
} else {
return undefined;
}
};
const canAskToJoin = (joinRule?: JoinRule): boolean => {
return SettingsStore.getValue("feature_ask_to_join") && JoinRule.Knock === joinRule;
};
interface IDirectoryOpts {
limit: number;
query: string;
}
const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = null, onFinished }) => {
const inputRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const cli = MatrixClientPeg.safeGet();
const rovingContext = useContext(RovingTabIndexContext);
const [query, _setQuery] = useState(initialText);
const [recentSearches, clearRecentSearches] = useRecentSearches();
const [filter, setFilterInternal] = useState<Filter | null>(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 msc3946ProcessDynamicPredecessor = useFeatureEnabled("feature_dynamic_room_predecessors");
const ownInviteLink = makeUserPermalink(cli.getUserId()!);
const [inviteLinkCopied, setInviteLinkCopied] = useState<boolean>(false);
const trimmedQuery = useMemo(() => query.trim(), [query]);
const [supportsSpaceFiltering, setSupportsSpaceFiltering] = useState(true); // assume it does until we find out it doesn't
useEffect(() => {
cli.isVersionSupported("v1.4")
.then((supported) => {
return supported || cli.doesServerSupportUnstableFeature("org.matrix.msc3827.stable");
})
.then((supported) => {
setSupportsSpaceFiltering(supported);
});
}, [cli]);
const {
loading: publicRoomsLoading,
publicRooms,
protocols,
config,
setConfig,
search: searchPublicRooms,
error: publicRoomsError,
} = usePublicRoomDirectory();
const { loading: peopleLoading, users: userDirectorySearchResults, search: searchPeople } = useUserDirectory();
const { loading: profileLoading, profile, search: searchProfileInfo } = useProfileInfo();
const searchParams: [IDirectoryOpts] = useMemo(
() => [
{
query: trimmedQuery,
roomTypes: getRoomTypes(filter),
limit: SECTION_LIMIT,
},
],
[trimmedQuery, filter],
);
useDebouncedCallback(
filter === Filter.PublicRooms || filter === Filter.PublicSpaces,
searchPublicRooms,
searchParams,
);
useDebouncedCallback(filter === Filter.People, searchPeople, searchParams);
useDebouncedCallback(filter === Filter.People, searchProfileInfo, searchParams);
const possibleResults = useMemo<Result[]>(() => {
const visibleRooms = findVisibleRooms(cli, msc3946ProcessDynamicPredecessor);
const roomResults = visibleRooms.map(toRoomResult);
const userResults: IMemberResult[] = [];
// If we already have a DM with the user we're looking for, we will show that DM instead of the user themselves
const alreadyAddedUserIds = roomResults.reduce((userIds, result) => {
const userId = DMRoomMap.shared().getUserIdForRoomId(result.room.roomId);
if (!userId) return userIds;
if (result.room.getJoinedMemberCount() > 2) return userIds;
userIds.set(userId, result);
return userIds;
}, new Map<string, IMemberResult | IRoomResult>());
function addUserResults(users: Array<Member | RoomMember>, alreadyFiltered: boolean): void {
for (const user of users) {
// Make sure we don't have any user more than once
if (alreadyAddedUserIds.has(user.userId)) {
const result = alreadyAddedUserIds.get(user.userId)!;
if (alreadyFiltered && isMemberResult(result) && !result.alreadyFiltered) {
// But if they were added as not yet filtered then mark them as already filtered to avoid
// culling this result based on local filtering.
result.alreadyFiltered = true;
}
continue;
}
const result = toMemberResult(user, alreadyFiltered);
alreadyAddedUserIds.set(user.userId, result);
userResults.push(result);
}
}
addUserResults(findVisibleRoomMembers(visibleRooms, cli), false);
addUserResults(userDirectorySearchResults, true);
if (profile) {
addUserResults([new DirectoryMember(profile)], true);
}
return [
...SpaceStore.instance.enabledMetaSpaces.map((spaceKey) => ({
section: Section.Spaces,
filter: [] as Filter[],
avatar: (
<div
className={classNames(
"mx_SpotlightDialog_metaspaceResult",
`mx_SpotlightDialog_metaspaceResult_${spaceKey}`,
)}
/>
),
name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome),
onClick() {
SpaceStore.instance.setActiveSpace(spaceKey);
},
})),
...roomResults,
...userResults,
...publicRooms.map(toPublicRoomResult),
].filter((result) => filter === null || result.filter.includes(filter));
}, [cli, userDirectorySearchResults, profile, publicRooms, filter, msc3946ProcessDynamicPredecessor]);
const results = useMemo<Record<Section, Result[]>>(() => {
const results: Record<Section, Result[]> = {
[Section.People]: [],
[Section.Rooms]: [],
[Section.Spaces]: [],
[Section.Suggestions]: [],
[Section.PublicRoomsAndSpaces]: [],
};
// Group results in their respective sections
if (trimmedQuery) {
const lcQuery = trimmedQuery.toLowerCase();
const normalizedQuery = normalize(trimmedQuery);
possibleResults.forEach((entry) => {
if (isRoomResult(entry)) {
// If the room is a DM with a user that is part of the user directory search results,
// we can assume the user is a relevant result, so include the DM with them too.
const userId = DMRoomMap.shared().getUserIdForRoomId(entry.room.roomId);
if (!userDirectorySearchResults.some((user) => user.userId === userId)) {
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.alreadyFiltered && !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 || filter === Filter.PublicSpaces) {
// 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.getSafeUserId();
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 0;
});
}
return results;
}, [trimmedQuery, filter, cli, possibleResults, userDirectorySearchResults, 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 ?? undefined, query);
const setQuery = (e: ChangeEvent<HTMLInputElement>): void => {
const newQuery = transformSearchTerm(e.currentTarget.value);
_setQuery(newQuery);
};
useEffect(() => {
setTimeout(() => {
const node = rovingContext.state.nodes[0];
if (node) {
rovingContext.dispatch({
type: Type.SetFocus,
payload: { node },
});
node?.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 = (
room: {
roomId: string;
roomAlias?: string;
autoJoin?: boolean;
shouldPeek?: boolean;
viaServers?: string[];
joinRule?: IPublicRoomsChunkRoom["join_rule"];
},
persist = false,
viaKeyboard = false,
): void => {
if (persist) {
const recents = new Set(SettingsStore.getValue("SpotlightSearch.recentSearches", null).reverse());
// remove & add the room to put it at the end
recents.delete(room.roomId);
recents.add(room.roomId);
SettingsStore.setValue(
"SpotlightSearch.recentSearches",
null,
SettingLevel.ACCOUNT,
Array.from(recents).reverse().slice(0, MAX_RECENT_SEARCHES),
);
}
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
metricsTrigger: "WebUnifiedSearch",
metricsViaKeyboard: viaKeyboard,
room_id: room.roomId,
room_alias: room.roomAlias,
auto_join: room.autoJoin && !canAskToJoin(room.joinRule),
should_peek: room.shouldPeek,
via_servers: room.viaServers,
});
if (canAskToJoin(room.joinRule)) {
defaultDispatcher.dispatch({ action: Action.PromptAskToJoin });
}
onFinished();
};
let otherSearchesSection: JSX.Element | undefined;
if (trimmedQuery || (filter !== Filter.PublicRooms && filter !== Filter.PublicSpaces)) {
otherSearchesSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches"
role="group"
aria-labelledby="mx_SpotlightDialog_section_otherSearches"
>
<h4 id="mx_SpotlightDialog_section_otherSearches">
{trimmedQuery
? _t("spotlight_dialog|heading_with_query", { query })
: _t("spotlight_dialog|heading_without_query")}
</h4>
<div>
{filter !== Filter.PublicSpaces && supportsSpaceFiltering && (
<Option
id="mx_SpotlightDialog_button_explorePublicSpaces"
className="mx_SpotlightDialog_explorePublicSpaces"
onClick={() => setFilter(Filter.PublicSpaces)}
>
{filterToLabel(Filter.PublicSpaces)}
</Option>
)}
{filter !== Filter.PublicRooms && (
<Option
id="mx_SpotlightDialog_button_explorePublicRooms"
className="mx_SpotlightDialog_explorePublicRooms"
onClick={() => setFilter(Filter.PublicRooms)}
>
{filterToLabel(Filter.PublicRooms)}
</Option>
)}
{filter !== Filter.People && (
<Option
id="mx_SpotlightDialog_button_startChat"
className="mx_SpotlightDialog_startChat"
onClick={() => setFilter(Filter.People)}
>
{filterToLabel(Filter.People)}
</Option>
)}
</div>
</div>
);
}
let content: JSX.Element;
if (trimmedQuery || filter !== null) {
const resultMapper = (result: Result): JSX.Element => {
if (isRoomResult(result)) {
const notification = RoomNotificationStateStore.instance.getRoomState(result.room);
const unreadLabel = roomAriaUnreadLabel(result.room, notification);
const ariaProperties = {
"aria-label": unreadLabel ? `${result.room.name} ${unreadLabel}` : result.room.name,
"aria-describedby": `mx_SpotlightDialog_button_result_${result.room.roomId}_details`,
};
return (
<Option
id={`mx_SpotlightDialog_button_result_${result.room.roomId}`}
key={`${Section[result.section]}-${result.room.roomId}`}
onClick={(ev) => {
viewRoom({ roomId: result.room.roomId }, true, ev?.type !== "click");
}}
endAdornment={<RoomResultContextMenus room={result.room} />}
{...ariaProperties}
>
<DecoratedRoomAvatar room={result.room} size={AVATAR_SIZE} tooltipProps={{ tabIndex: -1 }} />
{result.room.name}
<NotificationBadge notification={notification} />
<RoomContextDetails
id={`mx_SpotlightDialog_button_result_${result.room.roomId}_details`}
className="mx_SpotlightDialog_result_details"
room={result.room}
/>
</Option>
);
}
if (isMemberResult(result)) {
return (
<Option
id={`mx_SpotlightDialog_button_result_${result.member.userId}`}
key={`${Section[result.section]}-${result.member.userId}`}
onClick={() => {
startDmOnFirstMessage(cli, [result.member]);
onFinished();
}}
aria-label={
result.member instanceof RoomMember ? result.member.rawDisplayName : result.member.name
}
aria-describedby={`mx_SpotlightDialog_button_result_${result.member.userId}_details`}
>
<SearchResultAvatar user={result.member} size={AVATAR_SIZE} />
{result.member instanceof RoomMember ? result.member.rawDisplayName : result.member.name}
<div
id={`mx_SpotlightDialog_button_result_${result.member.userId}_details`}
className="mx_SpotlightDialog_result_details"
>
{result.member.userId}
</div>
</Option>
);
}
if (isPublicRoomResult(result)) {
const clientRoom = cli.getRoom(result.publicRoom.room_id);
const joinRule = result.publicRoom.join_rule;
// Element Web currently does not allow guests to join rooms, so we
// instead show them view buttons for all rooms. If the room is not
// world readable, a modal will appear asking you to register first. If
// it is readable, the preview appears as normal.
const showViewButton =
clientRoom?.getMyMembership() === KnownMembership.Join ||
(result.publicRoom.world_readable && !canAskToJoin(joinRule)) ||
cli.isGuest();
const listener = (ev: ButtonEvent): void => {
ev.stopPropagation();
const { publicRoom } = result;
viewRoom(
{
roomAlias: publicRoom.canonical_alias || publicRoom.aliases?.[0],
roomId: publicRoom.room_id,
autoJoin: !result.publicRoom.world_readable && !cli.isGuest(),
shouldPeek: result.publicRoom.world_readable || cli.isGuest(),
viaServers: config ? [config.roomServer] : undefined,
joinRule,
},
true,
ev.type !== "click",
);
};
let buttonLabel;
if (showViewButton) {
buttonLabel = _t("action|view");
} else {
buttonLabel = canAskToJoin(joinRule) ? _t("action|ask_to_join") : _t("action|join");
}
return (
<Option
id={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}`}
className="mx_SpotlightDialog_result_multiline"
key={`${Section[result.section]}-${result.publicRoom.room_id}`}
onClick={listener}
endAdornment={
<AccessibleButton
kind={showViewButton ? "primary_outline" : "primary"}
onClick={listener}
tabIndex={-1}
>
{buttonLabel}
</AccessibleButton>
}
aria-labelledby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_name`}
aria-describedby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`}
aria-details={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_details`}
>
<RoomAvatar
className="mx_SearchResultAvatar"
oobData={{
roomId: result.publicRoom.room_id,
name: result.publicRoom.name,
avatarUrl: result.publicRoom.avatar_url,
roomType: result.publicRoom.room_type,
}}
size={AVATAR_SIZE}
/>
<PublicRoomResultDetails
room={result.publicRoom}
labelId={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_name`}
descriptionId={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`}
detailsId={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_details`}
/>
</Option>
);
}
// IResult case
return (
<Option
id={`mx_SpotlightDialog_button_result_${result.name}`}
key={`${Section[result.section]}-${result.name}`}
onClick={result.onClick ?? null}
>
{result.avatar}
{result.name}
{result.description}
</Option>
);
};
let peopleSection: JSX.Element | undefined;
if (results[Section.People].length) {
peopleSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_people"
>
<h4 id="mx_SpotlightDialog_section_people">{_t("invite|recents_section")}</h4>
<div>{results[Section.People].slice(0, SECTION_LIMIT).map(resultMapper)}</div>
</div>
);
}
let suggestionsSection: JSX.Element | undefined;
if (results[Section.Suggestions].length && filter === Filter.People) {
suggestionsSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_suggestions"
>
<h4 id="mx_SpotlightDialog_section_suggestions">{_t("common|suggestions")}</h4>
<div>{results[Section.Suggestions].slice(0, SECTION_LIMIT).map(resultMapper)}</div>
</div>
);
}
let roomsSection: JSX.Element | undefined;
if (results[Section.Rooms].length) {
roomsSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_rooms"
>
<h4 id="mx_SpotlightDialog_section_rooms">{_t("common|rooms")}</h4>
<div>{results[Section.Rooms].slice(0, SECTION_LIMIT).map(resultMapper)}</div>
</div>
);
}
let spacesSection: JSX.Element | undefined;
if (results[Section.Spaces].length) {
spacesSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_spaces"
>
<h4 id="mx_SpotlightDialog_section_spaces">{_t("spotlight_dialog|spaces_title")}</h4>
<div>{results[Section.Spaces].slice(0, SECTION_LIMIT).map(resultMapper)}</div>
</div>
);
}
let publicRoomsSection: JSX.Element | undefined;
if (filter === Filter.PublicRooms || filter === Filter.PublicSpaces) {
let content: JSX.Element | JSX.Element[];
if (publicRoomsError) {
content = (
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
{filter === Filter.PublicRooms
? _t("spotlight_dialog|failed_querying_public_rooms")
: _t("spotlight_dialog|failed_querying_public_spaces")}
</div>
);
} else {
content = results[Section.PublicRoomsAndSpaces].slice(0, SECTION_LIMIT).map(resultMapper);
}
publicRoomsSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_publicRooms"
>
<div className="mx_SpotlightDialog_sectionHeader">
<h4 id="mx_SpotlightDialog_section_publicRooms">{_t("common|suggestions")}</h4>
<div className="mx_SpotlightDialog_options">
<NetworkDropdown protocols={protocols} config={config ?? null} setConfig={setConfig} />
</div>
</div>
<div>{content}</div>
</div>
);
}
let spaceRoomsSection: JSX.Element | undefined;
if (spaceResults.length && activeSpace && filter === null) {
spaceRoomsSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_results"
role="group"
aria-labelledby="mx_SpotlightDialog_section_spaceRooms"
>
<h4 id="mx_SpotlightDialog_section_spaceRooms">
{_t("spotlight_dialog|other_rooms_in_space", { spaceName: activeSpace.name })}
</h4>
<div>
{spaceResults.slice(0, SECTION_LIMIT).map(
(room: HierarchyRoom): JSX.Element => (
<Option
id={`mx_SpotlightDialog_button_result_${room.room_id}`}
key={room.room_id}
onClick={(ev) => {
viewRoom({ roomId: room.room_id }, true, ev?.type !== "click");
}}
>
<BaseAvatar
name={room.name}
idName={room.room_id}
url={
room.avatar_url
? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(
parseInt(AVATAR_SIZE, 10),
)
: null
}
size={AVATAR_SIZE}
/>
{room.name || room.canonical_alias}
{room.name && room.canonical_alias && (
<div className="mx_SpotlightDialog_result_details">{room.canonical_alias}</div>
)}
</Option>
),
)}
{spaceResultsLoading && <Spinner />}
</div>
</div>
);
}
let joinRoomSection: JSX.Element | undefined;
if (
trimmedQuery.startsWith("#") &&
trimmedQuery.includes(":") &&
(!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery)))
) {
joinRoomSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
<div>
<Option
id="mx_SpotlightDialog_button_joinRoomAlias"
className="mx_SpotlightDialog_joinRoomAlias"
onClick={(ev) => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: trimmedQuery,
auto_join: true,
metricsTrigger: "WebUnifiedSearch",
metricsViaKeyboard: ev?.type !== "click",
});
onFinished();
}}
>
{_t("spotlight_dialog|join_button_text", {
roomAddress: trimmedQuery,
})}
</Option>
</div>
</div>
);
}
let hiddenResultsSection: JSX.Element | undefined;
if (filter === Filter.People) {
hiddenResultsSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_hiddenResults" role="group">
<h4>{_t("spotlight_dialog|result_may_be_hidden_privacy_warning")}</h4>
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
{_t("spotlight_dialog|cant_find_person_helpful_hint")}
</div>
<TooltipOption
id="mx_SpotlightDialog_button_inviteLink"
className="mx_SpotlightDialog_inviteLink"
onClick={() => {
setInviteLinkCopied(true);
copyPlaintext(ownInviteLink);
}}
onTooltipOpenChange={(open) => {
if (!open) setInviteLinkCopied(false);
}}
title={inviteLinkCopied ? _t("common|copied") : _t("action|copy")}
>
<span className="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline">
{_t("spotlight_dialog|copy_link_text")}
</span>
</TooltipOption>
</div>
);
} else if (trimmedQuery && (filter === Filter.PublicRooms || filter === Filter.PublicSpaces)) {
hiddenResultsSection = (
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_hiddenResults" role="group">
<h4>{_t("spotlight_dialog|result_may_be_hidden_warning")}</h4>
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
{_t("spotlight_dialog|cant_find_room_helpful_hint")}
</div>
<Option
id="mx_SpotlightDialog_button_createNewRoom"
className="mx_SpotlightDialog_createRoom"
onClick={() =>
defaultDispatcher.dispatch({
action: "view_create_room",
public: true,
defaultName: capitalize(trimmedQuery),
})
}
>
<span className="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline">
{_t("spotlight_dialog|create_new_room_button")}
</span>
</Option>
</div>
);
}
let groupChatSection: JSX.Element | undefined;
if (filter === Filter.People) {
groupChatSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches"
role="group"
aria-labelledby="mx_SpotlightDialog_section_groupChat"
>
<h4 id="mx_SpotlightDialog_section_groupChat">{_t("spotlight_dialog|group_chat_section_title")}</h4>
<Option
id="mx_SpotlightDialog_button_startGroupChat"
className="mx_SpotlightDialog_startGroupChat"
onClick={() => showStartChatInviteDialog(trimmedQuery)}
>
{_t("spotlight_dialog|start_group_chat_button")}
</Option>
</div>
);
}
let messageSearchSection: JSX.Element | undefined;
if (filter === null) {
messageSearchSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches"
role="group"
aria-labelledby="mx_SpotlightDialog_section_messageSearch"
>
<h4 id="mx_SpotlightDialog_section_messageSearch">
{_t("spotlight_dialog|message_search_section_title")}
</h4>
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
{_t(
"spotlight_dialog|search_messages_hint",
{},
{ icon: () => <div className="mx_SpotlightDialog_otherSearches_messageSearchIcon" /> },
)}
</div>
</div>
);
}
content = (
<>
{peopleSection}
{suggestionsSection}
{roomsSection}
{spacesSection}
{spaceRoomsSection}
{publicRoomsSection}
{joinRoomSection}
{hiddenResultsSection}
{otherSearchesSection}
{groupChatSection}
{messageSearchSection}
</>
);
} else {
let recentSearchesSection: JSX.Element | undefined;
if (recentSearches.length) {
recentSearchesSection = (
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_recentSearches"
role="group"
// Firefox sometimes makes this element focusable due to overflow,
// so force it out of tab order by default.
tabIndex={-1}
aria-labelledby="mx_SpotlightDialog_section_recentSearches"
>
<h4>
<span id="mx_SpotlightDialog_section_recentSearches">
{_t("spotlight_dialog|recent_searches_section_title")}
</span>
<AccessibleButton kind="link" onClick={clearRecentSearches}>
{_t("action|clear")}
</AccessibleButton>
</h4>
<div>
{recentSearches.map((room) => {
const notification = RoomNotificationStateStore.instance.getRoomState(room);
const unreadLabel = roomAriaUnreadLabel(room, notification);
const ariaProperties = {
"aria-label": unreadLabel ? `${room.name} ${unreadLabel}` : room.name,
"aria-describedby": `mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`,
};
return (
<Option
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}`}
key={room.roomId}
onClick={(ev) => {
viewRoom({ roomId: room.roomId }, true, ev?.type !== "click");
}}
endAdornment={<RoomResultContextMenus room={room} />}
{...ariaProperties}
>
<DecoratedRoomAvatar
room={room}
size={AVATAR_SIZE}
tooltipProps={{ tabIndex: -1 }}
/>
{room.name}
<NotificationBadge notification={notification} />
<RoomContextDetails
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}_details`}
className="mx_SpotlightDialog_result_details"
room={room}
/>
</Option>
);
})}
</div>
</div>
);
}
content = (
<>
<div
className="mx_SpotlightDialog_section mx_SpotlightDialog_recentlyViewed"
role="group"
aria-labelledby="mx_SpotlightDialog_section_recentlyViewed"
>
<h4 id="mx_SpotlightDialog_section_recentlyViewed">
{_t("spotlight_dialog|recently_viewed_section_title")}
</h4>
<div>
{BreadcrumbsStore.instance.rooms
.filter((r) => r.roomId !== SdkContextClass.instance.roomViewStore.getRoomId())
.map((room) => (
<TooltipOption
id={`mx_SpotlightDialog_button_recentlyViewed_${room.roomId}`}
title={room.name}
key={room.roomId}
onClick={(ev) => {
viewRoom({ roomId: room.roomId }, false, ev.type !== "click");
}}
>
<DecoratedRoomAvatar room={room} size="32px" tooltipProps={{ tabIndex: -1 }} />
{room.name}
</TooltipOption>
))}
</div>
</div>
{recentSearchesSection}
{otherSearchesSection}
</>
);
}
const onDialogKeyDown = (ev: KeyboardEvent | React.KeyboardEvent): void => {
const navigationAction = getKeyBindingsManager().getNavigationAction(ev);
switch (navigationAction) {
case KeyBindingAction.FilterRooms:
ev.stopPropagation();
ev.preventDefault();
onFinished();
break;
}
let node: HTMLElement | undefined;
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
switch (accessibilityAction) {
case KeyBindingAction.Escape:
ev.stopPropagation();
ev.preventDefault();
onFinished();
break;
case KeyBindingAction.ArrowUp:
case KeyBindingAction.ArrowDown:
ev.stopPropagation();
ev.preventDefault();
if (rovingContext.state.activeNode && rovingContext.state.nodes.length > 0) {
let nodes = rovingContext.state.nodes;
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 = nodeIsForRecentlyViewed(rovingContext.state.activeNode)
? rovingContext.state.activeNode
: nodes.find(nodeIsForRecentlyViewed);
// exclude all other recently viewed items from the list so up/down arrows skip them
nodes = nodes.filter((ref) => ref === keptRecentlyViewedRef || !nodeIsForRecentlyViewed(ref));
}
const idx = nodes.indexOf(rovingContext.state.activeNode);
node = findSiblingElement(nodes, idx + (accessibilityAction === 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.activeNode &&
rovingContext.state.nodes.length > 0 &&
nodeIsForRecentlyViewed(rovingContext.state.activeNode)
) {
// we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
ev.stopPropagation();
ev.preventDefault();
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
const idx = nodes.indexOf(rovingContext.state.activeNode);
node = findSiblingElement(
nodes,
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
);
}
break;
}
if (node) {
rovingContext.dispatch({
type: Type.SetFocus,
payload: { node },
});
node?.scrollIntoView({
block: "nearest",
});
}
};
const onKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Backspace:
if (!query && filter !== null) {
ev.stopPropagation();
ev.preventDefault();
setFilter(null);
}
break;
case KeyBindingAction.Enter:
ev.stopPropagation();
ev.preventDefault();
rovingContext.state.activeNode?.click();
break;
}
};
const activeDescendant = rovingContext.state.activeNode?.id;
return (
<>
<div id="mx_SpotlightDialog_keyboardPrompt">
{_t(
"spotlight_dialog|keyboard_scroll_hint",
{},
{
arrows: () => (
<>
<kbd></kbd>
<kbd></kbd>
{!filter !== null && !query && <kbd></kbd>}
{!filter !== null && !query && <kbd></kbd>}
</>
),
},
)}
</div>
<BaseDialog
className="mx_SpotlightDialog"
onFinished={onFinished}
hasCancel={false}
onKeyDown={onDialogKeyDown}
screenName="UnifiedSearch"
aria-label={_t("spotlight_dialog|search_dialog")}
>
<div className="mx_SpotlightDialog_searchBox mx_textinput">
{filter !== null && (
<div
className={classNames("mx_SpotlightDialog_filter", {
mx_SpotlightDialog_filterPeople: filter === Filter.People,
mx_SpotlightDialog_filterPublicRooms: filter === Filter.PublicRooms,
mx_SpotlightDialog_filterPublicSpaces: filter === Filter.PublicSpaces,
})}
>
<span>{filterToLabel(filter)}</span>
<AccessibleButton
tabIndex={-1}
title={_t("spotlight_dialog|remove_filter", {
filter: filterToLabel(filter),
})}
className="mx_SpotlightDialog_filter--close"
onClick={() => setFilter(null)}
/>
</div>
)}
<input
ref={inputRef}
autoFocus
type="text"
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
spellCheck="false"
placeholder={_t("action|search")}
value={query}
onChange={setQuery}
onKeyDown={onKeyDown}
aria-owns="mx_SpotlightDialog_content"
aria-activedescendant={activeDescendant}
aria-label={_t("action|search")}
aria-describedby="mx_SpotlightDialog_keyboardPrompt"
/>
{(publicRoomsLoading || peopleLoading || profileLoading) && <Spinner w={24} h={24} />}
</div>
<div
ref={scrollContainerRef}
id="mx_SpotlightDialog_content"
role="listbox"
aria-activedescendant={activeDescendant}
aria-describedby="mx_SpotlightDialog_keyboardPrompt"
>
{content}
</div>
</BaseDialog>
</>
);
};
const RovingSpotlightDialog: React.FC<IProps> = (props) => {
return <RovingTabIndexProvider>{() => <SpotlightDialog {...props} />}</RovingTabIndexProvider>;
};
export default RovingSpotlightDialog;