element-portable/src/components/views/rooms/RoomList.tsx
2024-12-04 10:02:26 +00:00

666 lines
28 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2018 , 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { EventType, RoomType, Room } from "matrix-js-sdk/src/matrix";
import React, { ComponentType, createRef, ReactComponentElement, SyntheticEvent } from "react";
import { IState as IRovingTabIndexState, RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../dispatcher/payloads";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import PosthogTrackers from "../../../PosthogTrackers";
import SettingsStore from "../../../settings/SettingsStore";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { UIComponent } from "../../../settings/UIFeature";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { ITagMap } from "../../../stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "../../../stores/room-list/models";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import {
isMetaSpace,
ISuggestedRoom,
MetaSpace,
SpaceKey,
UPDATE_SELECTED_SPACE,
UPDATE_SUGGESTED_ROOMS,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import { arrayFastClone, arrayHasDiff } from "../../../utils/arrays";
import { objectShallowClone, objectWithOnly } from "../../../utils/objects";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { shouldShowSpaceInvite, showAddExistingRooms, showCreateNewRoom, showSpaceInvite } from "../../../utils/space";
import { ChevronFace, ContextMenuTooltipButton, MenuProps, useContextMenu } from "../../structures/ContextMenu";
import RoomAvatar from "../avatars/RoomAvatar";
import { BetaPill } from "../beta/BetaCard";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import ExtraTile from "./ExtraTile";
import RoomSublist, { IAuxButtonProps } from "./RoomSublist";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
onFocus: (ev: React.FocusEvent) => void;
onBlur: (ev: React.FocusEvent) => void;
onResize: () => void;
onListCollapse?: (isExpanded: boolean) => void;
resizeNotifier: ResizeNotifier;
isMinimized: boolean;
activeSpace: SpaceKey;
}
interface IState {
sublists: ITagMap;
currentRoomId?: string;
suggestedRooms: ISuggestedRoom[];
}
export const TAG_ORDER: TagID[] = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
DefaultTagID.Conference,
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Suggested,
];
const ALWAYS_VISIBLE_TAGS: TagID[] = [DefaultTagID.DM, DefaultTagID.Untagged];
interface ITagAesthetics {
sectionLabel: TranslationKey;
sectionLabelRaw?: string;
AuxButtonComponent?: ComponentType<IAuxButtonProps>;
isInvite: boolean;
defaultHidden: boolean;
}
type TagAestheticsMap = Partial<{
[tagId in TagID]: ITagAesthetics;
}>;
const auxButtonContextMenuPosition = (handle: HTMLDivElement): MenuProps => {
const rect = handle.getBoundingClientRect();
return {
chevronFace: ChevronFace.None,
left: rect.left - 7,
top: rect.top + rect.height,
};
};
const DmAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex, dispatcher = defaultDispatcher }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const activeSpace = useEventEmitterState(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpaceRoom;
});
const showCreateRooms = shouldShowComponent(UIComponent.CreateRooms);
const showInviteUsers = shouldShowComponent(UIComponent.InviteUsers);
if (activeSpace && (showCreateRooms || showInviteUsers)) {
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && handle.current) {
const canInvite = shouldShowSpaceInvite(activeSpace);
contextMenu = (
<IconizedContextMenu {...auxButtonContextMenuPosition(handle.current)} onFinished={closeMenu} compact>
<IconizedContextMenuOptionList first>
{showCreateRooms && (
<IconizedContextMenuOption
label={_t("action|start_new_chat")}
iconClassName="mx_RoomList_iconStartChat"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: "view_create_chat" });
PosthogTrackers.trackInteraction(
"WebRoomListRoomsSublistPlusMenuCreateChatItem",
e,
);
}}
/>
)}
{showInviteUsers && (
<IconizedContextMenuOption
label={_t("action|invite_to_space")}
iconClassName="mx_RoomList_iconInvite"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showSpaceInvite(activeSpace);
}}
disabled={!canInvite}
title={canInvite ? undefined : _t("spaces|error_no_permission_invite")}
/>
)}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
return (
<>
<ContextMenuTooltipButton
tabIndex={tabIndex}
onClick={openMenu}
className="mx_RoomSublist_auxButton"
aria-label={_t("action|add_people")}
title={_t("action|add_people")}
isExpanded={menuDisplayed}
ref={handle}
/>
{contextMenu}
</>
);
} else if (!activeSpace && showCreateRooms) {
return (
<AccessibleButton
tabIndex={tabIndex}
onClick={(e) => {
dispatcher.dispatch({ action: "view_create_chat" });
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateChatItem", e);
}}
className="mx_RoomSublist_auxButton"
aria-label={_t("action|start_chat")}
title={_t("action|start_chat")}
/>
);
}
return null;
};
const UntaggedAuxButton: React.FC<IAuxButtonProps> = ({ tabIndex }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
const activeSpace = useEventEmitterState<Room | null>(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => {
return SpaceStore.instance.activeSpaceRoom;
});
const showCreateRoom = shouldShowComponent(UIComponent.CreateRooms);
const showExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
let contextMenuContent: JSX.Element | undefined;
if (menuDisplayed && activeSpace) {
const canAddRooms = activeSpace.currentState.maySendStateEvent(
EventType.SpaceChild,
MatrixClientPeg.safeGet().getSafeUserId(),
);
contextMenuContent = (
<IconizedContextMenuOptionList first>
<IconizedContextMenuOption
label={_t("action|explore_rooms")}
iconClassName="mx_RoomList_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined, // other
});
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
}}
/>
{showCreateRoom ? (
<>
<IconizedContextMenuOption
label={_t("action|new_room")}
iconClassName="mx_RoomList_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_create_room")}
>
<BetaPill />
</IconizedContextMenuOption>
)}
<IconizedContextMenuOption
label={_t("action|add_existing_room")}
iconClassName="mx_RoomList_iconAddExistingRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
showAddExistingRooms(activeSpace);
}}
disabled={!canAddRooms}
title={canAddRooms ? undefined : _t("spaces|error_no_permission_add_room")}
/>
</>
) : null}
</IconizedContextMenuOptionList>
);
} else if (menuDisplayed) {
contextMenuContent = (
<IconizedContextMenuOptionList first>
{showCreateRoom && (
<>
<IconizedContextMenuOption
label={_t("action|new_room")}
iconClassName="mx_RoomList_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({ action: "view_create_room" });
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuCreateRoomItem", e);
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
iconClassName="mx_RoomList_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
defaultDispatcher.dispatch({
action: "view_create_room",
type: elementCallVideoRoomsEnabled
? RoomType.UnstableCall
: RoomType.ElementVideo,
});
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
)}
{showExploreRooms ? (
<IconizedContextMenuOption
label={_t("action|explore_public_rooms")}
iconClassName="mx_RoomList_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
closeMenu();
PosthogTrackers.trackInteraction("WebRoomListRoomsSublistPlusMenuExploreRoomsItem", e);
defaultDispatcher.fire(Action.ViewRoomDirectory);
}}
/>
) : null}
</IconizedContextMenuOptionList>
);
}
let contextMenu: JSX.Element | null = null;
if (menuDisplayed && handle.current) {
contextMenu = (
<IconizedContextMenu {...auxButtonContextMenuPosition(handle.current)} onFinished={closeMenu} compact>
{contextMenuContent}
</IconizedContextMenu>
);
}
if (showCreateRoom || showExploreRooms) {
return (
<>
<ContextMenuTooltipButton
tabIndex={tabIndex}
onClick={openMenu}
className="mx_RoomSublist_auxButton"
aria-label={_t("room_list|add_room_label")}
title={_t("room_list|add_room_label")}
isExpanded={menuDisplayed}
ref={handle}
/>
{contextMenu}
</>
);
}
return null;
};
const TAG_AESTHETICS: TagAestheticsMap = {
[DefaultTagID.Invite]: {
sectionLabel: _td("action|invites_list"),
isInvite: true,
defaultHidden: false,
},
[DefaultTagID.Favourite]: {
sectionLabel: _td("common|favourites"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.DM]: {
sectionLabel: _td("common|people"),
isInvite: false,
defaultHidden: false,
AuxButtonComponent: DmAuxButton,
},
[DefaultTagID.Conference]: {
sectionLabel: _td("voip|metaspace_video_rooms|conference_room_section"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.Untagged]: {
sectionLabel: _td("common|rooms"),
isInvite: false,
defaultHidden: false,
AuxButtonComponent: UntaggedAuxButton,
},
[DefaultTagID.LowPriority]: {
sectionLabel: _td("common|low_priority"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.ServerNotice]: {
sectionLabel: _td("common|system_alerts"),
isInvite: false,
defaultHidden: false,
},
[DefaultTagID.Suggested]: {
sectionLabel: _td("room_list|suggested_rooms_heading"),
isInvite: false,
defaultHidden: false,
},
};
export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef?: string;
private treeRef = createRef<HTMLDivElement>();
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
sublists: {},
suggestedRooms: SpaceStore.instance.suggestedRooms,
};
}
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
SdkContextClass.instance.roomViewStore.on(UPDATE_EVENT, this.onRoomViewStoreUpdate);
SpaceStore.instance.on(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
this.updateLists(); // trigger the first update
}
public componentWillUnmount(): void {
SpaceStore.instance.off(UPDATE_SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.updateLists);
defaultDispatcher.unregister(this.dispatcherRef);
SdkContextClass.instance.roomViewStore.off(UPDATE_EVENT, this.onRoomViewStoreUpdate);
}
private onRoomViewStoreUpdate = (): void => {
this.setState({
currentRoomId: SdkContextClass.instance.roomViewStore.getRoomId() ?? undefined,
});
};
private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.ViewRoomDelta) {
const viewRoomDeltaPayload = payload as ViewRoomDeltaPayload;
const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
if (!currentRoomId) return;
const room = this.getRoomDelta(currentRoomId, viewRoomDeltaPayload.delta, viewRoomDeltaPayload.unread);
if (room) {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
show_room_tile: true, // to make sure the room gets scrolled into view
metricsTrigger: "WebKeyboardShortcut",
metricsViaKeyboard: true,
});
}
} else if (payload.action === Action.PstnSupportUpdated) {
this.updateLists();
}
};
private getRoomDelta = (roomId: string, delta: number, unread = false): Room => {
const lists = RoomListStore.instance.orderedLists;
const rooms: Room[] = [];
TAG_ORDER.forEach((t) => {
let listRooms = lists[t];
if (unread) {
// filter to only notification rooms (and our current active room so we can index properly)
listRooms = listRooms.filter((r) => {
const state = RoomNotificationStateStore.instance.getRoomState(r);
return state.room.roomId === roomId || state.isUnread;
});
}
rooms.push(...listRooms);
});
const currentIndex = rooms.findIndex((r) => r.roomId === roomId);
// use slice to account for looping around the start
const [room] = rooms.slice((currentIndex + delta) % rooms.length);
return room;
};
private updateSuggestedRooms = (suggestedRooms: ISuggestedRoom[]): void => {
this.setState({ suggestedRooms });
};
private updateLists = (): void => {
const newLists = RoomListStore.instance.orderedLists;
const previousListIds = Object.keys(this.state.sublists);
const newListIds = Object.keys(newLists);
let doUpdate = arrayHasDiff(previousListIds, newListIds);
if (!doUpdate) {
// so we didn't have the visible sublists change, but did the contents of those
// sublists change significantly enough to break the sticky headers? Probably, so
// let's check the length of each.
for (const tagId of newListIds) {
const oldRooms = this.state.sublists[tagId];
const newRooms = newLists[tagId];
if (oldRooms.length !== newRooms.length) {
doUpdate = true;
break;
}
}
}
if (doUpdate) {
// We have to break our reference to the room list store if we want to be able to
// diff the object for changes, so do that.
// @ts-ignore - ITagMap is ts-ignored so this will have to be too
const newSublists = objectWithOnly(newLists, newListIds);
const sublists = objectShallowClone(newSublists, (k, v) => arrayFastClone(v));
this.setState({ sublists }, () => {
this.props.onResize();
});
}
};
private renderSuggestedRooms(): ReactComponentElement<typeof ExtraTile>[] {
return this.state.suggestedRooms.map((room) => {
const name = room.name || room.canonical_alias || room.aliases?.[0] || _t("empty_room");
const avatar = (
<RoomAvatar
oobData={{
name,
avatarUrl: room.avatar_url,
}}
size="32px"
/>
);
const viewRoom = (ev: SyntheticEvent): void => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_alias: room.canonical_alias || room.aliases?.[0],
room_id: room.room_id,
via_servers: room.viaServers,
oob_data: {
avatarUrl: room.avatar_url,
name,
},
metricsTrigger: "RoomList",
metricsViaKeyboard: ev.type !== "click",
});
};
return (
<ExtraTile
isMinimized={this.props.isMinimized}
isSelected={this.state.currentRoomId === room.room_id}
displayName={name}
avatar={avatar}
onClick={viewRoom}
key={`suggestedRoomTile_${room.room_id}`}
/>
);
});
}
private renderSublists(): React.ReactElement[] {
// show a skeleton UI if the user is in no rooms and they are not filtering and have no suggested rooms
const showSkeleton =
!this.state.suggestedRooms?.length &&
Object.values(RoomListStore.instance.orderedLists).every((list) => !list?.length);
return TAG_ORDER.map((orderedTagId) => {
let extraTiles: ReactComponentElement<typeof ExtraTile>[] | undefined;
if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
}
const aesthetics = TAG_AESTHETICS[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
let alwaysVisible = ALWAYS_VISIBLE_TAGS.includes(orderedTagId);
if (
(this.props.activeSpace === MetaSpace.Favourites && orderedTagId !== DefaultTagID.Favourite) ||
(this.props.activeSpace === MetaSpace.People && orderedTagId !== DefaultTagID.DM) ||
(this.props.activeSpace === MetaSpace.Orphans && orderedTagId === DefaultTagID.DM) ||
(this.props.activeSpace === MetaSpace.VideoRooms && orderedTagId === DefaultTagID.DM) ||
(!isMetaSpace(this.props.activeSpace) &&
orderedTagId === DefaultTagID.DM &&
!SettingsStore.getValue("Spaces.showPeopleInSpace", this.props.activeSpace))
) {
alwaysVisible = false;
}
let forceExpanded = false;
if (
(this.props.activeSpace === MetaSpace.Favourites && orderedTagId === DefaultTagID.Favourite) ||
(this.props.activeSpace === MetaSpace.People && orderedTagId === DefaultTagID.DM)
) {
forceExpanded = true;
}
// The cost of mounting/unmounting this component offsets the cost
// of keeping it in the DOM and hiding it when it is not required
return (
<RoomSublist
key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true}
startAsHidden={aesthetics.defaultHidden}
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
AuxButtonComponent={aesthetics.AuxButtonComponent}
isMinimized={this.props.isMinimized}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
resizeNotifier={this.props.resizeNotifier}
alwaysVisible={alwaysVisible}
onListCollapse={this.props.onListCollapse}
forceExpanded={forceExpanded}
/>
);
});
}
public focus(): void {
// focus the first focusable element in this aria treeview widget
const treeItems = this.treeRef.current?.querySelectorAll<HTMLElement>('[role="treeitem"]');
if (!treeItems) return;
[...treeItems].find((e) => e.offsetParent !== null)?.focus();
}
public render(): React.ReactNode {
const sublists = this.renderSublists();
return (
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.props.onKeyDown}>
{({ onKeyDownHandler }) => (
<div
onFocus={this.props.onFocus}
onBlur={this.props.onBlur}
onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (
navAction === KeyBindingAction.NextLandmark ||
navAction === KeyBindingAction.PreviousLandmark
) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
onKeyDownHandler(ev);
}}
className="mx_RoomList"
role="tree"
aria-label={_t("common|rooms")}
ref={this.treeRef}
>
{sublists}
</div>
)}
</RovingTabIndexProvider>
);
}
}