diff --git a/res/css/views/dialogs/_SpotlightDialog.scss b/res/css/views/dialogs/_SpotlightDialog.scss index 9f78d905b52..b2d7f92be60 100644 --- a/res/css/views/dialogs/_SpotlightDialog.scss +++ b/res/css/views/dialogs/_SpotlightDialog.scss @@ -61,6 +61,69 @@ limitations under the License. padding: 12px 16px; border-bottom: 1px solid $system; + > .mx_SpotlightDialog_filter { + display: flex; + align-content: center; + align-items: center; + border-radius: 8px; + margin-right: 8px; + background-color: $quinary-content; + vertical-align: middle; + color: $primary-content; + position: relative; + padding: 4px 8px 4px 37px; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 18px; + height: 18px; + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + } + + &.mx_SpotlightDialog_filterPeople::before { + mask-image: url('$(res)/img/element-icons/room/members.svg'); + } + + &.mx_SpotlightDialog_filterPublicRooms::before { + mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); + } + + .mx_SpotlightDialog_filter--close { + position: relative; + display: inline-block; + width: 16px; + height: 16px; + background: #F4F6FA; + border-radius: 8px; + margin-left: 8px; + text-align: center; + line-height: 16px; + color: $secondary-content; + + &::before { + background-color: $secondary-content; + content: ""; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + width: 8px; + height: 8px; + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + mask-image: url("$(res)/img/cancel-small.svg"); + } + } + } + > input { display: block; box-sizing: border-box; @@ -81,7 +144,7 @@ limitations under the License. overflow-y: auto; .mx_SpotlightDialog_section { - > h4 { + > h4, > .mx_SpotlightDialog_sectionHeader > h4 { font-weight: $font-semi-bold; font-size: $font-12px; line-height: $font-15px; @@ -90,6 +153,11 @@ limitations under the License. margin-bottom: 8px; } + .mx_SpotlightDialog_sectionHeader { + display: flex; + justify-content: space-between; + } + & + .mx_SpotlightDialog_section { margin-top: 24px; } @@ -103,7 +171,7 @@ limitations under the License. margin-right: 1px; // occlude the 1px visible of the very next tile to prevent it looking broken } - .mx_AccessibleButton { + .mx_SpotlightDialog_option { border-radius: 8px; padding: 4px; color: $primary-content; @@ -122,7 +190,7 @@ limitations under the License. margin: 0 9px 4px; // maintain centering } - & + .mx_AccessibleButton { + & + .mx_SpotlightDialog_option { margin-left: 16px; } @@ -134,8 +202,9 @@ limitations under the License. .mx_SpotlightDialog_results, .mx_SpotlightDialog_recentSearches, - .mx_SpotlightDialog_otherSearches { - .mx_AccessibleButton { + .mx_SpotlightDialog_otherSearches, + .mx_SpotlightDialog_hiddenResults { + .mx_SpotlightDialog_option { padding: 6px 4px; border-radius: 8px; font-size: $font-15px; @@ -148,6 +217,20 @@ limitations under the License. text-overflow: ellipsis; overflow: hidden; + &.mx_SpotlightDialog_result_multiline { + align-items: start; + + .mx_AccessibleButton { + padding: 4px 20px; + margin: 2px 4px; + } + + .mx_SpotlightDialog_enterPrompt { + margin-top: 9px; + margin-right: 8px; + } + } + > .mx_SpotlightDialog_metaspaceResult, > .mx_DecoratedRoomAvatar, > .mx_BaseAvatar { @@ -161,6 +244,34 @@ limitations under the License. } } + .mx_SpotlightDialog_result_publicRoomDetails { + display: flex; + flex-direction: column; + flex-grow: 1; + min-width: 0; + + .mx_SpotlightDialog_result_publicRoomHeader { + display: flex; + flex-direction: row; + + .mx_SpotlightDialog_result_publicRoomName { + color: $primary-content; + font-size: $font-15px; + } + .mx_SpotlightDialog_result_publicRoomAlias { + color: $tertiary-content; + font-size: $font-12px; + margin-left: 8px; + } + } + .mx_SpotlightDialog_result_publicRoomDescription { + word-wrap: break-word; + white-space: break-spaces; + color: $secondary-content; + font-size: $font-12px; + } + } + .mx_NotificationBadge { margin-left: 8px; } @@ -175,10 +286,39 @@ limitations under the License. } } + .mx_SpotlightDialog_inviteLink .mx_AccessibleButton, + .mx_SpotlightDialog_createRoom .mx_AccessibleButton { + position: relative; + margin: 0; + padding: 3px 8px 3px 28px; + + &::before { + content: ""; + display: block; + position: absolute; + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + left: 8px; + width: 16px; + height: 16px; + background: $accent; + } + } + + .mx_SpotlightDialog_inviteLink .mx_AccessibleButton::before { + mask-image: url("$(res)/img/element-icons/link.svg"); + } + + .mx_SpotlightDialog_createRoom .mx_AccessibleButton::before { + mask-image: url("$(res)/img/element-icons/roomlist/hash.svg"); + } + .mx_SpotlightDialog_otherSearches { .mx_SpotlightDialog_startChat, .mx_SpotlightDialog_joinRoomAlias, - .mx_SpotlightDialog_explorePublicRooms { + .mx_SpotlightDialog_explorePublicRooms, + .mx_SpotlightDialog_startGroupChat { padding-left: 32px; position: relative; @@ -209,6 +349,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/roomlist/explore.svg'); } + .mx_SpotlightDialog_startGroupChat::before { + mask-image: url('$(res)/img/element-icons/group-members.svg'); + } + .mx_SpotlightDialog_otherSearches_messageSearchText { font-size: $font-15px; line-height: $font-24px; diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index ae602462199..cdec2eb89d9 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -14,8 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ +import classNames from "classnames"; +import { sum } from "lodash"; import { WebSearch as WebSearchEvent } from "matrix-analytics-events/types/typescript/WebSearch"; import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; +import { IPublicRoomsChunkRoom, MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix"; import { Room } from "matrix-js-sdk/src/models/room"; import { normalize } from "matrix-js-sdk/src/utils"; import React, { ChangeEvent, KeyboardEvent, RefObject, useContext, useEffect, useMemo, useState } from "react"; @@ -29,6 +32,7 @@ import { } from "../../../../accessibility/RovingTabIndex"; import { mediaFromMxc } from "../../../../customisations/Media"; import { Action } from "../../../../dispatcher/actions"; +import dis from "../../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../../dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../../dispatcher/payloads/ViewRoomPayload"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; @@ -47,22 +51,31 @@ import { RecentAlgorithm } from "../../../../stores/room-list/algorithms/tag-sor import { RoomViewStore } from "../../../../stores/RoomViewStore"; import { getMetaSpaceName } from "../../../../stores/spaces"; import SpaceStore from "../../../../stores/spaces/SpaceStore"; +import { Member, startDm } from "../../../../utils/direct-messages"; import DMRoomMap from "../../../../utils/DMRoomMap"; +import { makeUserPermalink } from "../../../../utils/permalinks/Permalinks"; +import { copyPlaintext } from "../../../../utils/strings"; import BaseAvatar from "../../avatars/BaseAvatar"; import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar"; +import { SearchResultAvatar } from "../../avatars/SearchResultAvatar"; import { BetaPill } from "../../beta/BetaCard"; +import { NewNetworkDropdown } from "../../directory/NewNetworkDropdown"; import AccessibleButton from "../../elements/AccessibleButton"; import Spinner from "../../elements/Spinner"; import NotificationBadge from "../../rooms/NotificationBadge"; -import BaseDialog from ".././BaseDialog"; -import BetaFeedbackDialog from ".././BetaFeedbackDialog"; +import BaseDialog from "../BaseDialog"; +import BetaFeedbackDialog from "../BetaFeedbackDialog"; import { IDialogProps } from "../IDialogProps"; import { UserTab } from "../UserTab"; import { Option } from "./Option"; +import { PublicRoomResultDetails } from "./PublicRoomResultDetails"; import { RoomResultDetails } from "./RoomResultDetails"; import { TooltipOption } from "./TooltipOption"; +import { useProfileInfoResults } from "./useProfileInfoResults"; +import { usePublicRoomResults } from "./usePublicRoomResults"; import { useRecentSearches } from "./useRecentSearches"; import { useSpaceResults } from "./useSpaceResults"; +import { useUserResults } from "./useUserResults"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -80,17 +93,33 @@ enum Section { People, Rooms, Spaces, + Suggestions, + PublicRooms, +} + +enum Filter { + People, + PublicRooms, } interface IBaseResult { section: Section; + filter: Filter[]; query?: string[]; // extra fields to query match, stored as lowercase } +interface IPublicRoomResult extends IBaseResult { + publicRoom: IPublicRoomsChunkRoom; +} + interface IRoomResult extends IBaseResult { room: Room; } +interface IMemberResult extends IBaseResult { + member: Member | RoomMember; +} + interface IResult extends IBaseResult { avatar: JSX.Element; name: string; @@ -98,9 +127,61 @@ interface IResult extends IBaseResult { onClick?(): void; } -type Result = IRoomResult | IResult; +type Result = IRoomResult | IPublicRoomResult | IMemberResult | IResult; const isRoomResult = (result: any): result is IRoomResult => !!result?.room; +const isPublicRoomResult = (result: any): result is IPublicRoomResult => !!result?.publicRoom; +const isMemberResult = (result: any): result is IMemberResult => !!result?.member; + +const toPublicRoomResult = (publicRoom: IPublicRoomsChunkRoom): IPublicRoomResult => ({ + publicRoom, + section: Section.PublicRooms, + filter: [Filter.PublicRooms], + query: [ + publicRoom.room_id.toLowerCase(), + publicRoom.canonical_alias?.toLowerCase(), + publicRoom.name?.toLowerCase(), + publicRoom.topic?.toLowerCase(), + ...(publicRoom.aliases?.map(it => it.toLowerCase()) || []), + ].filter(Boolean), +}); + +const toRoomResult = (room: Room): IRoomResult => { + const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); + if (otherUserId) { + return { + room, + section: Section.People, + filter: [Filter.People], + query: [ + otherUserId.toLowerCase(), + room.getMember(otherUserId)?.name.toLowerCase(), + ].filter(Boolean), + }; + } else if (room.isSpaceRoom()) { + return { + room, + section: Section.Spaces, + filter: [], + }; + } else { + return { + room, + section: Section.Rooms, + filter: [], + }; + } +}; + +const toMemberResult = (member: Member | RoomMember): IMemberResult => ({ + member, + section: Section.Suggestions, + filter: [Filter.People], + query: [ + member.userId.toLowerCase(), + member.name.toLowerCase(), + ].filter(Boolean), +}); const recentAlgorithm = new RecentAlgorithm(); @@ -124,56 +205,83 @@ export const useWebSearchMetrics = (numResults: number, queryLength: number, via }, [numResults, queryLength, viaSpotlight]); }; +const findVisibleRooms = (cli: MatrixClient) => { + return cli.getVisibleRooms().filter(room => { + // TODO we may want to put invites in their own list + return room.getMyMembership() === "join" || room.getMyMembership() == "invite"; + }); +}; + +const findVisibleRoomMembers = (cli: MatrixClient, filterDMs = true) => { + return Object.values( + findVisibleRooms(cli) + .filter(room => !filterDMs || !DMRoomMap.shared().getUserIdForRoomId(room.roomId)) + .reduce((members, room) => { + for (const member of room.getJoinedMembers()) { + members[member.userId] = member; + } + return members; + }, {} as Record), + ).filter(it => it.userId !== cli.getUserId()); +}; + const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => { const cli = MatrixClientPeg.get(); const rovingContext = useContext(RovingTabIndexContext); const [query, _setQuery] = useState(initialText); const [recentSearches, clearRecentSearches] = useRecentSearches(); + const [filter, setFilter] = useState(null); - const possibleResults = useMemo(() => [ - ...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({ - section: Section.Spaces, - avatar: ( -
- ), - name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome), - onClick() { - SpaceStore.instance.setActiveSpace(spaceKey); - }, - })), - ...cli.getVisibleRooms().filter(room => { - // TODO we may want to put invites in their own list - return room.getMyMembership() === "join" || room.getMyMembership() == "invite"; - }).map(room => { - let section: Section; - let query: string[]; - - const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - if (otherUserId) { - section = Section.People; - query = [ - otherUserId.toLowerCase(), - room.getMember(otherUserId)?.name.toLowerCase(), - ].filter(Boolean); - } else if (room.isSpaceRoom()) { - section = Section.Spaces; - } else { - section = Section.Rooms; - } + const ownInviteLink = makeUserPermalink(MatrixClientPeg.get().getUserId()); + const trimmedQuery = query.trim(); - return { room, section, query }; - }), - ], [cli]); + const { results: publicRoomResults, protocols, config, setConfig } = + usePublicRoomResults(filter === Filter.PublicRooms, trimmedQuery, SECTION_LIMIT); + const { results: userResults } = + useUserResults(filter === Filter.People, trimmedQuery, SECTION_LIMIT); + const { results: profileInfoResults } = + useProfileInfoResults(filter === Filter.People, trimmedQuery); + const possibleResults = useMemo( + () => { + const roomMembers = findVisibleRoomMembers(cli); + const roomMemberIds = new Set(roomMembers.map(item => item.userId)); + return [ + ...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({ + section: Section.Spaces, + filter: [], + avatar:
, + name: getMetaSpaceName(spaceKey, SpaceStore.instance.allRoomsInHome), + onClick() { + SpaceStore.instance.setActiveSpace(spaceKey); + }, + })), + ...findVisibleRooms(cli).map(toRoomResult), + ...roomMembers.map(toMemberResult), + ...userResults.filter(item => !roomMemberIds.has(item.userId)).map(toMemberResult), + ...profileInfoResults.map(toMemberResult), + ...publicRoomResults.map(toPublicRoomResult), + ].filter(result => filter === null || result.filter.includes(filter)); + }, + [cli, userResults, profileInfoResults, publicRoomResults, filter], + ); + + const results = useMemo>(() => { + const results: Record = { + [Section.People]: [], + [Section.Rooms]: [], + [Section.Spaces]: [], + [Section.Suggestions]: [], + [Section.PublicRooms]: [], + }; - const trimmedQuery = query.trim(); - const [people, rooms, spaces] = useMemo<[Result[], Result[], Result[]] | []>(() => { - if (!trimmedQuery) return []; + if (!trimmedQuery) return results; const lcQuery = trimmedQuery.toLowerCase(); const normalizedQuery = normalize(trimmedQuery); - const results: [Result[], Result[], Result[]] = [[], [], []]; - // Group results in their respective sections possibleResults.forEach(entry => { if (isRoomResult(entry)) { @@ -181,6 +289,10 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => !entry.room.getCanonicalAlias()?.toLowerCase().includes(lcQuery) && !entry.query?.some(q => q.includes(lcQuery)) ) return; // bail, does not match query + } else if (isMemberResult(entry)) { + if (!entry.query?.some(q => q.includes(lcQuery))) return; // bail, does not match query + } else if (isPublicRoomResult(entry)) { + if (!entry.query?.some(q => q.includes(lcQuery))) return; // bail, does not match query } else { if (!entry.name.toLowerCase().includes(lcQuery) && !entry.query?.some(q => q.includes(lcQuery)) @@ -193,7 +305,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => // Sort results by most recent activity const myUserId = cli.getUserId(); - for (const resultArray of results) { + for (const resultArray of Object.values(results)) { resultArray.sort((a: Result, b: Result) => { // This is not a room result, it should appear at the bottom of // the list @@ -210,7 +322,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => return results; }, [possibleResults, trimmedQuery, cli]); - const numResults = trimmedQuery ? people.length + rooms.length + spaces.length : 0; + const numResults = trimmedQuery ? sum(Object.values(results).map(it => it.length)) : 0; useWebSearchMetrics(numResults, query.length, true); const activeSpace = SpaceStore.instance.activeSpaceRoom; @@ -259,14 +371,47 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => onFinished(); }; + let otherSearchesSection: JSX.Element; + if (trimmedQuery || filter !== Filter.PublicRooms) { + otherSearchesSection = ( +
+

+ { trimmedQuery + ? _t('Use "%(query)s" to search', { query }) + : _t("Other searches") } +

+
+ { (filter !== Filter.PublicRooms) && ( + + ) } + { (trimmedQuery && filter !== Filter.People) && ( + + ) } +
+
+ ); + } + let content: JSX.Element; - if (trimmedQuery) { + if (trimmedQuery || filter === Filter.PublicRooms) { const resultMapper = (result: Result): JSX.Element => { if (isRoomResult(result)) { return ( ); } + if (isMemberResult(result)) { + return ( + + ); + } + if (isPublicRoomResult(result)) { + const clientRoom = cli.getRoom(result.publicRoom.room_id); + const listener = (ev) => { + viewRoom(result.publicRoom.room_id, true, ev.type !== "click"); + }; + return ( + + ); + } // IResult case return (