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
|
@ -28,6 +28,7 @@ import DMRoomMap from "../../../utils/DMRoomMap";
|
|||
import SdkConfig from "../../../SdkConfig";
|
||||
import * as Email from "../../../email";
|
||||
import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from "../../../utils/IdentityServerUtils";
|
||||
import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers";
|
||||
import { abbreviateUrl } from "../../../utils/UrlUtils";
|
||||
import IdentityAuthClient from "../../../IdentityAuthClient";
|
||||
import { humanizeTime } from "../../../utils/humanize";
|
||||
|
@ -43,8 +44,9 @@ import SettingsStore from "../../../settings/SettingsStore";
|
|||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import BaseAvatar from '../avatars/BaseAvatar';
|
||||
import { SearchResultAvatar } from "../avatars/SearchResultAvatar";
|
||||
import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton';
|
||||
import { compare, selectText } from '../../../utils/strings';
|
||||
import { selectText } from '../../../utils/strings';
|
||||
import Field from '../elements/Field';
|
||||
import TabbedView, { Tab, TabLocation } from '../../structures/TabbedView';
|
||||
import Dialpad from '../voip/DialPad';
|
||||
|
@ -91,22 +93,7 @@ class DMUserTile extends React.PureComponent<IDMUserTileProps> {
|
|||
|
||||
render() {
|
||||
const avatarSize = 20;
|
||||
const avatar = (this.props.member as ThreepidMember).isEmail
|
||||
? <img
|
||||
className='mx_InviteDialog_userTile_avatar mx_InviteDialog_userTile_threepidAvatar'
|
||||
src={require("../../../../res/img/icon-email-pill-avatar.svg").default}
|
||||
width={avatarSize}
|
||||
height={avatarSize}
|
||||
/>
|
||||
: <BaseAvatar
|
||||
className='mx_InviteDialog_userTile_avatar'
|
||||
url={this.props.member.getMxcAvatarUrl()
|
||||
? mediaFromMxc(this.props.member.getMxcAvatarUrl()).getSquareThumbnailHttp(avatarSize)
|
||||
: null}
|
||||
name={this.props.member.name}
|
||||
idName={this.props.member.userId}
|
||||
width={avatarSize}
|
||||
height={avatarSize} />;
|
||||
const avatar = <SearchResultAvatar user={this.props.member} size={avatarSize} />;
|
||||
|
||||
let closeButton;
|
||||
if (this.props.onRemove) {
|
||||
|
@ -422,121 +409,15 @@ export default class InviteDialog extends React.PureComponent<IInviteDialogProps
|
|||
}
|
||||
|
||||
private buildSuggestions(excludedTargetIds: Set<string>): {userId: string, user: RoomMember}[] {
|
||||
const maxConsideredMembers = 200;
|
||||
const joinedRooms = MatrixClientPeg.get().getRooms()
|
||||
.filter(r => r.getMyMembership() === 'join' && r.getJoinedMemberCount() <= maxConsideredMembers);
|
||||
const cli = MatrixClientPeg.get();
|
||||
const activityScores = buildActivityScores(cli);
|
||||
const memberScores = buildMemberScores(cli);
|
||||
const memberComparator = compareMembers(activityScores, memberScores);
|
||||
|
||||
// Generates { userId: {member, rooms[]} }
|
||||
const memberRooms = joinedRooms.reduce((members, room) => {
|
||||
// Filter out DMs (we'll handle these in the recents section)
|
||||
if (DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
|
||||
return members; // Do nothing
|
||||
}
|
||||
|
||||
const joinedMembers = room.getJoinedMembers().filter(u => !excludedTargetIds.has(u.userId));
|
||||
for (const member of joinedMembers) {
|
||||
// Filter out user IDs that are already in the room / should be excluded
|
||||
if (excludedTargetIds.has(member.userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!members[member.userId]) {
|
||||
members[member.userId] = {
|
||||
member: member,
|
||||
// Track the room size of the 'picked' member so we can use the profile of
|
||||
// the smallest room (likely a DM).
|
||||
pickedMemberRoomSize: room.getJoinedMemberCount(),
|
||||
rooms: [],
|
||||
};
|
||||
}
|
||||
|
||||
members[member.userId].rooms.push(room);
|
||||
|
||||
if (room.getJoinedMemberCount() < members[member.userId].pickedMemberRoomSize) {
|
||||
members[member.userId].member = member;
|
||||
members[member.userId].pickedMemberRoomSize = room.getJoinedMemberCount();
|
||||
}
|
||||
}
|
||||
return members;
|
||||
}, {});
|
||||
|
||||
// Generates { userId: {member, numRooms, score} }
|
||||
const memberScores = Object.values(memberRooms).reduce((scores, entry: {member: RoomMember, rooms: Room[]}) => {
|
||||
const numMembersTotal = entry.rooms.reduce((c, r) => c + r.getJoinedMemberCount(), 0);
|
||||
const maxRange = maxConsideredMembers * entry.rooms.length;
|
||||
scores[entry.member.userId] = {
|
||||
member: entry.member,
|
||||
numRooms: entry.rooms.length,
|
||||
score: Math.max(0, Math.pow(1 - (numMembersTotal / maxRange), 5)),
|
||||
};
|
||||
return scores;
|
||||
}, {});
|
||||
|
||||
// Now that we have scores for being in rooms, boost those people who have sent messages
|
||||
// recently, as a way to improve the quality of suggestions. We do this by checking every
|
||||
// room to see who has sent a message in the last few hours, and giving them a score
|
||||
// which correlates to the freshness of their message. In theory, this results in suggestions
|
||||
// which are closer to "continue this conversation" rather than "this person exists".
|
||||
const trueJoinedRooms = MatrixClientPeg.get().getRooms().filter(r => r.getMyMembership() === 'join');
|
||||
const now = (new Date()).getTime();
|
||||
const earliestAgeConsidered = now - (60 * 60 * 1000); // 1 hour ago
|
||||
const maxMessagesConsidered = 50; // so we don't iterate over a huge amount of traffic
|
||||
const lastSpoke = {}; // userId: timestamp
|
||||
const lastSpokeMembers = {}; // userId: room member
|
||||
for (const room of trueJoinedRooms) {
|
||||
// Skip low priority rooms and DMs
|
||||
const isDm = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
if (Object.keys(room.tags).includes("m.lowpriority") || isDm) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
|
||||
for (let i = events.length - 1; i >= Math.max(0, events.length - maxMessagesConsidered); i--) {
|
||||
const ev = events[i];
|
||||
if (excludedTargetIds.has(ev.getSender())) {
|
||||
continue;
|
||||
}
|
||||
if (ev.getTs() <= earliestAgeConsidered) {
|
||||
break; // give up: all events from here on out are too old
|
||||
}
|
||||
|
||||
if (!lastSpoke[ev.getSender()] || lastSpoke[ev.getSender()] < ev.getTs()) {
|
||||
lastSpoke[ev.getSender()] = ev.getTs();
|
||||
lastSpokeMembers[ev.getSender()] = room.getMember(ev.getSender());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const userId in lastSpoke) {
|
||||
const ts = lastSpoke[userId];
|
||||
const member = lastSpokeMembers[userId];
|
||||
if (!member) continue; // skip people we somehow don't have profiles for
|
||||
|
||||
// Scores from being in a room give a 'good' score of about 1.0-1.5, so for our
|
||||
// boost we'll try and award at least +1.0 for making the list, with +4.0 being
|
||||
// an approximate maximum for being selected.
|
||||
const distanceFromNow = Math.abs(now - ts); // abs to account for slight future messages
|
||||
const inverseTime = (now - earliestAgeConsidered) - distanceFromNow;
|
||||
const scoreBoost = Math.max(1, inverseTime / (15 * 60 * 1000)); // 15min segments to keep scores sane
|
||||
|
||||
let record = memberScores[userId];
|
||||
if (!record) record = memberScores[userId] = { score: 0 };
|
||||
record.member = member;
|
||||
record.score += scoreBoost;
|
||||
}
|
||||
|
||||
const members = Object.values(memberScores);
|
||||
members.sort((a, b) => {
|
||||
if (a.score === b.score) {
|
||||
if (a.numRooms === b.numRooms) {
|
||||
return compare(a.member.userId, b.member.userId);
|
||||
}
|
||||
|
||||
return b.numRooms - a.numRooms;
|
||||
}
|
||||
return b.score - a.score;
|
||||
});
|
||||
|
||||
return members.map(m => ({ userId: m.member.userId, user: m.member }));
|
||||
return Object.values(memberScores).map(({ member }) => member)
|
||||
.filter(member => !excludedTargetIds.has(member.userId))
|
||||
.sort(memberComparator)
|
||||
.map(member => ({ userId: member.userId, user: member }));
|
||||
}
|
||||
|
||||
private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean {
|
||||
|
|
|
@ -1,786 +0,0 @@
|
|||
/*
|
||||
Copyright 2021 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 React, {
|
||||
ChangeEvent,
|
||||
ComponentProps,
|
||||
KeyboardEvent,
|
||||
RefObject,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import { normalize } from "matrix-js-sdk/src/utils";
|
||||
import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces";
|
||||
import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy";
|
||||
import { RoomType } from "matrix-js-sdk/src/@types/event";
|
||||
import { WebSearch as WebSearchEvent } from "@matrix-org/analytics-events/types/typescript/WebSearch";
|
||||
|
||||
import { IDialogProps } from "./IDialogProps";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import BaseDialog from "./BaseDialog";
|
||||
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import {
|
||||
findSiblingElement,
|
||||
RovingAccessibleButton,
|
||||
RovingAccessibleTooltipButton,
|
||||
RovingTabIndexContext,
|
||||
RovingTabIndexProvider,
|
||||
Type,
|
||||
useRovingTabIndex,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { mediaFromMxc } from "../../../customisations/Media";
|
||||
import BaseAvatar from "../avatars/BaseAvatar";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import Modal from "../../../Modal";
|
||||
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
|
||||
import { RoomViewStore } from "../../../stores/RoomViewStore";
|
||||
import { showStartChatInviteDialog } from "../../../RoomInvite";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import NotificationBadge from "../rooms/NotificationBadge";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { BetaPill } from "../beta/BetaCard";
|
||||
import { UserTab } from "./UserTab";
|
||||
import BetaFeedbackDialog from "./BetaFeedbackDialog";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { getMetaSpaceName } from "../../../stores/spaces";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
|
||||
import { PosthogAnalytics } from "../../../PosthogAnalytics";
|
||||
import { getCachedRoomIDForAlias } from "../../../RoomAliasCache";
|
||||
import { roomContextDetailsText, spaceContextDetailsText } from "../../../utils/i18n-helpers";
|
||||
import { RecentAlgorithm } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
|
||||
|
||||
const MAX_RECENT_SEARCHES = 10;
|
||||
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
|
||||
const AVATAR_SIZE = 24;
|
||||
|
||||
const Option: React.FC<ComponentProps<typeof RovingAccessibleButton>> = ({ inputRef, children, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleButton
|
||||
{...props}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
>
|
||||
{ children }
|
||||
<div className="mx_SpotlightDialog_enterPrompt" aria-hidden>↵</div>
|
||||
</AccessibleButton>;
|
||||
};
|
||||
|
||||
const TooltipOption: React.FC<ComponentProps<typeof RovingAccessibleTooltipButton>> = ({ inputRef, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleTooltipButton
|
||||
{...props}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
/>;
|
||||
};
|
||||
|
||||
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([]);
|
||||
}];
|
||||
};
|
||||
|
||||
const ResultDetails = ({ room }: { room: Room }) => {
|
||||
const contextDetails = room.isSpaceRoom() ? spaceContextDetailsText(room) : roomContextDetailsText(room);
|
||||
if (contextDetails) {
|
||||
return <div className="mx_SpotlightDialog_result_details">
|
||||
{ contextDetails }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
interface IProps extends IDialogProps {
|
||||
initialText?: string;
|
||||
}
|
||||
|
||||
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];
|
||||
};
|
||||
|
||||
function refIsForRecentlyViewed(ref: RefObject<HTMLElement>): boolean {
|
||||
return ref.current?.id.startsWith("mx_SpotlightDialog_button_recentlyViewed_");
|
||||
}
|
||||
|
||||
enum Section {
|
||||
People,
|
||||
Rooms,
|
||||
Spaces,
|
||||
}
|
||||
|
||||
interface IBaseResult {
|
||||
section: Section;
|
||||
query?: string[]; // extra fields to query match, stored as lowercase
|
||||
}
|
||||
|
||||
interface IRoomResult extends IBaseResult {
|
||||
room: Room;
|
||||
}
|
||||
|
||||
interface IResult extends IBaseResult {
|
||||
avatar: JSX.Element;
|
||||
name: string;
|
||||
description?: string;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
type Result = IRoomResult | IResult;
|
||||
|
||||
const isRoomResult = (result: any): result is IRoomResult => !!result?.room;
|
||||
|
||||
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 = setTimeout(() => {
|
||||
PosthogAnalytics.instance.trackEvent<WebSearchEvent>({
|
||||
eventName: "WebSearch",
|
||||
viaSpotlight,
|
||||
numResults,
|
||||
queryLength,
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [numResults, queryLength, viaSpotlight]);
|
||||
};
|
||||
|
||||
const SpotlightDialog: React.FC<IProps> = ({ initialText = "", onFinished }) => {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const rovingContext = useContext(RovingTabIndexContext);
|
||||
const [query, _setQuery] = useState(initialText);
|
||||
const [recentSearches, clearRecentSearches] = useRecentSearches();
|
||||
|
||||
const possibleResults = useMemo<Result[]>(() => [
|
||||
...SpaceStore.instance.enabledMetaSpaces.map(spaceKey => ({
|
||||
section: Section.Spaces,
|
||||
avatar: (
|
||||
<div className={`mx_SpotlightDialog_metaspaceResult mx_SpotlightDialog_metaspaceResult_${spaceKey}`} />
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
return { room, section, query };
|
||||
}),
|
||||
], [cli]);
|
||||
|
||||
const trimmedQuery = query.trim();
|
||||
const [people, rooms, spaces] = useMemo<[Result[], Result[], Result[]] | []>(() => {
|
||||
if (!trimmedQuery) return [];
|
||||
|
||||
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)) {
|
||||
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 (!entry.name.toLowerCase().includes(lcQuery) &&
|
||||
!entry.query?.some(q => q.includes(lcQuery))
|
||||
) return; // bail, does not match query
|
||||
}
|
||||
|
||||
results[entry.section].push(entry);
|
||||
});
|
||||
|
||||
// Sort results by most recent activity
|
||||
|
||||
const myUserId = cli.getUserId();
|
||||
for (const resultArray of results) {
|
||||
resultArray.sort((a: Result, b: Result) => {
|
||||
// This is not a room result, it should appear at the bottom of
|
||||
// the list
|
||||
if (!(a as IRoomResult).room) return 1;
|
||||
if (!(b as IRoomResult).room) return -1;
|
||||
|
||||
const roomA = (a as IRoomResult).room;
|
||||
const roomB = (b as IRoomResult).room;
|
||||
|
||||
return recentAlgorithm.getLastTs(roomB, myUserId) - recentAlgorithm.getLastTs(roomA, myUserId);
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [possibleResults, trimmedQuery, cli]);
|
||||
|
||||
const numResults = trimmedQuery ? people.length + rooms.length + spaces.length : 0;
|
||||
useWebSearchMetrics(numResults, query.length, true);
|
||||
|
||||
const activeSpace = SpaceStore.instance.activeSpaceRoom;
|
||||
const [spaceResults, spaceResultsLoading] = useSpaceResults(activeSpace, query);
|
||||
|
||||
const setQuery = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const newQuery = e.currentTarget.value;
|
||||
_setQuery(newQuery);
|
||||
|
||||
setImmediate(() => {
|
||||
// reset the activeRef when we change query for best usability
|
||||
const ref = rovingContext.state.refs[0];
|
||||
if (ref) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
});
|
||||
ref.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const viewRoom = (roomId: string, persist = false, viaKeyboard = false) => {
|
||||
if (persist) {
|
||||
const recents = new Set(SettingsStore.getValue("SpotlightSearch.recentSearches", null).reverse());
|
||||
// remove & add the room to put it at the end
|
||||
recents.delete(roomId);
|
||||
recents.add(roomId);
|
||||
|
||||
SettingsStore.setValue(
|
||||
"SpotlightSearch.recentSearches",
|
||||
null,
|
||||
SettingLevel.ACCOUNT,
|
||||
Array.from(recents).reverse().slice(0, MAX_RECENT_SEARCHES),
|
||||
);
|
||||
}
|
||||
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: roomId,
|
||||
metricsTrigger: "WebUnifiedSearch",
|
||||
metricsViaKeyboard: viaKeyboard,
|
||||
});
|
||||
onFinished();
|
||||
};
|
||||
|
||||
let content: JSX.Element;
|
||||
if (trimmedQuery) {
|
||||
const resultMapper = (result: Result): JSX.Element => {
|
||||
if (isRoomResult(result)) {
|
||||
return (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_result_${result.room.roomId}`}
|
||||
key={result.room.roomId}
|
||||
onClick={(ev) => {
|
||||
viewRoom(result.room.roomId, true, ev.type !== "click");
|
||||
}}
|
||||
>
|
||||
<DecoratedRoomAvatar room={result.room} avatarSize={AVATAR_SIZE} tooltipProps={{ tabIndex: -1 }} />
|
||||
{ result.room.name }
|
||||
<NotificationBadge notification={RoomNotificationStateStore.instance.getRoomState(result.room)} />
|
||||
<ResultDetails room={result.room} />
|
||||
</Option>
|
||||
);
|
||||
}
|
||||
|
||||
// IResult case
|
||||
return (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_result_${result.name}`}
|
||||
key={result.name}
|
||||
onClick={result.onClick}
|
||||
>
|
||||
{ result.avatar }
|
||||
{ result.name }
|
||||
{ result.description }
|
||||
</Option>
|
||||
);
|
||||
};
|
||||
|
||||
let peopleSection: JSX.Element;
|
||||
if (people.length) {
|
||||
peopleSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
|
||||
<h4>{ _t("People") }</h4>
|
||||
<div>
|
||||
{ people.slice(0, SECTION_LIMIT).map(resultMapper) }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let roomsSection: JSX.Element;
|
||||
if (rooms.length) {
|
||||
roomsSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
|
||||
<h4>{ _t("Rooms") }</h4>
|
||||
<div>
|
||||
{ rooms.slice(0, SECTION_LIMIT).map(resultMapper) }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let spacesSection: JSX.Element;
|
||||
if (spaces.length) {
|
||||
spacesSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
|
||||
<h4>{ _t("Spaces you're in") }</h4>
|
||||
<div>
|
||||
{ spaces.slice(0, SECTION_LIMIT).map(resultMapper) }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
let spaceRoomsSection: JSX.Element;
|
||||
if (spaceResults.length) {
|
||||
spaceRoomsSection = <div className="mx_SpotlightDialog_section mx_SpotlightDialog_results" role="group">
|
||||
<h4>{ _t("Other rooms in %(spaceName)s", { spaceName: activeSpace.name }) }</h4>
|
||||
<div>
|
||||
{ spaceResults.slice(0, SECTION_LIMIT).map((room: IHierarchyRoom): JSX.Element => (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_result_${room.room_id}`}
|
||||
key={room.room_id}
|
||||
onClick={(ev) => {
|
||||
viewRoom(room.room_id, true, ev.type !== "click");
|
||||
}}
|
||||
>
|
||||
<BaseAvatar
|
||||
name={room.name}
|
||||
idName={room.room_id}
|
||||
url={room.avatar_url
|
||||
? mediaFromMxc(room.avatar_url).getSquareThumbnailHttp(AVATAR_SIZE)
|
||||
: null
|
||||
}
|
||||
width={AVATAR_SIZE}
|
||||
height={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;
|
||||
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("Join %(roomAddress)s", {
|
||||
roomAddress: trimmedQuery,
|
||||
}) }
|
||||
</Option>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
content = <>
|
||||
{ peopleSection }
|
||||
{ roomsSection }
|
||||
{ spacesSection }
|
||||
{ spaceRoomsSection }
|
||||
{ joinRoomSection }
|
||||
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
|
||||
<h4>{ _t('Use "%(query)s" to search', { query }) }</h4>
|
||||
<div>
|
||||
<Option
|
||||
id="mx_SpotlightDialog_button_explorePublicRooms"
|
||||
className="mx_SpotlightDialog_explorePublicRooms"
|
||||
onClick={() => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewRoomDirectory,
|
||||
initialText: query,
|
||||
});
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Public rooms") }
|
||||
</Option>
|
||||
<Option
|
||||
id="mx_SpotlightDialog_button_startChat"
|
||||
className="mx_SpotlightDialog_startChat"
|
||||
onClick={() => {
|
||||
showStartChatInviteDialog(query);
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("People") }
|
||||
</Option>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
|
||||
<h4>{ _t("Other searches") }</h4>
|
||||
<div className="mx_SpotlightDialog_otherSearches_messageSearchText">
|
||||
{ _t("To search messages, look for this icon at the top of a room <icon/>", {}, {
|
||||
icon: () => <div className="mx_SpotlightDialog_otherSearches_messageSearchIcon" />,
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
} else {
|
||||
let recentSearchesSection: JSX.Element;
|
||||
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}
|
||||
>
|
||||
<h4>
|
||||
{ _t("Recent searches") }
|
||||
<AccessibleButton kind="link" onClick={clearRecentSearches}>
|
||||
{ _t("Clear") }
|
||||
</AccessibleButton>
|
||||
</h4>
|
||||
<div>
|
||||
{ recentSearches.map(room => (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_recentSearch_${room.roomId}`}
|
||||
key={room.roomId}
|
||||
onClick={(ev) => {
|
||||
viewRoom(room.roomId, true, ev.type !== "click");
|
||||
}}
|
||||
>
|
||||
<DecoratedRoomAvatar room={room} avatarSize={AVATAR_SIZE} tooltipProps={{ tabIndex: -1 }} />
|
||||
{ room.name }
|
||||
<NotificationBadge notification={RoomNotificationStateStore.instance.getRoomState(room)} />
|
||||
<ResultDetails room={room} />
|
||||
</Option>
|
||||
)) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
content = <>
|
||||
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_recentlyViewed" role="group">
|
||||
<h4>{ _t("Recently viewed") }</h4>
|
||||
<div>
|
||||
{ BreadcrumbsStore.instance.rooms
|
||||
.filter(r => r.roomId !== RoomViewStore.instance.getRoomId())
|
||||
.map(room => (
|
||||
<TooltipOption
|
||||
id={`mx_SpotlightDialog_button_recentlyViewed_${room.roomId}`}
|
||||
title={room.name}
|
||||
key={room.roomId}
|
||||
onClick={(ev) => {
|
||||
viewRoom(room.roomId, false, ev.type !== "click");
|
||||
}}
|
||||
>
|
||||
<DecoratedRoomAvatar room={room} avatarSize={32} tooltipProps={{ tabIndex: -1 }} />
|
||||
{ room.name }
|
||||
</TooltipOption>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{ recentSearchesSection }
|
||||
|
||||
<div className="mx_SpotlightDialog_section mx_SpotlightDialog_otherSearches" role="group">
|
||||
<h4>{ _t("Other searches") }</h4>
|
||||
<div>
|
||||
<Option
|
||||
id="mx_SpotlightDialog_button_explorePublicRooms"
|
||||
className="mx_SpotlightDialog_explorePublicRooms"
|
||||
onClick={() => {
|
||||
defaultDispatcher.fire(Action.ViewRoomDirectory);
|
||||
onFinished();
|
||||
}}
|
||||
>
|
||||
{ _t("Explore public rooms") }
|
||||
</Option>
|
||||
</div>
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
|
||||
const onDialogKeyDown = (ev: KeyboardEvent) => {
|
||||
const navigationAction = getKeyBindingsManager().getNavigationAction(ev);
|
||||
switch (navigationAction) {
|
||||
case KeyBindingAction.FilterRooms:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
break;
|
||||
}
|
||||
|
||||
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
switch (accessibilityAction) {
|
||||
case KeyBindingAction.Escape:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
onFinished();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (ev: KeyboardEvent) => {
|
||||
let ref: RefObject<HTMLElement>;
|
||||
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
|
||||
switch (action) {
|
||||
case KeyBindingAction.ArrowUp:
|
||||
case KeyBindingAction.ArrowDown:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
|
||||
if (rovingContext.state.refs.length > 0) {
|
||||
let refs = rovingContext.state.refs;
|
||||
if (!query) {
|
||||
// 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 + (action === 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 &&
|
||||
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 + (action === KeyBindingAction.ArrowLeft ? -1 : 1));
|
||||
}
|
||||
break;
|
||||
case KeyBindingAction.Enter:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
rovingContext.state.activeRef?.current?.click();
|
||||
break;
|
||||
}
|
||||
|
||||
if (ref) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
});
|
||||
ref.current?.scrollIntoView({
|
||||
block: "nearest",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const openFeedback = SdkConfig.get().bug_report_endpoint_url ? () => {
|
||||
Modal.createDialog(BetaFeedbackDialog, {
|
||||
featureId: "feature_spotlight",
|
||||
});
|
||||
} : null;
|
||||
|
||||
const activeDescendant = rovingContext.state.activeRef?.current?.id;
|
||||
|
||||
return <>
|
||||
<div id="mx_SpotlightDialog_keyboardPrompt">
|
||||
{ _t("Use <arrows/> to scroll", {}, {
|
||||
arrows: () => <>
|
||||
<div>↓</div>
|
||||
<div>↑</div>
|
||||
{ !query && <div>←</div> }
|
||||
{ !query && <div>→</div> }
|
||||
</>,
|
||||
}) }
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
className="mx_SpotlightDialog"
|
||||
onFinished={onFinished}
|
||||
hasCancel={false}
|
||||
onKeyDown={onDialogKeyDown}
|
||||
screenName="UnifiedSearch"
|
||||
aria-label={_t("Search Dialog")}
|
||||
>
|
||||
<div className="mx_SpotlightDialog_searchBox mx_textinput">
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder={_t("Search")}
|
||||
value={query}
|
||||
onChange={setQuery}
|
||||
onKeyDown={onKeyDown}
|
||||
aria-owns="mx_SpotlightDialog_content"
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-label={_t("Search")}
|
||||
aria-describedby="mx_SpotlightDialog_keyboardPrompt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="mx_SpotlightDialog_content"
|
||||
role="listbox"
|
||||
aria-activedescendant={activeDescendant}
|
||||
aria-describedby="mx_SpotlightDialog_keyboardPrompt"
|
||||
>
|
||||
{ content }
|
||||
</div>
|
||||
|
||||
<div className="mx_SpotlightDialog_footer">
|
||||
<BetaPill onClick={() => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: Action.ViewUserSettings,
|
||||
initialTabId: UserTab.Labs,
|
||||
});
|
||||
onFinished();
|
||||
}} />
|
||||
{ openFeedback && _t("Results not as expected? Please <a>give feedback</a>.", {}, {
|
||||
a: sub => <AccessibleButton kind="link_inline" onClick={openFeedback}>
|
||||
{ sub }
|
||||
</AccessibleButton>,
|
||||
}) }
|
||||
{ openFeedback && <AccessibleButton
|
||||
kind="primary_outline"
|
||||
onClick={openFeedback}
|
||||
>
|
||||
{ _t("Feedback") }
|
||||
</AccessibleButton> }
|
||||
</div>
|
||||
</BaseDialog>
|
||||
</>;
|
||||
};
|
||||
|
||||
const RovingSpotlightDialog: React.FC<IProps> = (props) => {
|
||||
return <RovingTabIndexProvider>
|
||||
{ () => <SpotlightDialog {...props} /> }
|
||||
</RovingTabIndexProvider>;
|
||||
};
|
||||
|
||||
export default RovingSpotlightDialog;
|
43
src/components/views/dialogs/spotlight/Option.tsx
Normal file
43
src/components/views/dialogs/spotlight/Option.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { RovingAccessibleButton } from "../../../../accessibility/roving/RovingAccessibleButton";
|
||||
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
||||
import AccessibleButton from "../../elements/AccessibleButton";
|
||||
|
||||
interface OptionProps extends ComponentProps<typeof RovingAccessibleButton> {
|
||||
endAdornment?: ReactNode;
|
||||
}
|
||||
|
||||
export const Option: React.FC<OptionProps> = ({ inputRef, children, endAdornment, className, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleButton
|
||||
{...props}
|
||||
className={classNames(className, "mx_SpotlightDialog_option")}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
>
|
||||
{ children }
|
||||
<div className="mx_SpotlightDialog_enterPrompt" aria-hidden>↵</div>
|
||||
{ endAdornment }
|
||||
</AccessibleButton>;
|
||||
};
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { IPublicRoomsChunkRoom } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { linkifyAndSanitizeHtml } from "../../../../HtmlUtils";
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import { getDisplayAliasForRoom } from "../../../structures/RoomDirectory";
|
||||
|
||||
const MAX_NAME_LENGTH = 80;
|
||||
const MAX_TOPIC_LENGTH = 800;
|
||||
|
||||
export function PublicRoomResultDetails({ room }: { room: IPublicRoomsChunkRoom }): JSX.Element {
|
||||
let name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
|
||||
if (name.length > MAX_NAME_LENGTH) {
|
||||
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
|
||||
}
|
||||
|
||||
let topic = room.topic || '';
|
||||
// Additional truncation based on line numbers is done via CSS,
|
||||
// but to ensure that the DOM is not polluted with a huge string
|
||||
// we give it a hard limit before rendering.
|
||||
if (topic.length > MAX_TOPIC_LENGTH) {
|
||||
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_SpotlightDialog_result_publicRoomDetails">
|
||||
<div className="mx_SpotlightDialog_result_publicRoomHeader">
|
||||
<span className="mx_SpotlightDialog_result_publicRoomName">{ name }</span>
|
||||
<span className="mx_SpotlightDialog_result_publicRoomAlias">
|
||||
{ room.canonical_alias ?? room.room_id }
|
||||
</span>
|
||||
</div>
|
||||
<div className="mx_SpotlightDialog_result_publicRoomDescription">
|
||||
<span className="mx_SpotlightDialog_result_publicRoomMemberCount">
|
||||
{ _t("%(count)s Members", {
|
||||
count: room.num_joined_members,
|
||||
}) }
|
||||
</span>
|
||||
{ topic && (
|
||||
<>
|
||||
·
|
||||
<span
|
||||
className="mx_SpotlightDialog_result_publicRoomTopic"
|
||||
dangerouslySetInnerHTML={{ __html: linkifyAndSanitizeHtml(topic) }}
|
||||
/>
|
||||
</>
|
||||
) }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
31
src/components/views/dialogs/spotlight/RoomResultDetails.tsx
Normal file
31
src/components/views/dialogs/spotlight/RoomResultDetails.tsx
Normal file
|
@ -0,0 +1,31 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { roomContextDetailsText, spaceContextDetailsText } from "../../../../utils/i18n-helpers";
|
||||
|
||||
export const RoomResultDetails = ({ room }: { room: Room }) => {
|
||||
const contextDetails = room.isSpaceRoom() ? spaceContextDetailsText(room) : roomContextDetailsText(room);
|
||||
if (contextDetails) {
|
||||
return <div className="mx_SpotlightDialog_result_details">
|
||||
{ contextDetails }
|
||||
</div>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
1057
src/components/views/dialogs/spotlight/SpotlightDialog.tsx
Normal file
1057
src/components/views/dialogs/spotlight/SpotlightDialog.tsx
Normal file
File diff suppressed because it is too large
Load diff
39
src/components/views/dialogs/spotlight/TooltipOption.tsx
Normal file
39
src/components/views/dialogs/spotlight/TooltipOption.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
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 classNames from "classnames";
|
||||
import React, { ComponentProps, ReactNode } from "react";
|
||||
|
||||
import { RovingAccessibleTooltipButton } from "../../../../accessibility/roving/RovingAccessibleTooltipButton";
|
||||
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
|
||||
import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
|
||||
|
||||
interface TooltipOptionProps extends ComponentProps<typeof RovingAccessibleTooltipButton> {
|
||||
endAdornment?: ReactNode;
|
||||
}
|
||||
|
||||
export const TooltipOption: React.FC<TooltipOptionProps> = ({ inputRef, className, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return <AccessibleTooltipButton
|
||||
{...props}
|
||||
className={classNames(className, "mx_SpotlightDialog_option")}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={-1}
|
||||
aria-selected={isActive}
|
||||
role="option"
|
||||
/>;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue