From 890ae1188cd0b7bb21cd1bf047769eed7e7d5e9d Mon Sep 17 00:00:00 2001 From: Badi Ifaoui Date: Tue, 16 Jan 2024 10:55:04 +0100 Subject: [PATCH] fix(#34): spotlight room name TG rooms icons (#35) * fix(#34): spotlight room name TG rooms icons * fix(#33): remove mint token button from sidebar --- components.json | 3 +- .../dialogs/spotlight/SpotlightDialog.tsx | 1302 +++++++++++++++++ src/components/views/elements/RoomName.tsx | 4 +- src/components/views/spaces/SpacePanel.tsx | 2 - 4 files changed, 1306 insertions(+), 5 deletions(-) create mode 100644 src/components/views/dialogs/spotlight/SpotlightDialog.tsx diff --git a/components.json b/components.json index b1b0c988c9..8290b6ac67 100644 --- a/components.json +++ b/components.json @@ -13,5 +13,6 @@ "src/autocomplete/Autocompleter.ts": "src/autocomplete/Autocompleter.ts", "src/components/views/dialogs/InviteDialog.tsx": "src/components/views/dialogs/InviteDialog.tsx", "src/components/views/right_panel/UserInfo.tsx": "src/components/views/right_panel/UserInfo.tsx", - "src/components/structures/HomePage.tsx": "src/components/structures/HomePage.tsx" + "src/components/structures/HomePage.tsx": "src/components/structures/HomePage.tsx", + "src/components/views/dialogs/spotlight/SpotlightDialog.tsx": "src/components/views/dialogs/spotlight/SpotlightDialog.tsx" } diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx new file mode 100644 index 0000000000..7e10f64a0c --- /dev/null +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -0,0 +1,1302 @@ +/* +Copyright 2021 - 2023 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 { 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 { normalize } from "matrix-js-sdk/src/utils"; +import React, { ChangeEvent, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import sanitizeHtml from "sanitize-html"; +import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts"; +import { + findSiblingElement, + RovingTabIndexContext, + RovingTabIndexProvider, + Type, +} from "matrix-react-sdk/src/accessibility/RovingTabIndex"; +import { mediaFromMxc } from "matrix-react-sdk/src/customisations/Media"; +import { Action } from "matrix-react-sdk/src/dispatcher/actions"; +import defaultDispatcher from "matrix-react-sdk/src/dispatcher/dispatcher"; +import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload"; +import { useDebouncedCallback } from "matrix-react-sdk/src/hooks/spotlight/useDebouncedCallback"; +import { useRecentSearches } from "matrix-react-sdk/src/hooks/spotlight/useRecentSearches"; +import { useProfileInfo } from "matrix-react-sdk/src/hooks/useProfileInfo"; +import { usePublicRoomDirectory } from "matrix-react-sdk/src/hooks/usePublicRoomDirectory"; +import { useSpaceResults } from "matrix-react-sdk/src/hooks/useSpaceResults"; +import { useUserDirectory } from "matrix-react-sdk/src/hooks/useUserDirectory"; +import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager"; +import { _t } from "matrix-react-sdk/src/languageHandler"; +import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg"; +import { PosthogAnalytics } from "matrix-react-sdk/src/PosthogAnalytics"; +import { getCachedRoomIDForAlias } from "matrix-react-sdk/src/RoomAliasCache"; +import { showStartChatInviteDialog } from "matrix-react-sdk/src/RoomInvite"; +import { SettingLevel } from "matrix-react-sdk/src/settings/SettingLevel"; +import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore"; +import { BreadcrumbsStore } from "matrix-react-sdk/src/stores/BreadcrumbsStore"; +import { RoomNotificationState } from "matrix-react-sdk/src/stores/notifications/RoomNotificationState"; +import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore"; +import { RecentAlgorithm } from "matrix-react-sdk/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm"; +import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext"; +import { getMetaSpaceName } from "matrix-react-sdk/src/stores/spaces"; +import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore"; +import { DirectoryMember, Member, startDmOnFirstMessage } from "matrix-react-sdk/src/utils/direct-messages"; +import DMRoomMap from "matrix-react-sdk/src/utils/DMRoomMap"; +import { makeUserPermalink } from "matrix-react-sdk/src/utils/permalinks/Permalinks"; +import { buildActivityScores, buildMemberScores, compareMembers } from "matrix-react-sdk/src/utils/SortMembers"; +import { copyPlaintext } from "matrix-react-sdk/src/utils/strings"; +import BaseAvatar from "matrix-react-sdk/src/components/views/avatars/BaseAvatar"; +import DecoratedRoomAvatar from "matrix-react-sdk/src/components/views/avatars/DecoratedRoomAvatar"; +import { SearchResultAvatar } from "matrix-react-sdk/src/components/views/avatars/SearchResultAvatar"; +import { NetworkDropdown } from "matrix-react-sdk/src/components/views/directory/NetworkDropdown"; +import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton"; +import Spinner from "matrix-react-sdk/src/components/views/elements/Spinner"; +import NotificationBadge from "matrix-react-sdk/src/components/views/rooms/NotificationBadge"; +import BaseDialog from "matrix-react-sdk/src/components/views/dialogs/BaseDialog"; +import { Option } from "matrix-react-sdk/src/components/views/dialogs/spotlight/Option"; +import { PublicRoomResultDetails } from "matrix-react-sdk/src/components/views/dialogs/spotlight/PublicRoomResultDetails"; +import { RoomResultContextMenus } from "matrix-react-sdk/src/components/views/dialogs/spotlight/RoomResultContextMenus"; +import { RoomContextDetails } from "matrix-react-sdk/src/components/views/rooms/RoomContextDetails"; +import { TooltipOption } from "matrix-react-sdk/src/components/views/dialogs/spotlight/TooltipOption"; +import { isLocalRoom } from "matrix-react-sdk/src/utils/localRoom/isLocalRoom"; +import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar"; +import { useFeatureEnabled } from "matrix-react-sdk/src/hooks/useSettings"; +import { filterBoolean } from "matrix-react-sdk/src/utils/arrays"; +import { transformSearchTerm } from "matrix-react-sdk/src/utils/SearchInput"; +import { Filter } from "matrix-react-sdk/src/components/views/dialogs/spotlight/Filter"; + +import RoomName from "../../elements/RoomName"; + +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 refIsForRecentlyViewed(ref?: RefObject): boolean { + return ref?.current?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true; +} + +function getRoomTypes(filter: Filter | null): Set { + const roomTypes = new Set(); + + 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({ + 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() === "join" || room.getMyMembership() == "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), + ).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 = ({ initialText = "", initialFilter = null, onFinished }) => { + const inputRef = useRef(null); + const scrollContainerRef = useRef(null); + const cli = MatrixClientPeg.safeGet(); + 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 msc3946ProcessDynamicPredecessor = useFeatureEnabled("feature_dynamic_room_predecessors"); + + const ownInviteLink = makeUserPermalink(cli.getUserId()!); + const [inviteLinkCopied, setInviteLinkCopied] = useState(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(() => { + 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()); + + function addUserResults(users: Array, 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: ( +
+ ), + name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome), + onClick(): void { + 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>(() => { + const results: Record = { + [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): void => { + const newQuery = transformSearchTerm(e.currentTarget.value); + _setQuery(newQuery); + }; + useEffect(() => { + setImmediate(() => { + const ref = rovingContext.state.refs[0]; + if (ref) { + 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 = ( + 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({ + 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 = ( +
+

+ {trimmedQuery + ? _t("spotlight_dialog|heading_with_query", { query }) + : _t("spotlight_dialog|heading_without_query")} +

+
+ {filter !== Filter.PublicSpaces && supportsSpaceFiltering && ( + + )} + {filter !== Filter.PublicRooms && ( + + )} + {filter !== Filter.People && ( + + )} +
+
+ ); + } + + 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 ( + + ); + } + if (isMemberResult(result)) { + return ( + + ); + } + 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() === "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 ( + + ); + } + + // IResult case + return ( + + ); + }; + + let peopleSection: JSX.Element | undefined; + if (results[Section.People].length) { + peopleSection = ( +
+

{_t("invite|recents_section")}

+
{results[Section.People].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ ); + } + + let suggestionsSection: JSX.Element | undefined; + if (results[Section.Suggestions].length && filter === Filter.People) { + suggestionsSection = ( +
+

{_t("common|suggestions")}

+
{results[Section.Suggestions].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ ); + } + + let roomsSection: JSX.Element | undefined; + if (results[Section.Rooms].length) { + roomsSection = ( +
+

{_t("common|rooms")}

+
{results[Section.Rooms].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ ); + } + + let spacesSection: JSX.Element | undefined; + if (results[Section.Spaces].length) { + spacesSection = ( +
+

{_t("spotlight_dialog|spaces_title")}

+
{results[Section.Spaces].slice(0, SECTION_LIMIT).map(resultMapper)}
+
+ ); + } + + let publicRoomsSection: JSX.Element | undefined; + if (filter === Filter.PublicRooms || filter === Filter.PublicSpaces) { + let content: JSX.Element | JSX.Element[]; + if (publicRoomsError) { + content = ( +
+ {filter === Filter.PublicRooms + ? _t("spotlight_dialog|failed_querying_public_rooms") + : _t("spotlight_dialog|failed_querying_public_spaces")} +
+ ); + } else { + content = results[Section.PublicRoomsAndSpaces].slice(0, SECTION_LIMIT).map(resultMapper); + } + + publicRoomsSection = ( +
+
+

{_t("common|suggestions")}

+
+ +
+
+
{content}
+
+ ); + } + + let spaceRoomsSection: JSX.Element | undefined; + if (spaceResults.length && activeSpace && filter === null) { + spaceRoomsSection = ( +
+

+ {_t("spotlight_dialog|other_rooms_in_space", { spaceName: activeSpace.name })} +

+
+ {spaceResults.slice(0, SECTION_LIMIT).map( + (room: HierarchyRoom): JSX.Element => ( + + ), + )} + {spaceResultsLoading && } +
+
+ ); + } + + let joinRoomSection: JSX.Element | undefined; + if ( + trimmedQuery.startsWith("#") && + trimmedQuery.includes(":") && + (!getCachedRoomIDForAlias(trimmedQuery) || !cli.getRoom(getCachedRoomIDForAlias(trimmedQuery))) + ) { + joinRoomSection = ( +
+
+ +
+
+ ); + } + + let hiddenResultsSection: JSX.Element | undefined; + if (filter === Filter.People) { + hiddenResultsSection = ( +
+

{_t("spotlight_dialog|result_may_be_hidden_privacy_warning")}

+
+ {_t("spotlight_dialog|cant_find_person_helpful_hint")} +
+ { + setInviteLinkCopied(true); + copyPlaintext(ownInviteLink); + }} + onHideTooltip={() => setInviteLinkCopied(false)} + title={inviteLinkCopied ? _t("common|copied") : _t("action|copy")} + > + + {_t("spotlight_dialog|copy_link_text")} + + +
+ ); + } else if (trimmedQuery && (filter === Filter.PublicRooms || filter === Filter.PublicSpaces)) { + hiddenResultsSection = ( +
+

{_t("spotlight_dialog|result_may_be_hidden_warning")}

+
+ {_t("spotlight_dialog|cant_find_room_helpful_hint")} +
+ +
+ ); + } + + let groupChatSection: JSX.Element | undefined; + if (filter === Filter.People) { + groupChatSection = ( +
+

{_t("spotlight_dialog|group_chat_section_title")}

+ +
+ ); + } + + let messageSearchSection: JSX.Element | undefined; + if (filter === null) { + messageSearchSection = ( +
+

+ {_t("spotlight_dialog|message_search_section_title")} +

+
+ {_t( + "spotlight_dialog|search_messages_hint", + {}, + { icon: () =>
}, + )} +
+
+ ); + } + + content = ( + <> + {peopleSection} + {suggestionsSection} + {roomsSection} + {spacesSection} + {spaceRoomsSection} + {publicRoomsSection} + {joinRoomSection} + {hiddenResultsSection} + {otherSearchesSection} + {groupChatSection} + {messageSearchSection} + + ); + } else { + let recentSearchesSection: JSX.Element | undefined; + if (recentSearches.length) { + recentSearchesSection = ( +
+

+ + {_t("spotlight_dialog|recent_searches_section_title")} + + + {_t("action|clear")} + +

+
+ {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 ( + + ); + })} +
+
+ ); + } + + content = ( + <> +
+

+ {_t("spotlight_dialog|recently_viewed_section_title")} +

+
+ {BreadcrumbsStore.instance.rooms + .filter((r) => r.roomId !== SdkContextClass.instance.roomViewStore.getRoomId()) + .map((room) => ( + { + viewRoom({ roomId: room.roomId }, false, ev.type !== "click"); + }} + > + + + + ))} +
+
+ + {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 ref: RefObject | 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.activeRef && 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 + (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.activeRef && + 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 + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1)); + } + break; + } + + if (ref) { + rovingContext.dispatch({ + type: Type.SetFocus, + payload: { ref }, + }); + ref.current?.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.activeRef?.current?.click(); + break; + } + }; + + const activeDescendant = rovingContext.state.activeRef?.current?.id; + + return ( + <> +
+ {_t( + "spotlight_dialog|keyboard_scroll_hint", + {}, + { + arrows: () => ( + <> + + + {!filter !== null && !query && } + {!filter !== null && !query && } + + ), + }, + )} +
+ + +
+ {filter !== null && ( +
+ {filterToLabel(filter)} + setFilter(null)} + /> +
+ )} + + {(publicRoomsLoading || peopleLoading || profileLoading) && } +
+ +
+ {content} +
+
+ + ); +}; + +const RovingSpotlightDialog: React.FC = (props) => { + return {() => }; +}; + +export default RovingSpotlightDialog; diff --git a/src/components/views/elements/RoomName.tsx b/src/components/views/elements/RoomName.tsx index 4736ac8416..f99812c3bf 100644 --- a/src/components/views/elements/RoomName.tsx +++ b/src/components/views/elements/RoomName.tsx @@ -58,9 +58,9 @@ export const RoomName = ({ room, children, maxLength }: IProps): JSX.Element => {isCommunityRoom && } {isTokenGatedRoom && } {truncatedRoomName} - {roomUsers?.length && !isTokenGatedRoom && !isCommunityRoom && ( + {(roomUsers?.length && !isTokenGatedRoom && !isCommunityRoom) ? ( - )} + ) : null} ), [truncatedRoomName, isCommunityRoom, isTokenGatedRoom, roomUsers], diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 8d0566a86d..ae04cc82fa 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -74,7 +74,6 @@ import React, { import { DragDropContext, Draggable, Droppable, DroppableProvidedProps } from "react-beautiful-dnd"; import SuperheroDexButton from "./SuperheroDexButton"; -import MintTokenButton from "./MintTokenButton"; const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { const invites = useEventEmitterState(SpaceStore.instance, UPDATE_INVITED_SPACES, () => { @@ -409,7 +408,6 @@ const SpacePanel: React.FC = () => { -