feat: Group TG and community rooms in sidebar

pull/27073/head
Badi ifaoui 2024-01-30 10:28:28 +01:00
parent 4b2e1a4a92
commit 49764d1a83
5 changed files with 1611 additions and 2 deletions

View File

@ -18,5 +18,7 @@
"src/components/structures/HomePage.tsx": "src/components/structures/HomePage.tsx",
"src/components/views/dialogs/spotlight/SpotlightDialog.tsx": "src/components/views/dialogs/spotlight/SpotlightDialog.tsx",
"src/components/views/elements/Pill.tsx": "src/components/views/elements/Pill.tsx",
"src/components/structures/LeftPanel.tsx": "src/components/structures/LeftPanel.tsx"
"src/components/structures/LeftPanel.tsx": "src/components/structures/LeftPanel.tsx",
"src/components/views/rooms/RoomList.tsx": "src/components/views/rooms/RoomList.tsx",
"src/components/views/rooms/RoomSublist.tsx": "src/components/views/rooms/RoomSublist.tsx"
}

View File

@ -19,7 +19,6 @@ import { createRef } from "react";
import classNames from "classnames";
import dis from "matrix-react-sdk/src/dispatcher/dispatcher";
import { _t } from "matrix-react-sdk/src/languageHandler";
import RoomList from "matrix-react-sdk/src/components/views/rooms/RoomList";
import LegacyCallHandler from "matrix-react-sdk/src/LegacyCallHandler";
import { HEADER_HEIGHT } from "matrix-react-sdk/src/components/views/rooms/RoomSublist";
import { Action } from "matrix-react-sdk/src/dispatcher/actions";
@ -45,6 +44,7 @@ import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers";
import PageType from "matrix-react-sdk/src/PageTypes";
import { UserOnboardingButton } from "matrix-react-sdk/src/components/views/user-onboarding/UserOnboardingButton";
import RoomList from "../views/rooms/RoomList";
import { Icon as Superhero } from "../../../res/themes/superhero/img/logos/superhero.svg";
interface IProps {

View File

@ -0,0 +1,682 @@
/*
Copyright 2015-2018, 2020, 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 { EventType, Room, RoomType } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "matrix-react-sdk/src/MatrixClientPeg";
import PosthogTrackers from "matrix-react-sdk/src/PosthogTrackers";
import {
IState as IRovingTabIndexState,
RovingTabIndexProvider,
} from "matrix-react-sdk/src/accessibility/RovingTabIndex";
import {
ChevronFace,
ContextMenuTooltipButton,
MenuProps,
useContextMenu,
} from "matrix-react-sdk/src/components/structures/ContextMenu";
import RoomAvatar from "matrix-react-sdk/src/components/views/avatars/RoomAvatar";
import { BetaPill } from "matrix-react-sdk/src/components/views/beta/BetaCard";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "matrix-react-sdk/src/components/views/context_menus/IconizedContextMenu";
import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton";
import ExtraTile from "matrix-react-sdk/src/components/views/rooms/ExtraTile";
import MatrixClientContext from "matrix-react-sdk/src/contexts/MatrixClientContext";
import { SdkContextClass } from "matrix-react-sdk/src/contexts/SDKContext";
import { shouldShowComponent } from "matrix-react-sdk/src/customisations/helpers/UIComponents";
import { Action } from "matrix-react-sdk/src/dispatcher/actions";
import defaultDispatcher from "matrix-react-sdk/src/dispatcher/dispatcher";
import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads";
import { ViewRoomDeltaPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomDeltaPayload";
import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload";
import { useEventEmitterState } from "matrix-react-sdk/src/hooks/useEventEmitter";
import { useFeatureEnabled } from "matrix-react-sdk/src/hooks/useSettings";
import { TranslationKey, _t, _td } from "matrix-react-sdk/src/languageHandler";
import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore";
import { UIComponent } from "matrix-react-sdk/src/settings/UIFeature";
import { UPDATE_EVENT } from "matrix-react-sdk/src/stores/AsyncStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "matrix-react-sdk/src/stores/room-list/RoomListStore";
import { ITagMap } from "matrix-react-sdk/src/stores/room-list/algorithms/models";
import { DefaultTagID, TagID } from "matrix-react-sdk/src/stores/room-list/models";
import {
ISuggestedRoom,
MetaSpace,
SpaceKey,
UPDATE_SELECTED_SPACE,
UPDATE_SUGGESTED_ROOMS,
isMetaSpace,
} from "matrix-react-sdk/src/stores/spaces";
import SpaceStore from "matrix-react-sdk/src/stores/spaces/SpaceStore";
import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier";
import { arrayFastClone, arrayHasDiff } from "matrix-react-sdk/src/utils/arrays";
import { objectShallowClone, objectWithOnly } from "matrix-react-sdk/src/utils/objects";
import {
shouldShowSpaceInvite,
showAddExistingRooms,
showCreateNewRoom,
showSpaceInvite,
} from "matrix-react-sdk/src/utils/space";
import React, { ComponentType, ReactComponentElement, SyntheticEvent, createRef } from "react";
import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore";
import { CustomTagID, SuperheroTagID } from "../../../stores/room-list/custom-models";
import RoomSublist, { IAuxButtonProps } from "./RoomSublist";
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: CustomTagID[] = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Suggested,
DefaultTagID.Archived,
SuperheroTagID.CommunityRooms,
];
const ALWAYS_VISIBLE_TAGS: CustomTagID[] = [DefaultTagID.DM, DefaultTagID.Untagged, SuperheroTagID.CommunityRooms];
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}
tooltip={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 (
<AccessibleTooltipButton
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}
tooltip={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}
tooltip={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}
tooltip={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.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,
},
// TODO: Replace with archived view: https://github.com/vector-im/element-web/issues/14038
[DefaultTagID.Archived]: {
sectionLabel: _td("common|historical"),
isInvite: true,
defaultHidden: true,
},
[DefaultTagID.Suggested]: {
sectionLabel: _td("room_list|suggested_rooms_heading"),
isInvite: false,
defaultHidden: false,
},
[SuperheroTagID.CommunityRooms]: {
sectionLabel: _td("room_list|suggested_rooms_heading"),
sectionLabelRaw: "Community Rooms",
isInvite: false,
defaultHidden: false,
AuxButtonComponent: DmAuxButton,
},
};
export default class RoomList extends React.PureComponent<IProps, IState> {
private dispatcherRef?: string;
private treeRef = createRef<HTMLDivElement>();
public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps) {
super(props);
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);
if (this.dispatcherRef) 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.
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 === SuperheroTagID.CommunityRooms &&
orderedTagId === SuperheroTagID.CommunityRooms) ||
(!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) ||
(this.props.activeSpace === SuperheroTagID.CommunityRooms &&
orderedTagId !== SuperheroTagID.CommunityRooms)
) {
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={onKeyDownHandler}
className="mx_RoomList"
role="tree"
aria-label={_t("common|rooms")}
ref={this.treeRef}
>
{sublists}
</div>
)}
</RovingTabIndexProvider>
);
}
}

View File

@ -0,0 +1,918 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2020 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 { Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import * as React from "react";
import { ComponentType, createRef, ReactComponentElement, ReactNode } from "react";
import { polyfillTouchEvent } from "matrix-react-sdk/src/@types/polyfill";
import { KeyBindingAction } from "matrix-react-sdk/src/accessibility/KeyboardShortcuts";
import { RovingAccessibleButton, RovingTabIndexWrapper } from "matrix-react-sdk/src/accessibility/RovingTabIndex";
import { Action } from "matrix-react-sdk/src/dispatcher/actions";
import defaultDispatcher, { MatrixDispatcher } from "matrix-react-sdk/src/dispatcher/dispatcher";
import { ActionPayload } from "matrix-react-sdk/src/dispatcher/payloads";
import { ViewRoomPayload } from "matrix-react-sdk/src/dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "matrix-react-sdk/src/KeyBindingsManager";
import { _t } from "matrix-react-sdk/src/languageHandler";
import { ListNotificationState } from "matrix-react-sdk/src/stores/notifications/ListNotificationState";
import { RoomNotificationStateStore } from "matrix-react-sdk/src/stores/notifications/RoomNotificationStateStore";
import { ListAlgorithm, SortAlgorithm } from "matrix-react-sdk/src/stores/room-list/algorithms/models";
import { ListLayout } from "matrix-react-sdk/src/stores/room-list/ListLayout";
import { DefaultTagID, TagID } from "matrix-react-sdk/src/stores/room-list/models";
import RoomListLayoutStore from "matrix-react-sdk/src/stores/room-list/RoomListLayoutStore";
import RoomListStore, {
LISTS_UPDATE_EVENT,
LISTS_LOADING_EVENT,
} from "matrix-react-sdk/src/stores/room-list/RoomListStore";
import { arrayFastClone, arrayHasOrderChange } from "matrix-react-sdk/src/utils/arrays";
import { objectExcluding, objectHasDiff } from "matrix-react-sdk/src/utils/objects";
import ResizeNotifier from "matrix-react-sdk/src/utils/ResizeNotifier";
import ContextMenu, {
ChevronFace,
ContextMenuTooltipButton,
StyledMenuItemCheckbox,
StyledMenuItemRadio,
} from "matrix-react-sdk/src/components/structures/ContextMenu";
import AccessibleButton, { ButtonEvent } from "matrix-react-sdk/src/components/views/elements/AccessibleButton";
import AccessibleTooltipButton from "matrix-react-sdk/src/components/views/elements/AccessibleTooltipButton";
import ExtraTile from "matrix-react-sdk/src/components/views/rooms/ExtraTile";
import SettingsStore from "matrix-react-sdk/src/settings/SettingsStore";
import { SlidingSyncManager } from "matrix-react-sdk/src/SlidingSyncManager";
import NotificationBadge from "matrix-react-sdk/src/components/views/rooms/NotificationBadge";
import RoomTile from "./RoomTile";
import { CustomTagID, SuperheroTagID } from "../../../stores/room-list/custom-models";
import { isVerifiedRoom } from "../../../hooks/useVerifiedRoom";
const SHOW_N_BUTTON_HEIGHT = 28; // As defined by CSS
const RESIZE_HANDLE_HEIGHT = 4; // As defined by CSS
export const HEADER_HEIGHT = 32; // As defined by CSS
const MAX_PADDING_HEIGHT = SHOW_N_BUTTON_HEIGHT + RESIZE_HANDLE_HEIGHT;
// HACK: We really shouldn't have to do this.
polyfillTouchEvent();
export interface IAuxButtonProps {
tabIndex: number;
dispatcher?: MatrixDispatcher;
}
interface IProps {
forRooms: boolean;
startAsHidden: boolean;
label: string;
AuxButtonComponent?: ComponentType<IAuxButtonProps>;
isMinimized: boolean;
tagId: TagID;
showSkeleton?: boolean;
alwaysVisible?: boolean;
forceExpanded?: boolean;
resizeNotifier: ResizeNotifier;
extraTiles?: ReactComponentElement<typeof ExtraTile>[] | null;
onListCollapse?: (isExpanded: boolean) => void;
}
function getLabelId(tagId: TagID): string {
return `mx_RoomSublist_label_${tagId}`;
}
// TODO: Use re-resizer's NumberSize when it is exposed as the type
interface ResizeDelta {
width: number;
height: number;
}
type PartialDOMRect = Pick<DOMRect, "left" | "top" | "height">;
interface IState {
contextMenuPosition?: PartialDOMRect;
isResizing: boolean;
isExpanded: boolean; // used for the for expand of the sublist when the room list is being filtered
height: number;
rooms: Room[];
roomsLoading: boolean;
}
export default class RoomSublist extends React.Component<IProps, IState> {
private headerButton = createRef<HTMLDivElement>();
private sublistRef = createRef<HTMLDivElement>();
private tilesRef = createRef<HTMLDivElement>();
private dispatcherRef?: string;
private layout: ListLayout;
private heightAtStart: number;
private notificationState: ListNotificationState;
private slidingSyncMode: boolean;
public constructor(props: IProps) {
super(props);
// when this setting is toggled it restarts the app so it's safe to not watch this.
this.slidingSyncMode = SettingsStore.getValue("feature_sliding_sync");
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0;
this.notificationState = RoomNotificationStateStore.instance.getListState(this.getTagId());
this.state = {
isResizing: false,
isExpanded: !this.layout.isCollapsed,
height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
rooms: this.getFilteredRooms(this.props.tagId),
roomsLoading: false,
};
// Why Object.assign() and not this.state.height? Because TypeScript says no.
this.state = Object.assign(this.state, { height: this.calculateInitialHeight() });
}
private getTagId(): TagID {
return this.props.tagId === SuperheroTagID.CommunityRooms ? DefaultTagID.Untagged : this.props.tagId;
}
private getFilteredRooms(tagId: CustomTagID): Room[] {
const listRooms = arrayFastClone(RoomListStore.instance.orderedLists[this.getTagId()] || []);
return listRooms?.filter((room) => {
const verifiedRoom = isVerifiedRoom(room.name);
const isCommunityRoom = verifiedRoom.isCommunityRoom || verifiedRoom.isTokenGatedRoom;
if (tagId === SuperheroTagID.CommunityRooms) {
return isCommunityRoom;
}
return !isCommunityRoom;
});
}
private calculateInitialHeight(): number {
const requestedVisibleTiles = Math.max(Math.floor(this.layout.visibleTiles), this.layout.minVisibleTiles);
const tileCount = Math.min(this.numTiles, requestedVisibleTiles);
return this.layout.tilesToPixelsWithPadding(tileCount, this.padding);
}
private get padding(): number {
let padding = RESIZE_HANDLE_HEIGHT;
// this is used for calculating the max height of the whole container,
// and takes into account whether there should be room reserved for the show more/less button
// when fully expanded. We can't rely purely on the layout's defaultVisible tile count
// because there are conditions in which we need to know that the 'show more' button
// is present while well under the default tile limit.
const needsShowMore = this.numTiles > this.numVisibleTiles;
// ...but also check this or we'll miss if the section is expanded and we need a
// 'show less'
const needsShowLess = this.numTiles > this.layout.defaultVisibleTiles;
if (needsShowMore || needsShowLess) {
padding += SHOW_N_BUTTON_HEIGHT;
}
return padding;
}
private get extraTiles(): ReactComponentElement<typeof ExtraTile>[] | null {
return this.props.extraTiles ?? null;
}
private get numTiles(): number {
return RoomSublist.calcNumTiles(this.state.rooms, this.extraTiles);
}
private static calcNumTiles(rooms: Room[], extraTiles?: any[] | null): number {
return (rooms || []).length + (extraTiles || []).length;
}
private get numVisibleTiles(): number {
if (this.slidingSyncMode) {
return this.state.rooms.length;
}
const nVisible = Math.ceil(this.layout.visibleTiles);
return Math.min(nVisible, this.numTiles);
}
public componentDidUpdate(prevProps: Readonly<IProps>, prevState: Readonly<IState>): void {
const prevExtraTiles = prevProps.extraTiles;
// as the rooms can come in one by one we need to reevaluate
// the amount of available rooms to cap the amount of requested visible rooms by the layout
if (RoomSublist.calcNumTiles(prevState.rooms, prevExtraTiles) !== this.numTiles) {
this.setState({ height: this.calculateInitialHeight() });
}
}
public shouldComponentUpdate(nextProps: Readonly<IProps>, nextState: Readonly<IState>): boolean {
if (objectHasDiff(this.props, nextProps)) {
// Something we don't care to optimize has updated, so update.
return true;
}
// Do the same check used on props for state, without the rooms we're going to no-op
const prevStateNoRooms = objectExcluding(this.state, ["rooms"]);
const nextStateNoRooms = objectExcluding(nextState, ["rooms"]);
if (objectHasDiff(prevStateNoRooms, nextStateNoRooms)) {
return true;
}
// If we're supposed to handle extra tiles, take the performance hit and re-render all the
// time so we don't have to consider them as part of the visible room optimization.
const prevExtraTiles = this.props.extraTiles || [];
const nextExtraTiles = nextProps.extraTiles || [];
if (prevExtraTiles.length > 0 || nextExtraTiles.length > 0) {
return true;
}
// If we're about to update the height of the list, we don't really care about which rooms
// are visible or not for no-op purposes, so ensure that the height calculation runs through.
if (RoomSublist.calcNumTiles(nextState.rooms, nextExtraTiles) !== this.numTiles) {
return true;
}
// Before we go analyzing the rooms, we can see if we're collapsed. If we're collapsed, we don't need
// to render anything. We do this after the height check though to ensure that the height gets appropriately
// calculated for when/if we become uncollapsed.
if (!nextState.isExpanded) {
return false;
}
// Quickly double check we're not about to break something due to the number of rooms changing.
if (this.state.rooms.length !== nextState.rooms.length) {
return true;
}
// Finally, determine if the room update (as presumably that's all that's left) is within
// our visible range. If it is, then do a render. If the update is outside our visible range
// then we can skip the update.
//
// We also optimize for order changing here: if the update did happen in our visible range
// but doesn't result in the list re-sorting itself then there's no reason for us to update
// on our own.
const prevSlicedRooms = this.state.rooms.slice(0, this.numVisibleTiles);
const nextSlicedRooms = nextState.rooms.slice(0, this.numVisibleTiles);
if (arrayHasOrderChange(prevSlicedRooms, nextSlicedRooms)) {
return true;
}
// Finally, nothing happened so no-op the update
return false;
}
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
RoomListStore.instance.on(LISTS_LOADING_EVENT, this.onListsLoading);
// Using the passive option to not block the main thread
// https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#improving_scrolling_performance_with_passive_listeners
this.tilesRef.current?.addEventListener("scroll", this.onScrollPrevent, { passive: true });
}
public componentWillUnmount(): void {
if (this.dispatcherRef) defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
RoomListStore.instance.off(LISTS_LOADING_EVENT, this.onListsLoading);
this.tilesRef.current?.removeEventListener("scroll", this.onScrollPrevent);
}
private onListsLoading = (tagId: TagID, isLoading: boolean): void => {
if (this.props.tagId !== tagId) {
return;
}
this.setState({
roomsLoading: isLoading,
});
};
private onListsUpdated = (): void => {
const stateUpdates = {} as IState;
const currentRooms = this.state.rooms;
const newRooms = this.getFilteredRooms(this.props.tagId);
if (arrayHasOrderChange(currentRooms, newRooms)) {
stateUpdates.rooms = newRooms;
}
if (Object.keys(stateUpdates).length > 0) {
this.setState(stateUpdates);
}
};
private onAction = (payload: ActionPayload): void => {
if (payload.action === Action.ViewRoom && payload.show_room_tile && this.state.rooms) {
// XXX: we have to do this a tick later because we have incorrect intermediate props during a room change
// where we lose the room we are changing from temporarily and then it comes back in an update right after.
setImmediate(() => {
const roomIndex = this.state.rooms.findIndex((r) => r.roomId === payload.room_id);
if (!this.state.isExpanded && roomIndex > -1) {
this.toggleCollapsed();
}
// extend the visible section to include the room if it is entirely invisible
if (roomIndex >= this.numVisibleTiles) {
this.layout.visibleTiles = this.layout.tilesWithPadding(roomIndex + 1, MAX_PADDING_HEIGHT);
this.forceUpdate(); // because the layout doesn't trigger a re-render
}
});
}
};
private applyHeightChange(newHeight: number): void {
const heightInTiles = Math.ceil(this.layout.pixelsToTiles(newHeight - this.padding));
this.layout.visibleTiles = Math.min(this.numTiles, heightInTiles);
}
private onResize = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLElement,
delta: ResizeDelta,
): void => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({ height: newHeight });
};
private onResizeStart = (): void => {
this.heightAtStart = this.state.height;
this.setState({ isResizing: true });
};
private onResizeStop = (
e: MouseEvent | TouchEvent,
travelDirection: Direction,
refToElement: HTMLElement,
delta: ResizeDelta,
): void => {
const newHeight = this.heightAtStart + delta.height;
this.applyHeightChange(newHeight);
this.setState({ isResizing: false, height: newHeight });
};
private onShowAllClick = async (): Promise<void> => {
if (this.slidingSyncMode) {
const count = RoomListStore.instance.getCount(this.props.tagId);
await SlidingSyncManager.instance.ensureListRegistered(this.props.tagId, {
ranges: [[0, count]],
});
}
// read number of visible tiles before we mutate it
const numVisibleTiles = this.numVisibleTiles;
const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
this.applyHeightChange(newHeight);
this.setState({ height: newHeight }, () => {
// focus the top-most new room
this.focusRoomTile(numVisibleTiles);
});
};
private onShowLessClick = (): void => {
const newHeight = this.layout.tilesToPixelsWithPadding(this.layout.defaultVisibleTiles, this.padding);
this.applyHeightChange(newHeight);
this.setState({ height: newHeight });
};
private focusRoomTile = (index: number): void => {
if (!this.sublistRef.current) return;
const elements = this.sublistRef.current.querySelectorAll<HTMLDivElement>(".mx_RoomTile");
const element = elements && elements[index];
if (element) {
element.focus();
}
};
private onOpenMenuClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
this.setState({
contextMenuPosition: {
left: ev.clientX,
top: ev.clientY,
height: 0,
},
});
};
private onCloseMenu = (): void => {
this.setState({ contextMenuPosition: undefined });
};
private onUnreadFirstChanged = (): void => {
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
const newAlgorithm = isUnreadFirst ? ListAlgorithm.Natural : ListAlgorithm.Importance;
RoomListStore.instance.setListOrder(this.props.tagId, newAlgorithm);
this.forceUpdate(); // because if the sublist doesn't have any changes then we will miss the list order change
};
private onTagSortChanged = async (sort: SortAlgorithm): Promise<void> => {
RoomListStore.instance.setTagSorting(this.getTagId(), sort);
this.forceUpdate();
};
private onMessagePreviewChanged = (): void => {
this.layout.showPreviews = !this.layout.showPreviews;
this.forceUpdate(); // because the layout doesn't trigger a re-render
};
private onBadgeClick = (ev: React.MouseEvent): void => {
ev.preventDefault();
ev.stopPropagation();
let room;
if (this.props.tagId === DefaultTagID.Invite) {
// switch to first room as that'll be the top of the list for the user
room = this.state.rooms && this.state.rooms[0];
} else {
// find the first room with a count of the same colour as the badge count
room = RoomListStore.instance.orderedLists[this.getTagId()].find((r: Room) => {
const notifState = this.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.color === this.notificationState.color;
});
}
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: "WebRoomListNotificationBadge",
metricsViaKeyboard: ev.type !== "click",
});
}
};
private onHeaderClick = (): void => {
const possibleSticky = this.headerButton.current?.parentElement;
const sublist = possibleSticky?.parentElement?.parentElement;
const list = sublist?.parentElement?.parentElement;
if (!possibleSticky || !list) return;
// the scrollTop is capped at the height of the header in LeftPanel, the top header is always sticky
const listScrollTop = Math.round(list.scrollTop);
const isAtTop = listScrollTop <= Math.round(HEADER_HEIGHT);
const isAtBottom = listScrollTop >= Math.round(list.scrollHeight - list.offsetHeight);
const isStickyTop = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyTop");
const isStickyBottom = possibleSticky.classList.contains("mx_RoomSublist_headerContainer_stickyBottom");
if ((isStickyBottom && !isAtBottom) || (isStickyTop && !isAtTop)) {
// is sticky - jump to list
sublist.scrollIntoView({ behavior: "smooth" });
} else {
// on screen - toggle collapse
const isExpanded = this.state.isExpanded;
this.toggleCollapsed();
// if the bottom list is collapsed then scroll it in so it doesn't expand off screen
if (!isExpanded && isStickyBottom) {
setImmediate(() => {
sublist.scrollIntoView({ behavior: "smooth" });
});
}
}
};
private toggleCollapsed = (): void => {
if (this.props.forceExpanded) return;
this.layout.isCollapsed = this.state.isExpanded;
this.setState({ isExpanded: !this.layout.isCollapsed });
if (this.props.onListCollapse) {
this.props.onListCollapse(!this.layout.isCollapsed);
}
};
private onHeaderKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case KeyBindingAction.CollapseRoomListSection:
ev.stopPropagation();
if (this.state.isExpanded) {
// Collapse the room sublist if it isn't already
this.toggleCollapsed();
}
break;
case KeyBindingAction.ExpandRoomListSection: {
ev.stopPropagation();
if (!this.state.isExpanded) {
// Expand the room sublist if it isn't already
this.toggleCollapsed();
} else if (this.sublistRef.current) {
// otherwise focus the first room
const element = this.sublistRef.current.querySelector(".mx_RoomTile") as HTMLDivElement;
if (element) {
element.focus();
}
}
break;
}
}
};
private onKeyDown = (ev: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
// On ArrowLeft go to the sublist header
case KeyBindingAction.ArrowLeft:
ev.stopPropagation();
this.headerButton.current?.focus();
break;
// Consume ArrowRight so it doesn't cause focus to get sent to composer
case KeyBindingAction.ArrowRight:
ev.stopPropagation();
}
};
private renderVisibleTiles(): React.ReactElement[] {
if (!this.state.isExpanded && !this.props.forceExpanded) {
// don't waste time on rendering
return [];
}
const tiles: React.ReactElement[] = [];
if (this.state.rooms) {
let visibleRooms = this.state.rooms;
if (!this.props.forceExpanded) {
visibleRooms = visibleRooms.slice(0, this.numVisibleTiles);
}
for (const room of visibleRooms) {
tiles.push(
<RoomTile
room={room}
key={`room-${room.roomId}`}
showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.tagId}
/>,
);
}
}
if (this.extraTiles) {
// HACK: We break typing here, but this 'extra tiles' property shouldn't exist.
(tiles as any[]).push(...this.extraTiles);
}
// We only have to do this because of the extra tiles. We do it conditionally
// to avoid spending cycles on slicing. It's generally fine to do this though
// as users are unlikely to have more than a handful of tiles when the extra
// tiles are used.
if (tiles.length > this.numVisibleTiles && !this.props.forceExpanded) {
return tiles.slice(0, this.numVisibleTiles);
}
return tiles;
}
private renderMenu(): ReactNode {
if (this.props.tagId === DefaultTagID.Suggested) return null; // not sortable
let contextMenu: JSX.Element | undefined;
if (this.state.contextMenuPosition) {
let isAlphabetical = RoomListStore.instance.getTagSorting(this.getTagId()) === SortAlgorithm.Alphabetic;
let isUnreadFirst = RoomListStore.instance.getListOrder(this.getTagId()) === ListAlgorithm.Importance;
if (this.slidingSyncMode) {
const slidingList = SlidingSyncManager.instance.slidingSync?.getListParams(this.props.tagId);
isAlphabetical = (slidingList?.sort || [])[0] === "by_name";
isUnreadFirst = (slidingList?.sort || [])[0] === "by_notification_level";
}
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections: JSX.Element | undefined;
if (this.props.tagId !== DefaultTagID.Invite) {
otherSections = (
<React.Fragment>
<hr />
<fieldset>
<legend className="mx_RoomSublist_contextMenu_title">{_t("common|appearance")}</legend>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onUnreadFirstChanged}
checked={isUnreadFirst}
>
{_t("room_list|sort_unread_first")}
</StyledMenuItemCheckbox>
<StyledMenuItemCheckbox
onClose={this.onCloseMenu}
onChange={this.onMessagePreviewChanged}
checked={this.layout.showPreviews}
>
{_t("room_list|show_previews")}
</StyledMenuItemCheckbox>
</fieldset>
</React.Fragment>
);
}
contextMenu = (
<ContextMenu
chevronFace={ChevronFace.None}
left={this.state.contextMenuPosition.left}
top={this.state.contextMenuPosition.top + this.state.contextMenuPosition.height}
onFinished={this.onCloseMenu}
>
<div className="mx_RoomSublist_contextMenu">
<fieldset>
<legend className="mx_RoomSublist_contextMenu_title">{_t("room_list|sort_by")}</legend>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Recent)}
checked={!isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("room_list|sort_by_activity")}
</StyledMenuItemRadio>
<StyledMenuItemRadio
onClose={this.onCloseMenu}
onChange={() => this.onTagSortChanged(SortAlgorithm.Alphabetic)}
checked={isAlphabetical}
name={`mx_${this.props.tagId}_sortBy`}
>
{_t("room_list|sort_by_alphabet")}
</StyledMenuItemRadio>
</fieldset>
{otherSections}
</div>
</ContextMenu>
);
}
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_RoomSublist_menuButton"
onClick={this.onOpenMenuClick}
title={_t("room_list|sublist_options")}
isExpanded={!!this.state.contextMenuPosition}
/>
{contextMenu}
</React.Fragment>
);
}
private renderHeader(): React.ReactElement {
return (
<RovingTabIndexWrapper inputRef={this.headerButton}>
{({ onFocus, isActive, ref }) => {
const tabIndex = isActive ? 0 : -1;
let ariaLabel = _t("a11y_jump_first_unread_room");
if (this.props.tagId === DefaultTagID.Invite) {
ariaLabel = _t("a11y|jump_first_invite");
}
const badge = (
<NotificationBadge
forceCount={true}
notification={this.notificationState}
onClick={this.onBadgeClick}
tabIndex={tabIndex}
aria-label={ariaLabel}
showUnsentTooltip={true}
/>
);
let addRoomButton: JSX.Element | undefined;
if (this.props.AuxButtonComponent) {
const AuxButtonComponent = this.props.AuxButtonComponent;
addRoomButton = <AuxButtonComponent tabIndex={tabIndex} />;
}
const collapseClasses = classNames({
mx_RoomSublist_collapseBtn: true,
mx_RoomSublist_collapseBtn_collapsed: !this.state.isExpanded && !this.props.forceExpanded,
});
const classes = classNames({
mx_RoomSublist_headerContainer: true,
mx_RoomSublist_headerContainer_withAux: !!addRoomButton,
});
const badgeContainer = <div className="mx_RoomSublist_badgeContainer">{badge}</div>;
let Button: React.ComponentType<React.ComponentProps<typeof AccessibleButton>> = AccessibleButton;
if (this.props.isMinimized) {
Button = AccessibleTooltipButton;
}
// Note: the addRoomButton conditionally gets moved around
// the DOM depending on whether or not the list is minimized.
// If we're minimized, we want it below the header so it
// doesn't become sticky.
// The same applies to the notification badge.
return (
<div
className={classes}
onKeyDown={this.onHeaderKeyDown}
onFocus={onFocus}
aria-label={this.props.label}
role="treeitem"
aria-expanded={this.state.isExpanded}
aria-level={1}
aria-selected="false"
>
<div className="mx_RoomSublist_stickableContainer">
<div className="mx_RoomSublist_stickable">
<Button
onFocus={onFocus}
ref={ref}
tabIndex={tabIndex}
className="mx_RoomSublist_headerText"
aria-expanded={this.state.isExpanded}
onClick={this.onHeaderClick}
onContextMenu={this.onContextMenu}
title={this.props.isMinimized ? this.props.label : undefined}
>
<span className={collapseClasses} />
<span id={getLabelId(this.props.tagId)}>{this.props.label}</span>
</Button>
{this.renderMenu()}
{this.props.isMinimized ? null : badgeContainer}
{this.props.isMinimized ? null : addRoomButton}
</div>
</div>
{this.props.isMinimized ? badgeContainer : null}
{this.props.isMinimized ? addRoomButton : null}
</div>
);
}}
</RovingTabIndexWrapper>
);
}
private onScrollPrevent(e: Event): void {
// the RoomTile calls scrollIntoView and the browser may scroll a div we do not wish to be scrollable
// this fixes https://github.com/vector-im/element-web/issues/14413
(e.target as HTMLDivElement).scrollTop = 0;
}
public render(): React.ReactElement {
const visibleTiles = this.renderVisibleTiles();
const hidden = !this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true;
const classes = classNames({
mx_RoomSublist: true,
mx_RoomSublist_hasMenuOpen: !!this.state.contextMenuPosition,
mx_RoomSublist_minimized: this.props.isMinimized,
mx_RoomSublist_hidden: hidden,
});
let content: JSX.Element | undefined;
if (this.state.roomsLoading) {
content = <div className="mx_RoomSublist_skeletonUI" />;
} else if (visibleTiles.length > 0 && this.props.forceExpanded) {
content = (
<div className="mx_RoomSublist_resizeBox mx_RoomSublist_resizeBox_forceExpanded">
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{visibleTiles}
</div>
</div>
);
} else if (visibleTiles.length > 0) {
const layout = this.layout; // to shorten calls
const minTiles = Math.min(layout.minVisibleTiles, this.numTiles);
const showMoreAtMinHeight = minTiles < this.numTiles;
const minHeightPadding = RESIZE_HANDLE_HEIGHT + (showMoreAtMinHeight ? SHOW_N_BUTTON_HEIGHT : 0);
const minTilesPx = layout.tilesToPixelsWithPadding(minTiles, minHeightPadding);
const maxTilesPx = layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
const showMoreBtnClasses = classNames({
mx_RoomSublist_showNButton: true,
});
// If we're hiding rooms, show a 'show more' button to the user. This button
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
let showNButton: JSX.Element | undefined;
const hasMoreSlidingSync =
this.slidingSyncMode && RoomListStore.instance.getCount(this.getTagId()) > this.state.rooms.length;
if (maxTilesPx > this.state.height || hasMoreSlidingSync) {
// the height of all the tiles is greater than the section height: we need a 'show more' button
const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
let numMissing = this.numTiles - amountFullyShown;
if (this.slidingSyncMode) {
numMissing = RoomListStore.instance.getCount(this.getTagId()) - amountFullyShown;
}
const label = _t("room_list|show_n_more", { count: numMissing });
let showMoreText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showMoreText = null;
showNButton = (
<RovingAccessibleButton
role="treeitem"
onClick={this.onShowAllClick}
className={showMoreBtnClasses}
aria-label={label}
>
<span className="mx_RoomSublist_showMoreButtonChevron mx_RoomSublist_showNButtonChevron">
{/* set by CSS masking */}
</span>
{showMoreText}
</RovingAccessibleButton>
);
} else if (this.numTiles > this.layout.defaultVisibleTiles) {
// we have all tiles visible - add a button to show less
const label = _t("room_list|show_less");
let showLessText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showLessText = null;
showNButton = (
<RovingAccessibleButton
role="treeitem"
onClick={this.onShowLessClick}
className={showMoreBtnClasses}
aria-label={label}
>
<span className="mx_RoomSublist_showLessButtonChevron mx_RoomSublist_showNButtonChevron">
{/* set by CSS masking */}
</span>
{showLessText}
</RovingAccessibleButton>
);
}
// Figure out if we need a handle
const handles: Enable = {
bottom: true, // the only one we need, but the others must be explicitly false
bottomLeft: false,
bottomRight: false,
left: false,
right: false,
top: false,
topLeft: false,
topRight: false,
};
if (layout.visibleTiles >= this.numTiles && this.numTiles <= layout.minVisibleTiles) {
// we're at a minimum, don't have a bottom handle
handles.bottom = false;
}
// We have to account for padding so we can accommodate a 'show more' button and
// the resize handle, which are pinned to the bottom of the container. This is the
// easiest way to have a resize handle below the button as otherwise we're writing
// our own resize handling and that doesn't sound fun.
//
// The layout class has some helpers for dealing with padding, as we don't want to
// apply it in all cases. If we apply it in all cases, the resizing feels like it
// goes backwards and can become wildly incorrect (visibleTiles says 18 when there's
// only mathematically 7 possible).
const handleWrapperClasses = classNames({
mx_RoomSublist_resizerHandles: true,
mx_RoomSublist_resizerHandles_showNButton: !!showNButton,
});
content = (
<React.Fragment>
<Resizable
size={{ height: this.state.height } as any}
minHeight={minTilesPx}
maxHeight={maxTilesPx}
onResizeStart={this.onResizeStart}
onResizeStop={this.onResizeStop}
onResize={this.onResize}
handleWrapperClass={handleWrapperClasses}
handleClasses={{ bottom: "mx_RoomSublist_resizerHandle" }}
className="mx_RoomSublist_resizeBox"
enable={handles}
>
<div className="mx_RoomSublist_tiles" ref={this.tilesRef}>
{visibleTiles}
</div>
{showNButton}
</Resizable>
</React.Fragment>
);
} else if (this.props.showSkeleton && this.state.isExpanded) {
content = <div className="mx_RoomSublist_skeletonUI" />;
}
return (
<div
ref={this.sublistRef}
className={classes}
role="group"
aria-hidden={hidden}
aria-labelledby={getLabelId(this.props.tagId)}
onKeyDown={this.onKeyDown}
>
{this.renderHeader()}
{content}
</div>
);
}
}

View File

@ -0,0 +1,7 @@
import { DefaultTagID } from "matrix-react-sdk/src/stores/room-list/models";
export enum SuperheroTagID {
CommunityRooms = "CommunityRooms",
}
export type CustomTagID = string | DefaultTagID | SuperheroTagID;