feat: Group TG and community rooms in sidebar
parent
4b2e1a4a92
commit
49764d1a83
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
Loading…
Reference in New Issue