Delabs room list

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2025-12-02 16:04:20 +00:00
parent 16fbb27983
commit 3c673f7a8b
No known key found for this signature in database
GPG Key ID: A2B008A5F49F5D0D
57 changed files with 62 additions and 5248 deletions

View File

@ -108,7 +108,3 @@ Unreliable in encrypted rooms.
## Knock rooms (`feature_ask_to_join`) [In Development]
Enables knock feature for rooms. This allows users to ask to join a room.
## New room list (`feature_new_room_list`) [In Development]
Enable the new room list that is currently in development.

View File

@ -18,7 +18,6 @@ test.describe("Room list filters and sort", () => {
displayName: "BotBob",
autoAcceptInvites: true,
},
labsFlags: ["feature_new_room_list"],
});
function getPrimaryFilters(page: Page): Locator {

View File

@ -9,10 +9,6 @@ import { test, expect } from "../../../element-web-test";
import type { Page } from "@playwright/test";
test.describe("Header section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the header section of the room list
* @param page

View File

@ -10,10 +10,6 @@ import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Room list panel", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the room list view
* @param page

View File

@ -10,10 +10,6 @@ import { type Page } from "@playwright/test";
import { test, expect } from "../../../element-web-test";
test.describe("Search section of the room list", () => {
test.use({
labsFlags: ["feature_new_room_list"],
});
/**
* Get the search section of the room list
* @param page

View File

@ -12,7 +12,6 @@ import { expect, test } from "../../../element-web-test";
test.describe("Room list", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_new_room_list"],
botCreateOpts: {
displayName: "BotBob",
},
@ -276,7 +275,7 @@ test.describe("Room list", () => {
});
test.describe("Avatar decoration", () => {
test.use({ labsFlags: ["feature_video_rooms", "feature_new_room_list"] });
test.use({ labsFlags: ["feature_video_rooms"] });
test("should be a public room", { tag: "@screenshot" }, async ({ page, app, user }) => {
// @ts-ignore Visibility enum is not accessible

View File

@ -22,7 +22,6 @@ test.describe("Release announcement", () => {
await app.viewRoomById(roomId);
await use({ roomId });
},
labsFlags: ["feature_new_room_list"],
});
test(

View File

@ -15,11 +15,6 @@ import { type ElementAppPage } from "../../pages/ElementAppPage";
test.describe("Room Header", () => {
test.use({
displayName: "Sakura",
config: {
features: {
feature_new_room_list: false,
},
},
});
test.describe("with feature_notifications enabled", () => {

View File

@ -21,8 +21,6 @@ test.describe("Preferences user settings tab", () => {
const locator = await app.settings.openUserSettings("Preferences");
await use(locator);
},
// display message preview settings
labsFlags: ["feature_new_room_list"],
});
test("should be rendered properly", { tag: "@screenshot" }, async ({ app, page, user }) => {

View File

@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
.mx_QuickThemeSwitcher {
display: flex;
align-items: center;
margin-top: var(--cpd-space-2x);
.mx_Dropdown {
min-width: 100px;

View File

@ -1,29 +0,0 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_BackdropPanel {
position: absolute;
left: 0;
top: 0;
height: 100vh;
width: 100%;
overflow: hidden;
filter: blur(var(--lp-background-blur));
/* Force a new layer for the backdropPanel so it's better hardware supported */
transform: translateZ(0);
}
.mx_BackdropPanel--image {
position: absolute;
top: 0;
left: 0;
min-height: 100%;
z-index: 0;
pointer-events: none;
overflow: hidden;
user-select: none;
}

View File

@ -180,27 +180,6 @@ Please see LICENSE files in the repository root for full details.
.mx_LegacyRoomListHeader:first-child {
margin-top: 12px;
}
.mx_LeftPanel_roomListWrapper {
/* Make the y-scrollbar more responsive */
padding-right: 2px;
overflow: hidden;
margin-top: 10px; /* so we're not up against the search/filter */
flex: 1 0 0; /* needed in Safari to properly set flex-basis */
&.mx_LeftPanel_roomListWrapper_stickyBottom {
padding-bottom: 32px;
}
&.mx_LeftPanel_roomListWrapper_stickyTop {
padding-top: 32px;
}
}
.mx_LeftPanel_actualRoomListContainer {
position: relative; /* for sticky headers */
height: 100%; /* ensure scrolling still works */
}
}
/* These styles override the defaults for the minimized (66px) layout */

View File

@ -110,12 +110,6 @@ Please see LICENSE files in the repository root for full details.
}
}
.mx_QuickSettingsButton_ContextMenuWrapper_new_room_list {
.mx_QuickThemeSwitcher {
margin-top: var(--cpd-space-2x);
}
}
.mx_QuickSettingsButton_icon {
margin-right: var(--cpd-space-1x);
color: $secondary-content;

View File

@ -1,93 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/* Note: this component expects to be contained within a flexbox */
.mx_RoomSearch {
flex: 1;
min-width: 0;
border-radius: 8px;
background-color: $panel-actions;
/* keep border thickness consistent to prevent movement */
border: 1px solid transparent;
height: 28px;
padding: 1px;
/* Create a flexbox for the icons (easier to manage) */
display: flex;
align-items: center;
cursor: pointer;
.mx_RoomSearch_icon {
width: 20px;
height: 20px;
color: $secondary-content;
margin-left: var(--cpd-space-2x);
flex-shrink: 0;
}
.mx_RoomSearch_spotlightTriggerText {
color: var(--cpd-color-text-secondary);
flex: 1;
min-width: 0;
/* the following rules are to match that of a real input field */
overflow: hidden;
margin: 9px;
font: var(--cpd-font-body-sm-semibold);
}
.mx_RoomSearch_shortcutPrompt {
border-radius: 6px;
background-color: $panel-actions;
padding: 2px 4px;
user-select: none;
font-size: $font-12px;
line-height: $font-15px;
font-family: inherit;
font-weight: var(--cpd-font-weight-semibold);
color: $light-fg-color;
margin-right: 6px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&.mx_RoomSearch_minimized {
height: 32px;
min-height: 32px;
width: 32px;
box-sizing: border-box;
.mx_RoomSearch_icon {
margin: 0 auto;
padding: 1px;
align-self: center;
}
.mx_RoomSearch_shortcutPrompt {
display: none;
}
}
&:hover {
background-color: $tertiary-content;
.mx_RoomSearch_spotlightTriggerText {
color: $background;
}
.mx_RoomSearch_shortcutPrompt {
background-color: $background;
color: $secondary-content;
}
.mx_RoomSearch_icon {
color: $background;
}
}
}

View File

@ -14,7 +14,8 @@ Please see LICENSE files in the repository root for full details.
--height-nested: 24px;
--height-topLevel: 32px;
background-color: $spacePanel-bg-color;
background-color: var(--cpd-color-bg-canvas-default);
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
flex: 0 0 auto;
padding: 0;
margin: 0;
@ -30,11 +31,6 @@ Please see LICENSE files in the repository root for full details.
width: 68px;
}
&.newUi {
background-color: var(--cpd-color-bg-canvas-default);
border-right: 1px solid var(--cpd-color-bg-subtle-primary);
}
.mx_SpacePanel_toggleCollapse {
position: absolute;
width: 18px;
@ -397,9 +393,10 @@ Please see LICENSE files in the repository root for full details.
.mx_UserMenu {
padding-bottom: 12px;
border-bottom: 1px solid $separator;
margin: 12px 14px 4px 18px;
margin: var(--cpd-space-4x) 14px 4px 18px;
width: min-content;
max-width: 226px;
border-bottom: none;
/* Display the container and img here as block elements so they don't take
* up extra vertical space.
@ -408,11 +405,6 @@ Please see LICENSE files in the repository root for full details.
display: block;
}
}
&.newUi .mx_UserMenu {
margin-top: var(--cpd-space-4x);
border-bottom: none;
}
}
.mx_SpacePanel_contextMenu {

View File

@ -1,33 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_LegacyRoomList {
padding-right: 7px; /* width of the scrollbar, to line things up */
}
.mx_LegacyRoomList_iconPlus::before {
mask-image: url("$(res)/img/element-icons/roomlist/plus-circle.svg");
}
.mx_LegacyRoomList_iconNewRoom::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-plus.svg");
}
.mx_LegacyRoomList_iconNewVideoRoom::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-video.svg");
}
.mx_LegacyRoomList_iconAddExistingRoom::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash.svg");
}
.mx_LegacyRoomList_iconExplore::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-search.svg");
}
.mx_LegacyRoomList_iconStartChat::before {
mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg");
}
.mx_LegacyRoomList_iconInvite::before {
mask-image: url("$(res)/img/element-icons/room/share.svg");
}

View File

@ -1,108 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_LegacyRoomListHeader {
display: flex;
align-items: center;
.mx_LegacyRoomListHeader_contextLessTitle,
.mx_LegacyRoomListHeader_contextMenuButton {
font: var(--cpd-font-heading-sm-semibold);
font-weight: var(--cpd-font-weight-semibold);
padding: 1px 24px 1px 4px;
position: relative;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: 8px;
margin-right: auto;
user-select: none;
}
.mx_LegacyRoomListHeader_contextMenuButton {
border-radius: 6px;
&:hover {
background-color: $quinary-content;
}
&::before {
content: "";
width: 20px;
height: 20px;
top: 3px;
right: 0;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background-color: $tertiary-content;
mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg");
}
&[aria-expanded="true"] {
background-color: $quinary-content;
&::before {
transform: rotate(180deg);
}
}
}
.mx_LegacyRoomListHeader_plusButton {
width: 32px;
height: 32px;
border-radius: 8px;
position: relative;
padding: 8px;
margin-left: 8px;
margin-right: 12px;
background-color: $panel-actions;
box-sizing: border-box;
flex-shrink: 0;
&::before {
content: "";
width: 16px;
height: 16px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background-color: $secondary-content;
mask-image: url("@vector-im/compound-design-tokens/icons/plus.svg");
}
&:hover {
background-color: $tertiary-content;
&::before {
background-color: $background;
}
}
}
}
.mx_LegacyRoomListHeader_iconInvite::before {
mask-image: url("$(res)/img/element-icons/room/invite.svg");
}
.mx_LegacyRoomListHeader_iconStartChat::before {
mask-image: url("@vector-im/compound-design-tokens/icons/user-add-solid.svg");
}
.mx_LegacyRoomListHeader_iconNewRoom::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-plus.svg");
}
.mx_LegacyRoomListHeader_iconNewVideoRoom::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-video.svg");
}
.mx_LegacyRoomListHeader_iconExplore::before {
mask-image: url("$(res)/img/element-icons/roomlist/hash-search.svg");
}
.mx_LegacyRoomListHeader_iconPlus::before {
mask-image: url("@vector-im/compound-design-tokens/icons/plus.svg");
}

View File

@ -1,43 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_RoomBreadcrumbs {
width: 100%;
/* Create a flexbox for the crumbs */
display: flex;
flex-direction: row;
align-items: flex-start;
margin-bottom: 12px;
.mx_RoomBreadcrumbs_crumb {
margin-right: 8px;
width: 32px;
}
/* These classes come from the CSSTransition component. There's many more classes we */
/* could care about, but this is all we worried about for now. The animation works by */
/* first triggering the enter state with the newest breadcrumb off screen (-40px) then */
/* sliding it into view. */
&.mx_RoomBreadcrumbs-enter {
transform: translateX(-40px); /* 32px for the avatar, 8px for the margin */
}
&.mx_RoomBreadcrumbs-enter-active {
transform: translateX(0);
/* Timing function is as-requested by design. */
/* NOTE: The transition time MUST match the value passed to CSSTransition! */
transition: transform 640ms cubic-bezier(0.66, 0.02, 0.36, 1);
}
.mx_RoomBreadcrumbs_placeholder {
font: var(--cpd-font-body-md-semibold);
line-height: 32px; /* specifically to match the height this is not scaled */
height: 32px;
}
}

View File

@ -1,422 +0,0 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
.mx_RoomSublist {
margin-left: 8px;
margin-bottom: 4px;
&.mx_RoomSublist_hidden {
display: none;
}
&:not(.mx_RoomSublist_minimized) {
.mx_RoomSublist_headerContainer {
height: auto;
}
}
.mx_RoomSublist_headerContainer {
/* Create a flexbox to make alignment easy */
display: flex;
align-items: center;
/* *************************** */
/* Sticky Headers Start */
/* Ideally we'd be able to use `position: sticky; top: 0; bottom: 0;` on the */
/* headerContainer, however due to our layout concerns we actually have to */
/* calculate it manually so we can sticky things in the right places. We also */
/* target the headerText instead of the container to reduce jumps when scrolling, */
/* and to help hide the badges/other buttons that could appear on hover. This */
/* all works by ensuring the header text has a fixed height when sticky so the */
/* fixed height of the container can maintain the scroll position. */
/* The combined height must be set in the LeftPanel component for sticky headers */
/* to work correctly. */
padding-bottom: 8px;
height: 24px;
color: $secondary-content;
.mx_RoomSublist_stickableContainer {
width: 100%;
}
.mx_RoomSublist_stickable {
flex: 1;
max-width: 100%;
/* Create a flexbox to make ordering easy */
display: flex;
align-items: center;
/* We use a generic sticky class for 2 reasons: to reduce style duplication and */
/* to identify when a header is sticky. If we didn't have a consistent sticky class, */
/* we'd have to do the "is sticky" checks again on click, as clicking the header */
/* when sticky scrolls instead of collapses the list. */
&.mx_RoomSublist_headerContainer_sticky {
position: fixed;
height: 32px; /* to match the header container */
/* width set by JS because of a compat issue between Firefox and Chrome */
width: calc(100% - 15px);
}
/* We don't have a top style because the top is dependent on the room list header's */
/* height, and is therefore calculated in JS. */
/* The class, mx_RoomSublist_headerContainer_stickyTop, is applied though. */
}
/* Sticky Headers End */
/* *************************** */
.mx_RoomSublist_badgeContainer {
/* Create another flexbox row because it's super easy to position the badge this way. */
display: flex;
align-items: center;
justify-content: center;
/* Apply the width and margin to the badge so the container doesn't occupy dead space */
.mx_NotificationBadge {
/* Do not set a width so the badges get properly sized */
margin-left: 8px; /* same as menu+aux buttons */
}
}
&:not(.mx_RoomSublist_headerContainer_withAux) {
.mx_NotificationBadge {
margin-right: 4px; /* just to push it over a bit, aligning it with the other elements */
}
}
.mx_RoomSublist_auxButton,
.mx_RoomSublist_menuButton {
margin-left: 8px; /* should be the same as the notification badge */
position: relative;
width: 24px;
height: 24px;
border-radius: 8px;
&::before {
content: "";
width: 16px;
height: 16px;
position: absolute;
top: 4px;
left: 4px;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: var(--cpd-color-icon-secondary);
}
}
.mx_RoomSublist_auxButton:hover,
.mx_RoomSublist_menuButton:hover {
background: $panel-actions;
}
/* Hide the menu button by default */
.mx_RoomSublist_menuButton {
visibility: hidden;
width: 0;
margin: 0;
}
.mx_RoomSublist_auxButton::before {
mask-image: url("@vector-im/compound-design-tokens/icons/plus.svg");
}
.mx_RoomSublist_menuButton::before {
mask-image: url("@vector-im/compound-design-tokens/icons/overflow-horizontal.svg");
}
.mx_RoomSublist_headerText {
flex: 1;
max-width: calc(100% - 16px); /* 16px is the badge width */
font: var(--cpd-font-body-sm-semibold);
/* Ellipsize any text overflow */
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
.mx_RoomSublist_collapseBtn {
display: inline-block;
position: relative;
width: 14px;
height: 14px;
margin-right: 6px;
&::before {
content: "";
width: 18px;
height: 18px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background-color: var(--cpd-color-icon-secondary);
mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg");
}
&.mx_RoomSublist_collapseBtn_collapsed::before {
transform: rotate(-90deg);
}
}
}
}
/* In the general case, we reserve space for each sublist header to prevent */
/* scroll jumps when they become sticky. However, that leaves a gap when */
/* scrolled to the top above the first sublist (whose header can only ever */
/* stick to top), so we make sure to exclude the first visible sublist. */
&:not(.mx_RoomSublist_hidden) ~ .mx_RoomSublist .mx_RoomSublist_stickableContainer {
height: 24px;
}
.mx_RoomSublist_resizeBox {
position: relative;
/* Create another flexbox column for the tiles */
display: flex;
flex-direction: column;
overflow: hidden;
.mx_RoomSublist_tiles {
flex: 1 0 0;
overflow: hidden;
overflow: clip;
/* need this to be flex otherwise the overflow hidden from above */
/* sometimes vertically centers the clipped list ... no idea why it would do this */
/* as the box model should be top aligned. Happens in both FF and Chromium */
display: flex;
flex-direction: column;
align-self: stretch;
/* without this Firefox will prefer pushing the resizer & show more/less button into the overflow */
min-height: 0;
mask-image: linear-gradient(0deg, transparent, black 4px);
}
&.mx_RoomSublist_resizeBox_forceExpanded .mx_RoomSublist_tiles {
/* in this state the div can collapse its height entirely in Chromium, */
/* so prevent that by allowing overflow */
overflow: visible;
/* clear the min-height to make it not collapse entirely in a state with no active resizer */
min-height: unset;
}
.mx_RoomSublist_resizerHandles_showNButton {
flex: 0 0 32px;
}
.mx_RoomSublist_resizerHandles {
flex: 0 0 4px;
display: flex;
justify-content: center;
width: 100%;
}
/* Class name comes from the ResizableBox component */
/* The hover state needs to use the whole sublist, not just the resizable box, */
/* so that selector is below and one level higher. */
.mx_RoomSublist_resizerHandle {
cursor: ns-resize;
border-radius: 3px;
/* Override styles from library */
max-width: 64px;
height: 4px !important; /* Update RESIZE_HANDLE_HEIGHT if this changes */
/* This is positioned directly below the 'show more' button. */
position: relative !important;
bottom: 0 !important; /* override from library */
}
&:hover,
&.mx_RoomSublist_hasMenuOpen {
.mx_RoomSublist_resizerHandle {
opacity: 0.8;
background-color: $primary-content;
}
}
}
.mx_RoomSublist_showNButton {
cursor: pointer;
font-size: $font-13px;
line-height: $font-18px;
color: $secondary-content;
/* Update the render() function for RoomSublist if these change */
/* Update the ListLayout class for minVisibleTiles if these change. */
height: 24px;
padding-bottom: 4px;
/* We create a flexbox to cheat at alignment */
display: flex;
align-items: center;
.mx_RoomSublist_showNButtonChevron {
position: relative;
width: 18px;
height: 18px;
margin-left: 12px;
margin-right: 16px;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: $tertiary-content;
left: -1px; /* adjust for image position */
}
.mx_RoomSublist_showMoreButtonChevron,
.mx_RoomSublist_showLessButtonChevron {
mask-image: url("@vector-im/compound-design-tokens/icons/chevron-down.svg");
}
.mx_RoomSublist_showLessButtonChevron {
transform: rotate(180deg);
}
}
&.mx_RoomSublist_hasMenuOpen,
&:not(.mx_RoomSublist_minimized) > .mx_RoomSublist_headerContainer:focus-within,
&:not(.mx_RoomSublist_minimized) > .mx_RoomSublist_headerContainer:hover {
.mx_RoomSublist_menuButton {
visibility: visible;
width: 24px;
margin-left: 8px;
}
}
&.mx_RoomSublist_minimized {
.mx_RoomSublist_headerContainer {
height: auto;
flex-direction: column;
position: relative;
.mx_RoomSublist_badgeContainer {
order: 0;
align-self: flex-end;
margin-right: 0;
}
.mx_RoomSublist_stickable {
order: 1;
max-width: 100%;
}
.mx_RoomSublist_auxButton {
order: 2;
visibility: visible;
width: 32px !important; /* !important to override hover styles */
height: 32px !important; /* !important to override hover styles */
margin-left: 0 !important; /* !important to override hover styles */
background-color: $panel-actions;
margin-top: 8px;
&::before {
top: 8px;
left: 8px;
}
}
}
.mx_RoomSublist_resizeBox {
align-items: center;
}
.mx_RoomSublist_showNButton {
flex-direction: column;
.mx_RoomSublist_showNButtonChevron {
margin-right: 12px; /* to center */
}
}
.mx_RoomSublist_menuButton {
height: 16px;
}
&.mx_RoomSublist_hasMenuOpen,
& > .mx_RoomSublist_headerContainer:hover {
.mx_RoomSublist_menuButton {
visibility: visible;
position: absolute;
bottom: 48px; /* align to middle of name, 40px for aux button (with padding) and 8px for alignment */
right: 0;
width: 16px;
height: 16px;
border-radius: 0;
z-index: 1; /* occlude the list name */
/* This is the same color as the left panel background because it needs */
/* to occlude the sublist title */
background-color: $roomlist-bg-color;
&::before {
top: 0;
left: 0;
}
}
&.mx_RoomSublist_headerContainer:not(.mx_RoomSublist_headerContainer_withAux) {
.mx_RoomSublist_menuButton {
bottom: 8px; /* align to the middle of name, 40px less than the `bottom` above. */
}
}
}
}
}
.mx_RoomSublist_contextMenu {
padding: 20px 16px;
width: 250px;
hr {
margin-top: 16px;
margin-bottom: 16px;
margin-right: 16px; /* additional 16px */
border: 1px solid $primary-content;
opacity: 0.1;
}
.mx_RoomSublist_contextMenu_title {
font-size: $font-15px;
line-height: $font-20px;
font-weight: var(--cpd-font-weight-semibold);
margin-bottom: 4px;
}
.mx_StyledRadioButton {
margin-top: 8px;
}
}
.mx_RoomSublist_skeletonUI {
position: relative;
margin-left: 4px;
height: 240px;
&::before {
background-color: var(--cpd-color-bg-subtle-secondary);
width: 100%;
height: 100%;
content: "";
position: absolute;
mask-repeat: repeat-y;
mask-size: auto 48px;
mask-image: url("$(res)/img/element-icons/roomlist/skeleton-ui.svg");
}
}
.mx_RoomSublist_minimized .mx_RoomSublist_skeletonUI {
width: 32px; /* cut off the horizontal lines in the svg */
margin-left: 10px; /* align with sublist + buttons */
}

View File

@ -1,167 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
/* Note: the room tile expects to be in a flexbox column container */
.mx_RoomTile {
margin-bottom: 4px;
padding: 4px;
/* The tile is also a flexbox row itself */
display: flex;
contain: content; /* Not strict as it will break when resizing a sublist vertically */
box-sizing: border-box;
font-size: var(--cpd-font-size-body-sm);
&.mx_RoomTile_selected,
&:hover,
&:focus-within,
&.mx_RoomTile_hasMenuOpen {
background-color: $panel-actions;
border-radius: 8px;
}
.mx_DecoratedRoomAvatar,
.mx_RoomTile_avatarContainer {
margin-right: 10px;
}
.mx_RoomTile_details {
min-width: 0;
}
.mx_RoomTile_titleContainer {
height: 32px;
min-width: 0;
flex-basis: 0;
flex-grow: 1;
margin-right: 8px; /* spacing to buttons/badges */
/* Create a new column layout flexbox for the title parts */
display: flex;
flex-direction: column;
justify-content: center;
.mx_RoomTile_subtitle {
align-items: center;
color: $secondary-content;
display: flex;
gap: $spacing-4;
line-height: 1.25;
position: relative;
top: -1px;
}
.mx_RoomTile_title,
.mx_RoomTile_subtitle_text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_RoomTile_title {
font: var(--cpd-font-body-md-regular);
line-height: 1.25;
&.mx_RoomTile_titleHasUnreadEvents {
font-weight: var(--cpd-font-weight-semibold);
}
}
.mx_RoomTile_titleWithSubtitle {
margin-top: -2px; /* shift the title up a bit more */
}
}
.mx_RoomTile_notificationsButton {
margin-left: 4px; /* spacing between buttons */
}
.mx_RoomTile_badgeContainer {
height: 16px;
/* don't set width so that it takes no space when there is no badge to show */
margin: auto 0; /* vertically align */
/* Create a flexbox to make aligning dot badges easier */
display: flex;
align-items: center;
.mx_NotificationBadge {
margin-right: 2px; /* centering */
}
.mx_NotificationBadge_dot {
/* make the smaller dot occupy the same width for centering */
margin-left: 5px;
margin-right: 7px;
}
}
/* The context menu buttons are hidden by default */
.mx_RoomTile_menuButton,
.mx_RoomTile_notificationsButton {
width: 20px;
min-width: 20px; /* yay flex */
height: 20px;
margin-top: auto;
margin-bottom: auto;
position: relative;
display: none;
&::before {
top: 2px;
left: 2px;
content: "";
width: 16px;
height: 16px;
position: absolute;
mask-position: center;
mask-size: contain;
mask-repeat: no-repeat;
background: var(--cpd-color-icon-primary);
}
}
/* If the room has an overriden notification setting then we always show the notifications menu button */
.mx_RoomTile_notificationsButton.mx_RoomTile_notificationsButton_show {
display: block;
}
.mx_RoomTile_menuButton::before {
mask-image: url("@vector-im/compound-design-tokens/icons/overflow-horizontal.svg");
}
&:not(.mx_RoomTile_minimized, .mx_RoomTile_sticky) {
&:hover,
&:focus-within,
&.mx_RoomTile_hasMenuOpen {
/* Hide the badge container on hover because it'll be a menu button */
.mx_RoomTile_badgeContainer {
width: 0;
height: 0;
display: none;
}
.mx_RoomTile_notificationsButton,
.mx_RoomTile_menuButton {
display: block;
}
}
}
&.mx_RoomTile_minimized {
flex-direction: column;
align-items: center;
position: relative;
.mx_DecoratedRoomAvatar,
.mx_RoomTile_avatarContainer {
margin-right: 0;
}
}
}

View File

@ -13,10 +13,6 @@ Please see LICENSE files in the repository root for full details.
margin-top: 8px;
position: relative;
&.mx_AvatarSetting_avatarDisplay:hover .mx_AvatarSetting_hover {
opacity: 1;
}
& > * {
box-sizing: border-box;
}

View File

@ -95,17 +95,6 @@ $accent-1400: var(--cpd-color-green-1400);
color: $primary-content;
}
.mx_RoomSearch {
&.mx_RoomSearch_focused,
&.mx_RoomSearch_hasQuery {
.mx_RoomSearch_clearButton {
&::before {
background-color: $background !important;
}
}
}
}
.mx_PollCreateDialog {
.mx_PollCreateDialog_option {
.mx_PollCreateDialog_removeOption {

View File

@ -1,13 +1,3 @@
/* sidebar blurred avatar background */
//
/* if backdrop-filter is supported, */
/* set the user avatar (if any) as a background so */
/* it can be blurred by the tag panel and room list */
.mx_RoomSublist_showNButton {
background-color: transparent !important;
}
a:hover,
a:link,
a:visited {

View File

@ -1,48 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
// This is intended to fix re-resizer because of its unguarded `instanceof TouchEvent` checks.
export function polyfillTouchEvent(): void {
// Firefox doesn't have touch events without touch devices being present, so create a fake
// one we can rely on lying about.
if (!window.TouchEvent) {
// We have no intention of actually using this, so just lie.
window.TouchEvent = class TouchEvent extends UIEvent {
public get altKey(): boolean {
return false;
}
public get changedTouches(): any {
return [];
}
public get ctrlKey(): boolean {
return false;
}
public get metaKey(): boolean {
return false;
}
public get shiftKey(): boolean {
return false;
}
public get targetTouches(): any {
return [];
}
public get touches(): any {
return [];
}
public get rotation(): number {
return 0.0;
}
public get scale(): number {
return 0.0;
}
public constructor(eventType: string, params?: any) {
super(eventType, params);
}
};
}
}

View File

@ -9,7 +9,6 @@
import { TimelineRenderingType } from "../contexts/RoomContext";
import { Action } from "../dispatcher/actions";
import defaultDispatcher from "../dispatcher/dispatcher";
import SettingsStore from "../settings/SettingsStore";
export const enum Landmark {
// This is the space/home button in the left panel.
@ -73,16 +72,10 @@ export class LandmarkNavigation {
const landmarkToDomElementMap: Record<Landmark, () => HTMLElement | null | undefined> = {
[Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector<HTMLElement>(".mx_SpaceButton_active"),
[Landmark.ROOM_SEARCH]: () =>
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListSearch_search")
: document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_SEARCH]: () => document.querySelector<HTMLElement>(".mx_RoomListSearch_search"),
[Landmark.ROOM_LIST]: () =>
SettingsStore.getValue("feature_new_room_list")
? document.querySelector<HTMLElement>(".mx_RoomListItemView_selected") ||
document.querySelector<HTMLElement>(".mx_RoomListItemView")
: document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
document.querySelector<HTMLElement>(".mx_RoomTile"),
document.querySelector<HTMLElement>(".mx_RoomListItemView_selected") ||
document.querySelector<HTMLElement>(".mx_RoomListItemView"),
[Landmark.MESSAGE_COMPOSER_OR_HOME]: () => {
const isComposerOpen = !!document.querySelector(".mx_MessageComposer");

View File

@ -1,75 +0,0 @@
/*
Copyright 2024,2025 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
import { KeyBindingAction } from "../KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton
}
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
onChange();
break;
case KeyBindingAction.Enter:
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
onClose();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
};
const onKeyUp = (e: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
case KeyBindingAction.Enter:
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
e.stopPropagation();
e.preventDefault();
break;
}
};
return (
<StyledCheckbox
{...props}
role="menuitemcheckbox"
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{children}
</StyledCheckbox>
);
};

View File

@ -1,77 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { useRovingTabIndex } from "../RovingTabIndex";
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
import { KeyBindingAction } from "../KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
label?: string;
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on KeyBindingAction.Enter
}
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Space:
onChange();
break;
case KeyBindingAction.Enter:
onChange();
// Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12
onClose();
break;
default:
handled = false;
}
if (handled) {
e.stopPropagation();
e.preventDefault();
}
};
const onKeyUp = (e: React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Enter:
case KeyBindingAction.Space:
// prevent the input default handler as we handle it on keydown to match
// https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html
e.stopPropagation();
e.preventDefault();
break;
}
};
return (
<StyledRadioButton
{...props}
role="menuitemradio"
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{children}
</StyledRadioButton>
);
};

View File

@ -1,33 +0,0 @@
/*
Copyright 2021-2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type CSSProperties } from "react";
interface IProps {
backgroundImage?: string;
blurMultiplier?: number;
}
export const BackdropPanel: React.FC<IProps> = ({ backgroundImage, blurMultiplier }) => {
if (!backgroundImage) return null;
const styles: CSSProperties = {};
if (blurMultiplier) {
const rootStyle = getComputedStyle(document.documentElement);
const blurValue = rootStyle.getPropertyValue("--lp-background-blur");
const pixelsValue = blurValue.replace("px", "");
const parsed = parseInt(pixelsValue, 10);
if (!isNaN(parsed)) {
styles.filter = `blur(${parsed * blurMultiplier}px)`;
}
}
return (
<div className="mx_BackdropPanel">
<img role="presentation" alt="" style={styles} className="mx_BackdropPanel--image" src={backgroundImage} />
</div>
);
};

View File

@ -583,6 +583,15 @@ export const alwaysAboveRightOf = (
return menuOptions;
};
// Placement method for <ContextMenu /> to position context menu below elementRect
export const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX + elementRect.width;
const top = elementRect.bottom + window.scrollY;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
type ContextMenuTuple<T> = [
boolean,
RefObject<T | null>,
@ -622,5 +631,3 @@ export { ContextMenuTooltipButton } from "../../accessibility/context_menu/Conte
export { MenuItem } from "../../accessibility/context_menu/MenuItem";
export { MenuItemCheckbox } from "../../accessibility/context_menu/MenuItemCheckbox";
export { MenuItemRadio } from "../../accessibility/context_menu/MenuItemRadio";
export { StyledMenuItemCheckbox } from "../../accessibility/context_menu/StyledMenuItemCheckbox";
export { StyledMenuItemRadio } from "../../accessibility/context_menu/StyledMenuItemRadio";

View File

@ -6,39 +6,22 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { createRef } from "react";
import React, { createRef } from "react";
import classNames from "classnames";
import dis from "../../dispatcher/dispatcher";
import { _t } from "../../languageHandler";
import LegacyRoomList from "../views/rooms/LegacyRoomList";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
import { HEADER_HEIGHT } from "../views/rooms/RoomSublist";
import { Action } from "../../dispatcher/actions";
import RoomSearch from "./RoomSearch";
import type ResizeNotifier from "../../utils/ResizeNotifier";
import SpaceStore from "../../stores/spaces/SpaceStore";
import { MetaSpace, type SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { type SpaceKey, UPDATE_SELECTED_SPACE } from "../../stores/spaces";
import UIStore from "../../stores/UIStore";
import { type IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
import LegacyRoomListHeader from "../views/rooms/LegacyRoomListHeader";
import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import IndicatorScrollbar from "./IndicatorScrollbar";
import RoomBreadcrumbs from "../views/rooms/RoomBreadcrumbs";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
import { shouldShowComponent } from "../../customisations/helpers/UIComponents";
import { UIComponent } from "../../settings/UIFeature";
import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton";
import PosthogTrackers from "../../PosthogTrackers";
import type PageType from "../../PageTypes";
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";
import SettingsStore from "../../settings/SettingsStore";
import { RoomListPanel } from "../views/rooms/RoomListPanel";
const HEADER_HEIGHT = 32; // As defined by CSS
interface IProps {
isMinimized: boolean;
pageType: PageType;
@ -58,8 +41,6 @@ interface IState {
export default class LeftPanel extends React.Component<IProps, IState> {
private listContainerRef = createRef<HTMLDivElement>();
private roomListRef = createRef<LegacyRoomList>();
private focusedElement: Element | null = null;
private isDoingStickyHeaders = false;
public constructor(props: IProps) {
@ -115,15 +96,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.setState({ activeSpace });
};
private onDialPad = (): void => {
dis.fire(Action.OpenDialPad);
};
private onExplore = (ev: ButtonEvent): void => {
dis.fire(Action.ViewRoomDirectory);
PosthogTrackers.trackInteraction("WebLeftPanelExploreRoomsButton", ev);
};
private refreshStickyHeaders = (): void => {
if (!this.listContainerRef.current) return; // ignore: no headers to sticky
this.handleStickyHeaders(this.listContainerRef.current);
@ -289,145 +261,17 @@ export default class LeftPanel extends React.Component<IProps, IState> {
this.handleStickyHeaders(list);
};
private onFocus = (ev: React.FocusEvent): void => {
this.focusedElement = ev.target;
};
private onBlur = (): void => {
this.focusedElement = null;
};
private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState): void => {
if (!this.focusedElement) return;
const action = getKeyBindingsManager().getRoomListAction(ev);
switch (action) {
case KeyBindingAction.NextRoom:
if (!state) {
ev.stopPropagation();
ev.preventDefault();
this.roomListRef.current?.focus();
}
break;
}
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.PreviousLandmark || navAction === KeyBindingAction.NextLandmark) {
ev.stopPropagation();
ev.preventDefault();
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_SEARCH,
navAction === KeyBindingAction.PreviousLandmark,
);
}
};
private renderBreadcrumbs(): React.ReactNode {
if (this.state.showBreadcrumbs === BreadcrumbsMode.Legacy && !this.props.isMinimized) {
return (
<IndicatorScrollbar
role="navigation"
aria-label={_t("a11y|recent_rooms")}
className="mx_LeftPanel_breadcrumbsContainer mx_AutoHideScrollbar"
verticalScrollsHorizontally={true}
>
<RoomBreadcrumbs />
</IndicatorScrollbar>
);
}
}
private renderSearchDialExplore(): React.ReactNode {
let dialPadButton: JSX.Element | undefined;
// If we have dialer support, show a button to bring up the dial pad to start a new call
if (this.state.supportsPstnProtocol) {
dialPadButton = (
<AccessibleButton
className="mx_LeftPanel_dialPadButton"
onClick={this.onDialPad}
title={_t("left_panel|open_dial_pad")}
/>
);
}
let rightButton: JSX.Element | undefined;
if (this.state.activeSpace === MetaSpace.Home && shouldShowComponent(UIComponent.ExploreRooms)) {
rightButton = (
<AccessibleButton
className="mx_LeftPanel_exploreButton"
onClick={this.onExplore}
title={_t("action|explore_rooms")}
/>
);
}
return (
<div
className="mx_LeftPanel_filterContainer"
onFocus={this.onFocus}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
role="search"
>
<RoomSearch isMinimized={this.props.isMinimized} />
{dialPadButton}
{rightButton}
</div>
);
}
public render(): React.ReactNode {
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
const containerClasses = classNames({
mx_LeftPanel: true,
mx_LeftPanel_newRoomList: useNewRoomList,
mx_LeftPanel_newRoomList: true,
mx_LeftPanel_minimized: this.props.isMinimized,
});
const roomListClasses = classNames("mx_LeftPanel_actualRoomListContainer", "mx_AutoHideScrollbar");
if (useNewRoomList) {
return (
<div className={containerClasses}>
<div className="mx_LeftPanel_roomListContainer">
<RoomListPanel activeSpace={this.state.activeSpace} />
</div>
</div>
);
}
const roomList = (
<LegacyRoomList
onKeyDown={this.onKeyDown}
resizeNotifier={this.props.resizeNotifier}
onFocus={this.onFocus}
onBlur={this.onBlur}
isMinimized={this.props.isMinimized}
activeSpace={this.state.activeSpace}
onResize={this.refreshStickyHeaders}
onListCollapse={this.refreshStickyHeaders}
ref={this.roomListRef}
/>
);
return (
<div className={containerClasses}>
<div className="mx_LeftPanel_roomListContainer">
{shouldShowComponent(UIComponent.FilterContainer) && this.renderSearchDialExplore()}
{this.renderBreadcrumbs()}
{!this.props.isMinimized && <LegacyRoomListHeader onVisibilityChange={this.refreshStickyHeaders} />}
<nav className="mx_LeftPanel_roomListWrapper" aria-label={_t("common|rooms")}>
<div
className={roomListClasses}
ref={this.listContainerRef}
// Firefox sometimes makes this element focusable due to
// overflow:scroll;, so force it out of tab order.
tabIndex={-1}
>
{roomList}
</div>
</nav>
<RoomListPanel activeSpace={this.state.activeSpace} />
</div>
</div>
);

View File

@ -53,7 +53,6 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { RoomView } from "./RoomView";
import ToastContainer from "./ToastContainer";
import UserView from "./UserView";
import { BackdropPanel } from "./BackdropPanel";
import { mediaFromMxc } from "../../customisations/Media";
import { UserTab } from "../views/dialogs/UserTab";
import { type OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload";
@ -283,25 +282,14 @@ class LoggedInView extends React.Component<IProps, IState> {
private createResizer(): Resizer<ICollapseConfig, CollapseItem> {
let panelSize: number | null;
let panelCollapsed: boolean;
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
// TODO decrease this once Spaces launches as it'll no longer need to include the 56px Community Panel
const toggleSize = useNewRoomList ? NEW_ROOM_LIST_MIN_WIDTH : 206 - 50;
const toggleSize = NEW_ROOM_LIST_MIN_WIDTH;
const collapseConfig: ICollapseConfig = {
toggleSize,
onCollapsed: (collapsed) => {
if (useNewRoomList) {
// The new room list does not support collapsing.
return;
}
panelCollapsed = collapsed;
if (collapsed) {
dis.dispatch({ action: "hide_left_panel" });
window.localStorage.setItem("mx_lhs_size", "0");
} else {
dis.dispatch({ action: "show_left_panel" });
}
// The new room list does not support collapsing.
return;
},
onResized: (size) => {
panelSize = size;
@ -312,12 +300,12 @@ class LoggedInView extends React.Component<IProps, IState> {
},
onResizeStop: () => {
// Always save the lhs size for the new room list.
if (useNewRoomList || !panelCollapsed) window.localStorage.setItem("mx_lhs_size", "" + panelSize);
window.localStorage.setItem("mx_lhs_size", "" + panelSize);
this.context.resizeNotifier.stopResizing();
},
isItemCollapsed: (domNode) => {
// New rooms list does not support collapsing.
return !useNewRoomList && domNode.classList.contains("mx_LeftPanel_minimized");
return false;
},
handler: this.resizeHandler.current ?? undefined,
};
@ -331,14 +319,8 @@ class LoggedInView extends React.Component<IProps, IState> {
}
private loadResizerPreferences(): void {
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
let lhsSize = parseInt(window.localStorage.getItem("mx_lhs_size")!, 10);
// If the user has not set a size, or for the new room list if the size is less than the minimum width,
// set a default size.
if (isNaN(lhsSize) || (useNewRoomList && lhsSize < NEW_ROOM_LIST_MIN_WIDTH)) {
lhsSize = 350;
}
this.resizer?.forHandleWithId("lp-resizer")?.resize(lhsSize);
// New room list does not support resizing
this.resizer?.forHandleWithId("lp-resizer")?.resize(350);
}
private onAccountData = (event: MatrixEvent): void => {
@ -744,18 +726,15 @@ class LoggedInView extends React.Component<IProps, IState> {
"mx_MatrixChat--with-avatar": this.state.backgroundImage,
});
const useNewRoomList = SettingsStore.getValue("feature_new_room_list");
const leftPanelWrapperClasses = classNames({
mx_LeftPanel_wrapper: true,
mx_LeftPanel_newRoomList: useNewRoomList,
mx_LeftPanel_newRoomList: true,
});
const audioFeedArraysForCalls = this.state.activeCalls.map((call) => {
return <AudioFeedArrayForLegacyCall call={call} key={call.callId} />;
});
const shouldUseMinimizedUI = !useNewRoomList && this.props.collapseLhs;
return (
<MatrixClientContextProvider client={this._matrixClient}>
<div
@ -767,22 +746,18 @@ class LoggedInView extends React.Component<IProps, IState> {
<ToastContainer />
<div className={bodyClasses}>
<div className="mx_LeftPanel_outerWrapper">
<LeftPanelLiveShareWarning isMinimized={shouldUseMinimizedUI || false} />
<LeftPanelLiveShareWarning isMinimized={false} />
<div className={leftPanelWrapperClasses}>
{!useNewRoomList && (
<BackdropPanel blurMultiplier={0.5} backgroundImage={this.state.backgroundImage} />
)}
<SpacePanel />
{!useNewRoomList && <BackdropPanel backgroundImage={this.state.backgroundImage} />}
{!moduleRenderer && (
<div
className="mx_LeftPanel_wrapper--user"
ref={this._resizeContainer}
data-collapsed={shouldUseMinimizedUI ? true : undefined}
data-collapsed={undefined}
>
<LeftPanel
pageType={this.props.page_type as PageTypes}
isMinimized={shouldUseMinimizedUI || false}
isMinimized={false}
resizeNotifier={this.context.resizeNotifier}
/>
</div>

View File

@ -1,54 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React from "react";
import { SearchIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts";
import defaultDispatcher from "../../dispatcher/dispatcher";
import { IS_MAC, Key } from "../../Keyboard";
import { _t } from "../../languageHandler";
import AccessibleButton from "../views/elements/AccessibleButton";
import { Action } from "../../dispatcher/actions";
interface IProps {
isMinimized: boolean;
}
export default class RoomSearch extends React.PureComponent<IProps> {
private openSpotlight(): void {
defaultDispatcher.fire(Action.OpenSpotlight);
}
public render(): React.ReactNode {
const classes = classNames(
{
mx_RoomSearch: true,
mx_RoomSearch_minimized: this.props.isMinimized,
},
"mx_RoomSearch_spotlightTrigger",
);
const shortcutPrompt = (
<kbd className="mx_RoomSearch_shortcutPrompt">
{IS_MAC ? "⌘ K" : _t(ALTERNATE_KEY_NAME[Key.CONTROL]) + " K"}
</kbd>
);
return (
<AccessibleButton onClick={this.openSpotlight} className={classes} aria-label={_t("action|search")}>
<SearchIcon className="mx_RoomSearch_icon" />
{!this.props.isMinimized && (
<div className="mx_RoomSearch_spotlightTriggerText">{_t("action|search")}</div>
)}
{shortcutPrompt}
</AccessibleButton>
);
}
}

View File

@ -9,18 +9,10 @@ Please see LICENSE files in the repository root for full details.
import React from "react";
import ContextMenuIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal";
import { ChevronFace, ContextMenuButton, type MenuProps, useContextMenu } from "../../structures/ContextMenu";
import { contextMenuBelow, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
import { type ButtonProps } from "../elements/AccessibleButton";
import IconizedContextMenu, { IconizedContextMenuOptionList } from "./IconizedContextMenu";
const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX + elementRect.width;
const top = elementRect.bottom + window.scrollY;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
type KebabContextMenuProps = Partial<ButtonProps<any>> & {
options: React.ReactNode[];
title: string;

View File

@ -14,7 +14,7 @@ import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../../utils/strings";
import { ChevronFace, ContextMenuTooltipButton, type MenuProps, useContextMenu } from "../../structures/ContextMenu";
import { contextMenuBelow, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
@ -27,14 +27,6 @@ export interface ThreadListContextMenuProps {
onMenuToggle?: (open: boolean) => void;
}
const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX + elementRect.width;
const top = elementRect.bottom + window.scrollY;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
const ThreadListContextMenu: React.FC<ThreadListContextMenuProps> = ({
mxEvent,
permalinkCreator,

View File

@ -18,9 +18,9 @@ import { RoomGeneralContextMenu } from "../../context_menus/RoomGeneralContextMe
import { RoomNotificationContextMenu } from "../../context_menus/RoomNotificationContextMenu";
import SpaceContextMenu from "../../context_menus/SpaceContextMenu";
import { type ButtonEvent } from "../../elements/AccessibleButton";
import { contextMenuBelow } from "../../rooms/RoomTile";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
import { contextMenuBelow } from "../../../structures/ContextMenu.tsx";
interface Props {
room: Room;

View File

@ -23,8 +23,7 @@ import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import BugReportDialog from "../dialogs/BugReportDialog";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
import { contextMenuBelow } from "../rooms/RoomTile";
import { ContextMenuTooltipButton } from "../../structures/ContextMenu";
import { contextMenuBelow, ContextMenuTooltipButton } from "../../structures/ContextMenu";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,

View File

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

View File

@ -1,426 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { ClientEvent, EventType, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import React, { type JSX, useContext, useEffect, useState } from "react";
import { Tooltip } from "@vector-im/compound-web";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { useDispatcher } from "../../../hooks/useDispatcher";
import { useEventEmitterState, useTypedEventEmitter, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { _t } from "../../../languageHandler";
import PosthogTrackers from "../../../PosthogTrackers";
import { UIComponent } from "../../../settings/UIFeature";
import {
getMetaSpaceName,
MetaSpace,
type SpaceKey,
UPDATE_HOME_BEHAVIOUR,
UPDATE_SELECTED_SPACE,
} from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import {
shouldShowSpaceInvite,
showAddExistingRooms,
showCreateNewRoom,
showCreateNewSubspace,
showSpaceInvite,
} from "../../../utils/space";
import {
ChevronFace,
ContextMenuButton,
ContextMenuTooltipButton,
type MenuProps,
useContextMenu,
} from "../../structures/ContextMenu";
import { BetaPill } from "../beta/BetaCard";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
} from "../context_menus/IconizedContextMenu";
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
import InlineSpinner from "../elements/InlineSpinner";
import { HomeButtonContextMenu } from "../spaces/SpacePanel";
const contextMenuBelow = (elementRect: DOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX;
const top = elementRect.bottom + window.scrollY + 12;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
// Long-running actions that should trigger a spinner
enum PendingActionType {
JoinRoom,
BulkRedact,
}
const usePendingActions = (): Map<PendingActionType, Set<string>> => {
const cli = useContext(MatrixClientContext);
const [actions, setActions] = useState(new Map<PendingActionType, Set<string>>());
const addAction = (type: PendingActionType, key: string): void => {
const keys = new Set(actions.get(type));
keys.add(key);
setActions(new Map(actions).set(type, keys));
};
const removeAction = (type: PendingActionType, key: string): void => {
const keys = new Set(actions.get(type));
if (keys.delete(key)) {
setActions(new Map(actions).set(type, keys));
}
};
useDispatcher(defaultDispatcher, (payload) => {
switch (payload.action) {
case Action.JoinRoom:
addAction(PendingActionType.JoinRoom, payload.roomId);
break;
case Action.JoinRoomReady:
case Action.JoinRoomError:
removeAction(PendingActionType.JoinRoom, payload.roomId);
break;
case Action.BulkRedactStart:
addAction(PendingActionType.BulkRedact, payload.roomId);
break;
case Action.BulkRedactEnd:
removeAction(PendingActionType.BulkRedact, payload.roomId);
break;
}
});
useTypedEventEmitter(cli, ClientEvent.Room, (room: Room) => removeAction(PendingActionType.JoinRoom, room.roomId));
return actions;
};
interface IProps {
onVisibilityChange?(): void;
}
const LegacyRoomListHeader: React.FC<IProps> = ({ onVisibilityChange }) => {
const cli = useContext(MatrixClientContext);
const [mainMenuDisplayed, mainMenuHandle, openMainMenu, closeMainMenu] = useContextMenu<HTMLDivElement>();
const [plusMenuDisplayed, plusMenuHandle, openPlusMenu, closePlusMenu] = useContextMenu<HTMLDivElement>();
const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom],
);
const allRoomsInHome = useEventEmitterState(SpaceStore.instance, UPDATE_HOME_BEHAVIOUR, () => {
return SpaceStore.instance.allRoomsInHome;
});
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms");
const pendingActions = usePendingActions();
const canShowMainMenu = activeSpace || spaceKey === MetaSpace.Home;
useEffect(() => {
if (mainMenuDisplayed && !canShowMainMenu) {
// Space changed under us and we no longer has a main menu to draw
closeMainMenu();
}
}, [closeMainMenu, canShowMainMenu, mainMenuDisplayed]);
const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name);
useEffect(() => {
onVisibilityChange?.();
}, [onVisibilityChange]);
const canExploreRooms = shouldShowComponent(UIComponent.ExploreRooms);
const canCreateRooms = shouldShowComponent(UIComponent.CreateRooms);
const canCreateSpaces = shouldShowComponent(UIComponent.CreateSpaces);
const hasPermissionToAddSpaceChild = activeSpace?.currentState?.maySendStateEvent(
EventType.SpaceChild,
cli.getUserId()!,
);
const canAddSubRooms = hasPermissionToAddSpaceChild && canCreateRooms;
const canAddSubSpaces = hasPermissionToAddSpaceChild && canCreateSpaces;
// If the user can't do anything on the plus menu, don't show it. This aims to target the
// plus menu shown on the Home tab primarily: the user has options to use the menu for
// communities and spaces, but is at risk of no options on the Home tab.
const canShowPlusMenu = canCreateRooms || canExploreRooms || canCreateSpaces || activeSpace;
let contextMenu: JSX.Element | undefined;
if (mainMenuDisplayed && mainMenuHandle.current) {
let ContextMenuComponent;
if (activeSpace) {
ContextMenuComponent = SpaceContextMenu;
} else {
ContextMenuComponent = HomeButtonContextMenu;
}
contextMenu = (
<ContextMenuComponent
{...contextMenuBelow(mainMenuHandle.current.getBoundingClientRect())}
space={activeSpace!}
onFinished={closeMainMenu}
hideHeader={true}
/>
);
} else if (plusMenuDisplayed && activeSpace) {
let inviteOption: JSX.Element | undefined;
if (shouldShowSpaceInvite(activeSpace)) {
inviteOption = (
<IconizedContextMenuOption
label={_t("action|invite")}
iconClassName="mx_LegacyRoomListHeader_iconInvite"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showSpaceInvite(activeSpace);
closePlusMenu();
}}
/>
);
}
let newRoomOptions: JSX.Element | undefined;
if (activeSpace?.currentState.maySendStateEvent(EventType.RoomAvatar, cli.getUserId()!)) {
newRoomOptions = (
<>
<IconizedContextMenuOption
iconClassName="mx_LegacyRoomListHeader_iconNewRoom"
label={_t("action|new_room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(activeSpace);
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
iconClassName="mx_LegacyRoomListHeader_iconNewVideoRoom"
label={_t("action|new_video_room")}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewRoom(
activeSpace,
elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
);
closePlusMenu();
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
);
}
contextMenu = (
<IconizedContextMenu
{...contextMenuBelow(plusMenuHandle.current!.getBoundingClientRect())}
onFinished={closePlusMenu}
compact
>
<IconizedContextMenuOptionList first>
{inviteOption}
{newRoomOptions}
<IconizedContextMenuOption
label={_t("action|explore_rooms")}
iconClassName="mx_LegacyRoomListHeader_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: activeSpace.roomId,
metricsTrigger: undefined, // other
});
closePlusMenu();
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e);
}}
/>
<IconizedContextMenuOption
label={_t("action|add_existing_room")}
iconClassName="mx_LegacyRoomListHeader_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showAddExistingRooms(activeSpace);
closePlusMenu();
}}
disabled={!canAddSubRooms}
title={!canAddSubRooms ? _t("spaces|error_no_permission_add_room") : undefined}
/>
{canCreateSpaces && (
<IconizedContextMenuOption
label={_t("room_list|add_space_label")}
iconClassName="mx_LegacyRoomListHeader_iconPlus"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
showCreateNewSubspace(activeSpace);
closePlusMenu();
}}
disabled={!canAddSubSpaces}
title={!canAddSubSpaces ? _t("spaces|error_no_permission_add_space") : undefined}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
} else if (plusMenuDisplayed) {
let newRoomOpts: JSX.Element | undefined;
let joinRoomOpt: JSX.Element | undefined;
if (canCreateRooms) {
newRoomOpts = (
<>
<IconizedContextMenuOption
label={_t("action|start_new_chat")}
iconClassName="mx_LegacyRoomListHeader_iconStartChat"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: Action.CreateChat });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
closePlusMenu();
}}
/>
<IconizedContextMenuOption
label={_t("action|new_room")}
iconClassName="mx_LegacyRoomListHeader_iconNewRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: Action.CreateRoom });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
closePlusMenu();
}}
/>
{videoRoomsEnabled && (
<IconizedContextMenuOption
label={_t("action|new_video_room")}
iconClassName="mx_LegacyRoomListHeader_iconNewVideoRoom"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({
action: Action.CreateRoom,
type: elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo,
});
closePlusMenu();
}}
>
<BetaPill />
</IconizedContextMenuOption>
)}
</>
);
}
if (canExploreRooms) {
joinRoomOpt = (
<IconizedContextMenuOption
label={_t("room_list|join_public_room_label")}
iconClassName="mx_LegacyRoomListHeader_iconExplore"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
defaultDispatcher.dispatch({ action: Action.ViewRoomDirectory });
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuExploreRoomsItem", e);
closePlusMenu();
}}
/>
);
}
contextMenu = (
<IconizedContextMenu
{...contextMenuBelow(plusMenuHandle.current!.getBoundingClientRect())}
onFinished={closePlusMenu}
compact
>
<IconizedContextMenuOptionList first>
{newRoomOpts}
{joinRoomOpt}
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
let title: string;
if (activeSpace && spaceName) {
title = spaceName;
} else {
title = getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome);
}
const pendingActionSummary = [...pendingActions.entries()]
.filter(([type, keys]) => keys.size > 0)
.map(([type, keys]) => {
switch (type) {
case PendingActionType.JoinRoom:
return _t("room_list|joining_rooms_status", { count: keys.size });
case PendingActionType.BulkRedact:
return _t("room_list|redacting_messages_status", { count: keys.size });
}
})
.join("\n");
let contextMenuButton: JSX.Element = <div className="mx_LegacyRoomListHeader_contextLessTitle">{title}</div>;
if (canShowMainMenu) {
const commonProps = {
ref: mainMenuHandle,
onClick: openMainMenu,
isExpanded: mainMenuDisplayed,
className: "mx_LegacyRoomListHeader_contextMenuButton",
children: title,
};
if (!!activeSpace) {
contextMenuButton = (
<ContextMenuButton
{...commonProps}
label={_t("room_list|space_menu_label", { spaceName: spaceName ?? activeSpace.name })}
/>
);
} else {
contextMenuButton = <ContextMenuTooltipButton {...commonProps} title={_t("room_list|home_menu_label")} />;
}
}
return (
<aside className="mx_LegacyRoomListHeader" aria-label={_t("room|context_menu|title")}>
{contextMenuButton}
{pendingActionSummary ? (
<Tooltip label={pendingActionSummary} isTriggerInteractive={false}>
<InlineSpinner />
</Tooltip>
) : null}
{canShowPlusMenu && (
<ContextMenuTooltipButton
ref={plusMenuHandle}
onClick={openPlusMenu}
isExpanded={plusMenuDisplayed}
className="mx_LegacyRoomListHeader_plusButton"
title={_t("action|add")}
/>
)}
{contextMenu}
</aside>
);
};
export default LegacyRoomListHeader;

View File

@ -1,142 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import { type EmptyObject, type Room } from "matrix-js-sdk/src/matrix";
import { CSSTransition } from "react-transition-group";
import { BreadcrumbsStore } from "../../../stores/BreadcrumbsStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
import Toolbar from "../../../accessibility/Toolbar";
import { Action } from "../../../dispatcher/actions";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";
interface IState {
// Both of these control the animation for the breadcrumbs. For details on the
// actual animation, see the CSS.
//
// doAnimation is to lie to the CSSTransition component (see onBreadcrumbsUpdate
// for info). skipFirst is used to try and reduce jerky animation - also see the
// breadcrumb update function for info on that.
doAnimation: boolean;
skipFirst: boolean;
}
const RoomBreadcrumbTile: React.FC<{ room: Room; onClick: (ev: ButtonEvent) => void }> = ({ room, onClick }) => {
const [onFocus, isActive, ref] = useRovingTabIndex();
return (
<AccessibleButton
className="mx_RoomBreadcrumbs_crumb"
onClick={onClick}
aria-label={_t("a11y|room_name", { name: room.name })}
title={room.name}
onFocus={onFocus}
ref={ref}
tabIndex={isActive ? 0 : -1}
placement="right"
>
<DecoratedRoomAvatar
room={room}
size="32px"
displayBadge={true}
hideIfDot={true}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
</AccessibleButton>
);
};
export default class RoomBreadcrumbs extends React.PureComponent<EmptyObject, IState> {
private unmounted = false;
private toolbar = createRef<HTMLDivElement>();
public constructor(props: EmptyObject) {
super(props);
this.state = {
doAnimation: true, // technically we want animation on mount, but it won't be perfect
skipFirst: false, // render the thing, as boring as it is
};
}
public componentDidMount(): void {
this.unmounted = false;
BreadcrumbsStore.instance.on(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
public componentWillUnmount(): void {
this.unmounted = true;
BreadcrumbsStore.instance.off(UPDATE_EVENT, this.onBreadcrumbsUpdate);
}
private onBreadcrumbsUpdate = (): void => {
if (this.unmounted) return;
// We need to trick the CSSTransition component into updating, which means we need to
// tell it to not animate, then to animate a moment later. This causes two updates
// which means two renders. The skipFirst change is so that our don't-animate state
// doesn't show the breadcrumb we're about to reveal as it causes a visual jump/jerk.
// The second update, on the next available tick, causes the "enter" animation to start
// again and this time we want to show the newest breadcrumb because it'll be hidden
// off screen for the animation.
this.setState({ doAnimation: false, skipFirst: true });
window.setTimeout(() => this.setState({ doAnimation: true, skipFirst: false }), 0);
};
private viewRoom = (room: Room, index: number, viaKeyboard = false): void => {
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebHorizontalBreadcrumbs",
metricsViaKeyboard: viaKeyboard,
});
};
public render(): React.ReactElement {
const tiles = BreadcrumbsStore.instance.rooms.map((r, i) => (
<RoomBreadcrumbTile
key={r.roomId}
room={r}
onClick={(ev: ButtonEvent) => this.viewRoom(r, i, ev.type !== "click")}
/>
));
if (tiles.length > 0) {
// NOTE: The CSSTransition timeout MUST match the timeout in our CSS!
return (
<CSSTransition
appear={true}
in={this.state.doAnimation}
timeout={640}
classNames="mx_RoomBreadcrumbs"
nodeRef={this.toolbar}
>
<Toolbar
className="mx_RoomBreadcrumbs"
aria-label={_t("room_list|breadcrumbs_label")}
ref={this.toolbar}
>
{tiles.slice(this.state.skipFirst ? 1 : 0)}
</Toolbar>
</CSSTransition>
);
} else {
return (
<div className="mx_RoomBreadcrumbs">
<div className="mx_RoomBreadcrumbs_placeholder">{_t("room_list|breadcrumbs_empty")}</div>
</div>
);
}
}
}

View File

@ -1,857 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { type Enable, Resizable } from "re-resizable";
import { type Direction } from "re-resizable/lib/resizer";
import React, { type JSX, type ComponentType, createRef, type ReactComponentElement, type ReactNode } from "react";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { RovingAccessibleButton, RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import { Action } from "../../../dispatcher/actions";
import defaultDispatcher, { type MatrixDispatcher } from "../../../dispatcher/dispatcher";
import { type ActionPayload } from "../../../dispatcher/payloads";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { _t } from "../../../languageHandler";
import { type ListNotificationState } from "../../../stores/notifications/ListNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { ListAlgorithm, SortAlgorithm } from "../../../stores/room-list/algorithms/models";
import { type ListLayout } from "../../../stores/room-list/ListLayout";
import { DefaultTagID, type TagID } from "../../../stores/room-list/models";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import RoomListStore, { LISTS_UPDATE_EVENT, LISTS_LOADING_EVENT } from "../../../stores/room-list/RoomListStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
import { objectExcluding, objectHasDiff } from "../../../utils/objects";
import type ResizeNotifier from "../../../utils/ResizeNotifier";
import ContextMenu, {
ChevronFace,
ContextMenuTooltipButton,
StyledMenuItemCheckbox,
StyledMenuItemRadio,
} from "../../structures/ContextMenu";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import type ExtraTile from "./ExtraTile";
import NotificationBadge from "./NotificationBadge";
import RoomTile from "./RoomTile";
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;
public constructor(props: IProps) {
super(props);
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0;
this.notificationState = RoomNotificationStateStore.instance.getListState(this.props.tagId);
this.state = {
isResizing: false,
isExpanded: !this.layout.isCollapsed,
height: 0, // to be fixed in a moment, we need `rooms` to calculate this.
rooms: arrayFastClone(RoomListStore.instance.orderedLists[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 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 {
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 {
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 = arrayFastClone(RoomListStore.instance.orderedLists[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.
setTimeout(() => {
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
}
}, 0);
}
};
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> => {
// 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.props.tagId, 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.props.tagId].find((r: Room) => {
const notifState = this.notificationState.getForRoom(r);
return notifState.count > 0 && notifState.level === this.notificationState.level;
});
}
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) {
setTimeout(() => {
sublist.scrollIntoView({ behavior: "smooth" });
}, 0);
}
}
};
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) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
// 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
hideIfDot={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>;
// 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">
<AccessibleButton
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>
</AccessibleButton>
{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;
if (maxTilesPx > this.state.height) {
// 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);
const numMissing = this.numTiles - 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

@ -1,483 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015-2017 , 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import classNames from "classnames";
import type { Call } from "../../../models/Call";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { _t } from "../../../languageHandler";
import { ChevronFace, ContextMenuTooltipButton, type MenuProps } from "../../structures/ContextMenu";
import { DefaultTagID, type TagID } from "../../../stores/room-list/models";
import { type MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import { RoomNotifState } from "../../../RoomNotifs";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { RoomNotificationContextMenu } from "../context_menus/RoomNotificationContextMenu";
import NotificationBadge from "./NotificationBadge";
import { type ActionPayload } from "../../../dispatcher/payloads";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { type NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { CachedRoomKey, type RoomEchoChamber } from "../../../stores/local-echo/RoomEchoChamber";
import { PROPERTY_UPDATED } from "../../../stores/local-echo/GenericEchoChamber";
import PosthogTrackers from "../../../PosthogTrackers";
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu";
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { RoomTileSubtitle } from "./RoomTileSubtitle";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { isKnockDenied } from "../../../utils/membership";
import SettingsStore from "../../../settings/SettingsStore";
interface Props {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
interface State {
selected: boolean;
notificationsMenuPosition: PartialDOMRect | null;
generalMenuPosition: PartialDOMRect | null;
call: Call | null;
messagePreview: MessagePreview | null;
}
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
export const contextMenuBelow = (elementRect: PartialDOMRect): MenuProps => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX - 9;
const top = elementRect.bottom + window.scrollY + 17;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
class RoomTile extends React.PureComponent<Props, State> {
private dispatcherRef?: string;
private roomTileRef = createRef<HTMLDivElement>();
private notificationState: NotificationState;
private roomProps: RoomEchoChamber;
public constructor(props: Props) {
super(props);
this.state = {
selected: SdkContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
call: CallStore.instance.getCall(this.props.room.roomId),
// generatePreview() will return nothing if the user has previews disabled
messagePreview: null,
};
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.roomProps = EchoChamber.forRoom(this.props.room);
}
private onRoomNameUpdate = (room: Room): void => {
this.forceUpdate();
};
private onNotificationUpdate = (): void => {
this.forceUpdate(); // notification state changed - update
};
private onRoomPropertyUpdate = (property: CachedRoomKey): void => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile
};
private get showContextMenu(): boolean {
return (
this.props.tag !== DefaultTagID.Invite &&
this.props.room.getMyMembership() !== KnownMembership.Knock &&
!isKnockDenied(this.props.room) &&
shouldShowComponent(UIComponent.RoomOptionsMenu)
);
}
private get showMessagePreview(): boolean {
return !this.props.isMinimized && this.props.showMessagePreview;
}
public componentDidUpdate(prevProps: Readonly<Props>, prevState: Readonly<State>): void {
const showMessageChanged = prevProps.showMessagePreview !== this.props.showMessagePreview;
const minimizedChanged = prevProps.isMinimized !== this.props.isMinimized;
if (showMessageChanged || minimizedChanged) {
this.generatePreview();
}
if (prevProps.room?.roomId !== this.props.room?.roomId) {
MessagePreviewStore.instance.off(
MessagePreviewStore.getPreviewChangedEventName(prevProps.room),
this.onRoomPreviewChanged,
);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
prevProps.room?.off(RoomEvent.Name, this.onRoomNameUpdate);
this.props.room?.on(RoomEvent.Name, this.onRoomNameUpdate);
}
}
public componentDidMount(): void {
this.generatePreview();
// when we're first rendered (or our sublist is expanded) make sure we are visible if we're active
if (this.state.selected) {
this.scrollIntoView();
}
SdkContextClass.instance.roomViewStore.addRoomListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.notificationState.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.props.room.on(RoomEvent.Name, this.onRoomNameUpdate);
CallStore.instance.on(CallStoreEvent.Call, this.onCallChanged);
// Recalculate the call for this room, since it could've changed between
// construction and mounting
this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) });
}
public componentWillUnmount(): void {
SdkContextClass.instance.roomViewStore.removeRoomListener(this.props.room.roomId, this.onActiveRoomUpdate);
MessagePreviewStore.instance.off(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.props.room.off(RoomEvent.Name, this.onRoomNameUpdate);
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NotificationStateEvents.Update, this.onNotificationUpdate);
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CallStore.instance.off(CallStoreEvent.Call, this.onCallChanged);
}
private onAction = (payload: ActionPayload): void => {
if (
payload.action === Action.ViewRoom &&
payload.room_id === this.props.room.roomId &&
payload.show_room_tile
) {
setTimeout(() => {
this.scrollIntoView();
});
}
};
private onRoomPreviewChanged = (room: Room): void => {
if (this.props.room && room.roomId === this.props.room.roomId) {
this.generatePreview();
}
};
private onCallChanged = (call: Call, roomId: string): void => {
if (roomId === this.props.room?.roomId) this.setState({ call });
};
private async generatePreview(): Promise<void> {
if (!this.showMessagePreview) {
return;
}
const messagePreview =
(await MessagePreviewStore.instance.getPreviewForRoom(this.props.room, this.props.tag)) ?? null;
this.setState({ messagePreview });
}
private scrollIntoView = (): void => {
if (!this.roomTileRef.current) return;
this.roomTileRef.current.scrollIntoView({
block: "nearest",
behavior: "auto",
});
};
private onTileClick = async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
ev.stopPropagation();
const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent);
const clearSearch = ([KeyBindingAction.Enter, KeyBindingAction.Space] as Array<string | undefined>).includes(
action,
);
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
show_room_tile: true, // make sure the room is visible in the list
room_id: this.props.room.roomId,
clear_search: clearSearch,
metricsTrigger: "RoomList",
metricsViaKeyboard: ev.type !== "click",
});
};
private onActiveRoomUpdate = (isActive: boolean): void => {
this.setState({ selected: isActive });
};
private onNotificationsMenuOpenClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ notificationsMenuPosition: target.getBoundingClientRect() });
PosthogTrackers.trackInteraction("WebRoomListRoomTileNotificationsMenu", ev);
};
private onCloseNotificationsMenu = (): void => {
this.setState({ notificationsMenuPosition: null });
};
private onGeneralMenuOpenClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ generalMenuPosition: target.getBoundingClientRect() });
};
private onContextMenu = (ev: React.MouseEvent): void => {
// If we don't have a context menu to show, ignore the action.
if (!this.showContextMenu) return;
ev.preventDefault();
ev.stopPropagation();
this.setState({
generalMenuPosition: {
left: ev.clientX,
bottom: ev.clientY,
},
});
};
private onCloseGeneralMenu = (): void => {
this.setState({ generalMenuPosition: null });
};
private renderNotificationsMenu(isActive: boolean): React.ReactElement | null {
if (
MatrixClientPeg.safeGet().isGuest() ||
this.props.tag === DefaultTagID.Archived ||
!this.showContextMenu ||
this.props.isMinimized
) {
// the menu makes no sense in these cases so do not show one
return null;
}
const state = this.roomProps.notificationVolume;
const classes = classNames("mx_RoomTile_notificationsButton", {
// Show bell icon for the default case too.
mx_RoomNotificationContextMenu_iconBell: state === RoomNotifState.AllMessages,
mx_RoomNotificationContextMenu_iconBellDot: state === RoomNotifState.AllMessagesLoud,
mx_RoomNotificationContextMenu_iconBellMentions: state === RoomNotifState.MentionsOnly,
mx_RoomNotificationContextMenu_iconBellCrossed: state === RoomNotifState.Mute,
// Only show the icon by default if the room is overridden to muted.
// TODO: [FTUE Notifications] Probably need to detect global mute state
mx_RoomTile_notificationsButton_show: state === RoomNotifState.Mute,
});
return (
<React.Fragment>
<ContextMenuTooltipButton
className={classes}
onClick={this.onNotificationsMenuOpenClick}
title={_t("room_list|notification_options")}
isExpanded={!!this.state.notificationsMenuPosition}
tabIndex={isActive ? 0 : -1}
/>
{this.state.notificationsMenuPosition && (
<RoomNotificationContextMenu
{...contextMenuBelow(this.state.notificationsMenuPosition)}
onFinished={this.onCloseNotificationsMenu}
room={this.props.room}
/>
)}
</React.Fragment>
);
}
private renderGeneralMenu(): React.ReactElement | null {
if (!this.showContextMenu) return null; // no menu to show
return (
<React.Fragment>
<ContextMenuTooltipButton
className="mx_RoomTile_menuButton"
onClick={this.onGeneralMenuOpenClick}
title={_t("room|context_menu|title")}
isExpanded={!!this.state.generalMenuPosition}
/>
{this.state.generalMenuPosition && (
<RoomGeneralContextMenu
{...contextMenuBelow(this.state.generalMenuPosition)}
onFinished={this.onCloseGeneralMenu}
room={this.props.room}
onPostFavoriteClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", ev)
}
onPostInviteClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", ev)
}
onPostSettingsClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuSettingsItem", ev)
}
onPostLeaveClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", ev)
}
onPostMarkAsReadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", ev)
}
onPostMarkAsUnreadClick={(ev: ButtonEvent) =>
PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", ev)
}
/>
)}
</React.Fragment>
);
}
/**
* RoomTile has a subtile if one of the following applies:
* - there is a call
* - message previews are enabled and there is a previewable message
*/
private get shouldRenderSubtitle(): boolean {
return !!this.state.call || (this.props.showMessagePreview && !!this.state.messagePreview);
}
public render(): React.ReactElement {
const classes = classNames({
mx_RoomTile: true,
mx_RoomTile_sticky:
SettingsStore.getValue("feature_ask_to_join") &&
(this.props.room.getMyMembership() === KnownMembership.Knock || isKnockDenied(this.props.room)),
mx_RoomTile_selected: this.state.selected,
mx_RoomTile_hasMenuOpen: !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
mx_RoomTile_minimized: this.props.isMinimized,
});
let name = this.props.room.name;
if (typeof name !== "string") name = "";
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
let badge: React.ReactNode;
if (!this.props.isMinimized && this.notificationState) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge notification={this.notificationState} roomId={this.props.room.roomId} />
</div>
);
}
const subtitle = this.shouldRenderSubtitle ? (
<RoomTileSubtitle
call={this.state.call}
messagePreview={this.state.messagePreview}
roomId={this.props.room.roomId}
showMessagePreview={this.props.showMessagePreview}
/>
) : null;
const titleClasses = classNames({
mx_RoomTile_title: true,
mx_RoomTile_titleWithSubtitle: !!subtitle,
mx_RoomTile_titleHasUnreadEvents: this.notificationState.isUnread,
});
const titleContainer = this.props.isMinimized ? null : (
<div className="mx_RoomTile_titleContainer">
<div title={name} className={titleClasses} tabIndex={-1}>
<span dir="auto">{name}</span>
</div>
{subtitle}
</div>
);
let ariaLabel = name;
// The following labels are written in such a fashion to increase screen reader efficiency (speed).
if (this.props.tag === DefaultTagID.Invite) {
// append nothing
} else if (this.notificationState.hasMentions) {
ariaLabel +=
" " +
_t("a11y|n_unread_messages_mentions", {
count: this.notificationState.count,
});
} else if (this.notificationState.hasUnreadCount) {
ariaLabel +=
" " +
_t("a11y|n_unread_messages", {
count: this.notificationState.count,
});
} else if (this.notificationState.isUnread) {
ariaLabel += " " + _t("a11y|unread_messages");
}
let ariaDescribedBy: string;
if (this.showMessagePreview) {
ariaDescribedBy = messagePreviewId(this.props.room.roomId);
}
return (
<React.Fragment>
<RovingTabIndexWrapper inputRef={this.roomTileRef}>
{({ onFocus, isActive, ref }) => (
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
ref={ref}
className={classes}
onClick={this.onTileClick}
onContextMenu={this.onContextMenu}
role="treeitem"
aria-label={ariaLabel}
aria-selected={this.state.selected}
aria-describedby={ariaDescribedBy}
title={this.props.isMinimized && !this.state.generalMenuPosition ? name : undefined}
>
<DecoratedRoomAvatar
room={this.props.room}
size="32px"
displayBadge={this.props.isMinimized}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
{titleContainer}
{badge}
{this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
</AccessibleButton>
)}
</RovingTabIndexWrapper>
</React.Fragment>
);
}
}
export default RoomTile;

View File

@ -1,45 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type FC } from "react";
import type { Call } from "../../../models/Call";
import { _t } from "../../../languageHandler";
import { useConnectionState, useParticipantCount } from "../../../hooks/useCall";
import { ConnectionState } from "../../../models/Call";
import { LiveContentSummary, LiveContentType } from "./LiveContentSummary";
interface Props {
call: Call;
}
export const RoomTileCallSummary: FC<Props> = ({ call }) => {
let text: string;
let active: boolean;
switch (useConnectionState(call)) {
case ConnectionState.Disconnected:
text = _t("common|video");
active = false;
break;
case ConnectionState.Connected:
case ConnectionState.Disconnecting:
text = _t("common|joined");
active = true;
break;
}
return (
<LiveContentSummary
type={LiveContentType.Video}
text={text}
active={active}
participantCount={useParticipantCount(call)}
/>
);
};

View File

@ -1,51 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type MessagePreview } from "../../../stores/room-list/MessagePreviewStore";
import { type Call } from "../../../models/Call";
import { RoomTileCallSummary } from "./RoomTileCallSummary";
interface Props {
call: Call | null;
messagePreview: MessagePreview | null;
roomId: string;
showMessagePreview: boolean;
}
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
export const RoomTileSubtitle: React.FC<Props> = ({ call, messagePreview, roomId, showMessagePreview }) => {
if (call) {
return (
<div className="mx_RoomTile_subtitle">
<RoomTileCallSummary call={call} />
</div>
);
}
if (showMessagePreview && messagePreview) {
const className = classNames("mx_RoomTile_subtitle", {
"mx_RoomTile_subtitle--thread-reply": messagePreview.isThreadReply,
});
const icon = messagePreview.isThreadReply ? <ThreadsIcon className="mx_Icon mx_Icon_12" /> : null;
return (
<div className={className} id={messagePreviewId(roomId)} title={messagePreview.text}>
{icon}
<span className="mx_RoomTile_subtitle_text">{messagePreview.text}</span>
</div>
);
}
return null;
};

View File

@ -119,8 +119,6 @@ const SpellCheckSection: React.FC = () => {
};
export default class PreferencesUserSettingsTab extends React.Component<IProps, IState> {
private static ROOM_LIST_SETTINGS: BooleanSettingKey[] = ["breadcrumbs"];
private static SPACES_SETTINGS: BooleanSettingKey[] = ["Spaces.allRoomsInHome"];
private static KEYBINDINGS_SETTINGS: BooleanSettingKey[] = ["ctrlFForSearch"];
@ -248,7 +246,6 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
timezone: TimezoneHandler.shortBrowserTimezone(),
});
const newRoomListEnabled = SettingsStore.getValue("feature_new_room_list");
const brand = SdkConfig.get().brand;
const timezones = this.state.timezones.map((tz) => {
@ -278,11 +275,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
)}
<SettingsSubsection heading={_t("settings|preferences|room_list_heading")}>
{!newRoomListEnabled && this.renderGroup(PreferencesUserSettingsTab.ROOM_LIST_SETTINGS)}
{/* The settings is on device level where the other room list settings are on account level */}
{newRoomListEnabled && (
<SettingsFlag name="RoomList.showMessagePreview" level={SettingLevel.DEVICE} />
)}
<SettingsFlag name="RoomList.showMessagePreview" level={SettingLevel.DEVICE} />
</SettingsSubsection>
<SettingsSubsection heading={_t("common|spaces")}>

View File

@ -7,12 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React, { type ChangeEvent, useMemo } from "react";
import {
VideoCallSolidIcon,
HomeSolidIcon,
UserProfileSolidIcon,
FavouriteSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { VideoCallSolidIcon, HomeSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { _t } from "../../../../../languageHandler";
import SettingsStore from "../../../../../settings/SettingsStore";
@ -53,8 +48,6 @@ export const onMetaSpaceChangeFactory =
const SidebarUserSettingsTab: React.FC = () => {
const {
[MetaSpace.Home]: homeEnabled,
[MetaSpace.Favourites]: favouritesEnabled,
[MetaSpace.People]: peopleEnabled,
[MetaSpace.Orphans]: orphansEnabled,
[MetaSpace.VideoRooms]: videoRoomsEnabled,
} = useSettingValue("Spaces.enabledMetaSpaces");
@ -71,9 +64,6 @@ const SidebarUserSettingsTab: React.FC = () => {
PosthogTrackers.trackInteraction("WebSettingsSidebarTabSpacesCheckbox", event, 1);
};
// "Favourites" and "People" meta spaces are not available in the new room list
const newRoomListEnabled = useSettingValue("feature_new_room_list");
return (
<SettingsTab>
<SettingsSection>
@ -103,36 +93,6 @@ const SidebarUserSettingsTab: React.FC = () => {
{_t("settings|sidebar|metaspaces_home_all_rooms")}
</StyledCheckbox>
{!newRoomListEnabled && (
<>
<StyledCheckbox
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.Favourites,
"WebSettingsSidebarTabSpacesCheckbox",
)}
className="mx_SidebarUserSettingsTab_checkbox"
description={_t("settings|sidebar|metaspaces_favourites_description")}
>
<FavouriteSolidIcon className="mx_SidebarUserSettingsTab_icon" />
{_t("common|favourites")}
</StyledCheckbox>
<StyledCheckbox
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.People,
"WebSettingsSidebarTabSpacesCheckbox",
)}
className="mx_SidebarUserSettingsTab_checkbox"
description={_t("settings|sidebar|metaspaces_people_description")}
>
<UserProfileSolidIcon className="mx_SidebarUserSettingsTab_icon" />
{_t("common|people")}
</StyledCheckbox>
</>
)}
<StyledCheckbox
checked={!!orphansEnabled}
onChange={onMetaSpaceChangeFactory(MetaSpace.Orphans, "WebSettingsSidebarTabSpacesCheckbox")}

View File

@ -8,25 +8,15 @@ Please see LICENSE files in the repository root for full details.
import React, { type JSX } from "react";
import classNames from "classnames";
import {
OverflowHorizontalIcon,
UserProfileSolidIcon,
FavouriteSolidIcon,
PinSolidIcon,
SettingsSolidIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { SettingsSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import ContextMenu, { alwaysAboveRightOf, ChevronFace, useContextMenu } from "../../structures/ContextMenu";
import AccessibleButton from "../elements/AccessibleButton";
import StyledCheckbox from "../elements/StyledCheckbox";
import { MetaSpace } from "../../../stores/spaces";
import { useSettingValue } from "../../../hooks/useSettings";
import { onMetaSpaceChangeFactory } from "../settings/tabs/user/SidebarUserSettingsTab";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserTab";
import QuickThemeSwitcher from "./QuickThemeSwitcher";
import Modal from "../../../Modal";
import DevtoolsDialog from "../dialogs/DevtoolsDialog";
@ -38,22 +28,15 @@ const QuickSettingsButton: React.FC<{
}> = ({ isPanelCollapsed = false }) => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLButtonElement>();
const { [MetaSpace.Favourites]: favouritesEnabled, [MetaSpace.People]: peopleEnabled } =
useSettingValue("Spaces.enabledMetaSpaces");
const currentRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
const developerModeEnabled = useSettingValue("developerMode");
// "Favourites" and "People" meta spaces are not available in the new room list
const newRoomListEnabled = useSettingValue("feature_new_room_list");
let contextMenu: JSX.Element | undefined;
if (menuDisplayed && handle.current) {
contextMenu = (
<ContextMenu
{...alwaysAboveRightOf(handle.current.getBoundingClientRect(), ChevronFace.None, 16)}
wrapperClassName={classNames("mx_QuickSettingsButton_ContextMenuWrapper", {
mx_QuickSettingsButton_ContextMenuWrapper_new_room_list: newRoomListEnabled,
})}
wrapperClassName="mx_QuickSettingsButton_ContextMenuWrapper"
// Eventually replace with a properly aria-labelled menu
data-testid="quick-settings-menu"
onFinished={closeMenu}
@ -90,49 +73,6 @@ const QuickSettingsButton: React.FC<{
</AccessibleButton>
)}
{!newRoomListEnabled && (
<>
<h4>
<PinSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("quick_settings|metaspace_section")}
</h4>
<StyledCheckbox
className="mx_QuickSettingsButton_option"
checked={!!favouritesEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.Favourites,
"WebQuickSettingsPinToSidebarCheckbox",
)}
>
<FavouriteSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("common|favourites")}
</StyledCheckbox>
<StyledCheckbox
className="mx_QuickSettingsButton_option"
checked={!!peopleEnabled}
onChange={onMetaSpaceChangeFactory(
MetaSpace.People,
"WebQuickSettingsPinToSidebarCheckbox",
)}
>
<UserProfileSolidIcon className="mx_QuickSettingsButton_icon" />
{_t("common|people")}
</StyledCheckbox>
<AccessibleButton
className="mx_QuickSettingsButton_moreOptionsButton mx_QuickSettingsButton_option"
onClick={() => {
closeMenu();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Sidebar,
});
}}
>
<OverflowHorizontalIcon className="mx_QuickSettingsButton_icon" />
{_t("quick_settings|sidebar_settings")}
</AccessibleButton>
</>
)}
<QuickThemeSwitcher requestClose={closeMenu} />
</ContextMenu>
);

View File

@ -389,8 +389,6 @@ const SpacePanel: React.FC = () => {
}
});
const newRoomListEnabled = useSettingValue("feature_new_room_list");
return (
<RovingTabIndexProvider handleHomeEnd handleUpDown={!dragging}>
{({ onKeyDownHandler, onDragEndHandler }) => (
@ -416,7 +414,6 @@ const SpacePanel: React.FC = () => {
<nav
className={classNames("mx_SpacePanel", {
collapsed: isPanelCollapsed,
newUi: newRoomListEnabled,
})}
onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);

View File

@ -226,7 +226,6 @@ export interface Settings {
"feature_location_share_live": IFeature;
"feature_dynamic_room_predecessors": IFeature;
"feature_render_reaction_images": IFeature;
"feature_new_room_list": IFeature;
"feature_ask_to_join": IFeature;
"feature_notifications": IFeature;
// These are in the feature namespace but aren't actually features
@ -688,15 +687,6 @@ export const SETTINGS: Settings = {
supportedLevelsAreOrdered: true,
default: false,
},
"feature_new_room_list": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
labsGroup: LabGroup.Ui,
displayName: _td("labs|new_room_list"),
description: _td("labs|under_active_development"),
isFeature: true,
default: true,
controller: new ReloadOnChangeController(),
},
/**
* With the transition to Compound we are moving to a base font size
* of 16px. We're taking the opportunity to move away from the `baseFontSize`

View File

@ -15,9 +15,6 @@ import { type IFilterCondition } from "./filters/IFilterCondition";
export enum RoomListStoreEvent {
// The event/channel which is called when the room lists have been changed.
ListsUpdate = "lists_update",
// The event which is called when the room list is loading.
// Called with the (tagId, bool) which is true when the list is loading, else false.
ListsLoading = "lists_loading",
}
export interface RoomListStore extends EventEmitter {

View File

@ -10,7 +10,6 @@ import {
type Room,
RelationType,
type MatrixEvent,
type Thread,
M_POLL_START,
RoomEvent,
type EmptyObject,
@ -31,7 +30,6 @@ import { ReactionEventPreview } from "./previews/ReactionEventPreview";
import { UPDATE_EVENT } from "../AsyncStore";
import { type IPreview } from "./previews/IPreview";
import shouldHideEvent from "../../shouldHideEvent";
import SettingsStore from "../../settings/SettingsStore";
// Emitted event for when a room's preview has changed. First argument will the room for which
// the change happened.
@ -179,15 +177,6 @@ export class MessagePreviewStore extends AsyncStoreWithClient<EmptyObject> {
private async generatePreview(room: Room, tagId?: TagID): Promise<void> {
const events = [...room.getLiveTimeline().getEvents(), ...room.getPendingEvents()];
const isNewRoomListEnabled = SettingsStore.getValue("feature_new_room_list");
if (!isNewRoomListEnabled) {
// add last reply from each thread
room.getThreads().forEach((thread: Thread): void => {
const lastReply = thread.lastReply();
if (lastReply) events.push(lastReply);
});
}
// sort events from oldest to newest
events.sort((a: MatrixEvent, b: MatrixEvent) => {
return a.getTs() - b.getTs();

View File

@ -37,7 +37,6 @@ import { SdkContextClass } from "../../contexts/SDKContext";
import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute";
export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate;
export const LISTS_LOADING_EVENT = RoomListStoreEvent.ListsLoading; // unused; used by SlidingRoomListStore
export class RoomListStoreClass extends AsyncStoreWithClient<EmptyObject> implements Interface {
/**

View File

@ -29,13 +29,12 @@ import SettingsStore from "../../settings/SettingsStore";
import DMRoomMap from "../../utils/DMRoomMap";
import { SpaceNotificationState } from "../notifications/SpaceNotificationState";
import { RoomNotificationStateStore } from "../notifications/RoomNotificationStateStore";
import { DefaultTagID } from "../room-list/models";
import { DefaultTagID, type TagID } from "../room-list/models";
import { EnhancedMap, mapDiff } from "../../utils/maps";
import { setDiff, setHasDiff } from "../../utils/sets";
import { Action } from "../../dispatcher/actions";
import { arrayHasDiff, arrayHasOrderChange, filterBoolean } from "../../utils/arrays";
import { reorderLexicographically } from "../../utils/stringOrderField";
import { TAG_ORDER } from "../../components/views/rooms/LegacyRoomList";
import { type SettingUpdatedPayload } from "../../dispatcher/payloads/SettingUpdatedPayload";
import {
isMetaSpace,
@ -66,14 +65,23 @@ import { ModuleApi } from "../../modules/Api.ts";
const ACTIVE_SPACE_LS_KEY = "mx_active_space";
const metaSpaceOrder: MetaSpace[] = [
MetaSpace.Home,
MetaSpace.Favourites,
MetaSpace.People,
MetaSpace.Orphans,
MetaSpace.VideoRooms,
const TAG_ORDER: TagID[] = [
DefaultTagID.Invite,
DefaultTagID.Favourite,
DefaultTagID.DM,
DefaultTagID.Untagged,
DefaultTagID.Conference,
DefaultTagID.LowPriority,
DefaultTagID.ServerNotice,
DefaultTagID.Suggested,
// DefaultTagID.Archived isn't here any more: we don't show it at all.
// The section still exists in the code as a place for rooms that we know
// about but aren't joined. At some point it could be removed entirely
// but we'd have to make sure that rooms you weren't in were hidden.
];
const metaSpaceOrder: MetaSpace[] = [MetaSpace.Home, MetaSpace.Orphans, MetaSpace.VideoRooms];
const MAX_SUGGESTED_ROOMS = 20;
const getSpaceContextKey = (space: SpaceKey): string => `mx_space_context_${space}`;
@ -167,20 +175,6 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
return this._storeReadyDeferred.promise;
}
/**
* Get the order of meta spaces to display in the space panel.
*
* This accessor should be removed when the "feature_new_room_list" labs flag is removed.
* "People" and "Favourites" will be removed from the "metaSpaceOrder" array and this filter will no longer be needed.
* @private
*/
private get metaSpaceOrder(): MetaSpace[] {
if (!SettingsStore.getValue("feature_new_room_list")) return metaSpaceOrder;
// People and Favourites are not shown when the new room list is enabled
return metaSpaceOrder.filter((space) => space !== MetaSpace.People && space !== MetaSpace.Favourites);
}
public get invitedSpaces(): Room[] {
return Array.from(this._invitedSpaces);
}
@ -1198,7 +1192,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
const oldMetaSpaces = this._enabledMetaSpaces;
const enabledMetaSpaces = SettingsStore.getValue("Spaces.enabledMetaSpaces");
this._enabledMetaSpaces = this.metaSpaceOrder.filter((k) => enabledMetaSpaces[k]);
this._enabledMetaSpaces = metaSpaceOrder.filter((k) => enabledMetaSpaces[k]);
this._allRoomsInHome = SettingsStore.getValue("Spaces.allRoomsInHome");
this.sendUserProperties();
@ -1315,7 +1309,7 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
case "Spaces.enabledMetaSpaces": {
const newValue = SettingsStore.getValue("Spaces.enabledMetaSpaces");
const enabledMetaSpaces = this.metaSpaceOrder.filter((k) => newValue[k]);
const enabledMetaSpaces = metaSpaceOrder.filter((k) => newValue[k]);
if (arrayHasDiff(this._enabledMetaSpaces, enabledMetaSpaces)) {
const hadPeopleOrHomeEnabled = this.enabledMetaSpaces.some((s) => {
return s === MetaSpace.Home || s === MetaSpace.People;

View File

@ -544,9 +544,6 @@ describe("<LoggedInView />", () => {
});
it("should enforce minimum width for new room list when stored size is zero", async () => {
// Enable new room list feature
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
// 0 represents the collapsed state for the old room list, which could have been set before the new room list was enabled
window.localStorage.setItem("mx_lhs_size", "0");
@ -557,9 +554,6 @@ describe("<LoggedInView />", () => {
});
it("should not set localStorage to 0 when resizing lp-resizer to minimum width for new room list", async () => {
// Enable new room list feature and mock SettingsStore
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
const minimumWidth = 224; // NEW_ROOM_LIST_MIN_WIDTH
// Render the component

View File

@ -1,273 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 Mikhail Aheichyk
Copyright 2023 Nordeck IT + Consulting GmbH.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { cleanup, queryByRole, render, screen, within } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { type Room } from "matrix-js-sdk/src/matrix";
import LegacyRoomList from "../../../../../src/components/views/rooms/LegacyRoomList";
import ResizeNotifier from "../../../../../src/utils/ResizeNotifier";
import { MetaSpace } from "../../../../../src/stores/spaces";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
import dis from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import * as testUtils from "../../../../test-utils";
import { mkSpace, stubClient } from "../../../../test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import RoomListStore from "../../../../../src/stores/room-list/RoomListStore";
import { type ITagMap } from "../../../../../src/stores/room-list/algorithms/models";
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
jest.mock("../../../../../src/dispatcher/dispatcher");
const getUserIdForRoomId = jest.fn();
const getDMRoomsForUserId = jest.fn();
// @ts-ignore
DMRoomMap.sharedInstance = { getUserIdForRoomId, getDMRoomsForUserId };
describe("LegacyRoomList", () => {
stubClient();
const client = MatrixClientPeg.safeGet();
const store = SpaceStore.instance;
function getComponent(props: Partial<LegacyRoomList["props"]> = {}): JSX.Element {
return (
<LegacyRoomList
onKeyDown={jest.fn()}
onFocus={jest.fn()}
onBlur={jest.fn()}
onResize={jest.fn()}
resizeNotifier={new ResizeNotifier()}
isMinimized={false}
activeSpace={MetaSpace.Home}
{...props}
/>
);
}
describe("Rooms", () => {
describe("when meta space is active", () => {
beforeEach(() => {
store.setActiveSpace(MetaSpace.Home);
});
it("does not render add room button when UIComponent customisation disables CreateRooms and ExploreRooms", () => {
const disabled: UIComponent[] = [UIComponent.CreateRooms, UIComponent.ExploreRooms];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
expect(within(roomsList).queryByRole("button", { name: "Add room" })).not.toBeInTheDocument();
});
it("renders add room button with menu when UIComponent customisation allows CreateRooms or ExploreRooms", async () => {
let disabled: UIComponent[] = [];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
const { rerender } = render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
const addRoomButton = within(roomsList).getByRole("button", { name: "Add room" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await userEvent.click(addRoomButton);
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore public rooms" })).toBeInTheDocument();
disabled = [UIComponent.CreateRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore public rooms" })).toBeInTheDocument();
disabled = [UIComponent.ExploreRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Explore public rooms" })).not.toBeInTheDocument();
});
it("renders add room button and clicks explore public rooms", async () => {
mocked(shouldShowComponent).mockReturnValue(true);
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
await userEvent.click(within(roomsList).getByRole("button", { name: "Add room" }));
const menu = screen.getByRole("menu");
await userEvent.click(within(menu).getByRole("menuitem", { name: "Explore public rooms" }));
expect(dis.fire).toHaveBeenCalledWith(Action.ViewRoomDirectory);
});
});
describe("when room space is active", () => {
let rooms: Room[];
const mkSpaceForRooms = (spaceId: string, children: string[] = []) =>
mkSpace(client, spaceId, rooms, children);
const space1 = "!space1:server";
beforeEach(async () => {
rooms = [];
mkSpaceForRooms(space1);
mocked(client).getRoom.mockImplementation(
(roomId) => rooms.find((room) => room.roomId === roomId) || null,
);
await testUtils.setupAsyncStoreWithClient(store, client);
store.setActiveSpace(space1);
});
it("does not render add room button when UIComponent customisation disables CreateRooms and ExploreRooms", () => {
const disabled: UIComponent[] = [UIComponent.CreateRooms, UIComponent.ExploreRooms];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
expect(within(roomsList).queryByRole("button", { name: "Add room" })).not.toBeInTheDocument();
});
it("renders add room button with menu when UIComponent customisation allows CreateRooms or ExploreRooms", async () => {
let disabled: UIComponent[] = [];
mocked(shouldShowComponent).mockImplementation((feature) => !disabled.includes(feature));
const { rerender } = render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
const addRoomButton = within(roomsList).getByRole("button", { name: "Add room" });
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
await userEvent.click(addRoomButton);
const menu = screen.getByRole("menu");
expect(within(menu).getByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Add existing room" })).toBeInTheDocument();
disabled = [UIComponent.CreateRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "New room" })).not.toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Add existing room" })).not.toBeInTheDocument();
disabled = [UIComponent.ExploreRooms];
rerender(getComponent());
expect(addRoomButton).toBeInTheDocument();
expect(menu).toBeInTheDocument();
expect(within(menu).queryByRole("menuitem", { name: "Explore rooms" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "New room" })).toBeInTheDocument();
expect(within(menu).getByRole("menuitem", { name: "Add existing room" })).toBeInTheDocument();
});
it("renders add room button and clicks explore rooms", async () => {
mocked(shouldShowComponent).mockReturnValue(true);
render(getComponent());
const roomsList = screen.getByRole("group", { name: "Rooms" });
await userEvent.click(within(roomsList).getByRole("button", { name: "Add room" }));
const menu = screen.getByRole("menu");
await userEvent.click(within(menu).getByRole("menuitem", { name: "Explore rooms" }));
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: space1,
});
});
});
describe("when video meta space is active", () => {
const videoRoomPrivate = "!videoRoomPrivate_server";
const videoRoomPublic = "!videoRoomPublic_server";
const videoRoomKnock = "!videoRoomKnock_server";
beforeEach(async () => {
cleanup();
const rooms: Room[] = [];
testUtils.mkRoom(client, videoRoomPrivate, rooms);
testUtils.mkRoom(client, videoRoomPublic, rooms);
testUtils.mkRoom(client, videoRoomKnock, rooms);
mocked(client).getRoom.mockImplementation(
(roomId) => rooms.find((room) => room.roomId === roomId) || null,
);
mocked(client).getRooms.mockImplementation(() => rooms);
const videoRoomKnockRoom = client.getRoom(videoRoomKnock)!;
const videoRoomPrivateRoom = client.getRoom(videoRoomPrivate)!;
const videoRoomPublicRoom = client.getRoom(videoRoomPublic)!;
[videoRoomPrivateRoom, videoRoomPublicRoom, videoRoomKnockRoom].forEach((room) => {
(room.isCallRoom as jest.Mock).mockReturnValue(true);
});
const roomLists: ITagMap = {};
roomLists[DefaultTagID.Conference] = [videoRoomKnockRoom, videoRoomPublicRoom];
roomLists[DefaultTagID.Untagged] = [videoRoomPrivateRoom];
jest.spyOn(RoomListStore.instance, "orderedLists", "get").mockReturnValue(roomLists);
await testUtils.setupAsyncStoreWithClient(store, client);
store.setActiveSpace(MetaSpace.VideoRooms);
});
it("renders Conferences and Room but no People section", () => {
const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms }));
const roomsEl = renderResult.getByRole("treeitem", { name: "Rooms" });
const conferenceEl = renderResult.getByRole("treeitem", { name: "Conferences" });
const noInvites = screen.queryByRole("treeitem", { name: "Invites" });
const noFavourites = screen.queryByRole("treeitem", { name: "Favourites" });
const noPeople = screen.queryByRole("treeitem", { name: "People" });
const noLowPriority = screen.queryByRole("treeitem", { name: "Low priority" });
const noHistorical = screen.queryByRole("treeitem", { name: "Historical" });
expect(roomsEl).toBeVisible();
expect(conferenceEl).toBeVisible();
expect(noInvites).toBeFalsy();
expect(noFavourites).toBeFalsy();
expect(noPeople).toBeFalsy();
expect(noLowPriority).toBeFalsy();
expect(noHistorical).toBeFalsy();
});
it("renders Public and Knock rooms in Conferences section", () => {
const renderResult = render(getComponent({ activeSpace: MetaSpace.VideoRooms }));
const conferenceList = renderResult.getByRole("group", { name: "Conferences" });
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPublic })).toBeVisible();
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomKnock })).toBeVisible();
expect(queryByRole(conferenceList, "treeitem", { name: videoRoomPrivate })).toBeFalsy();
const roomsList = renderResult.getByRole("group", { name: "Rooms" });
expect(queryByRole(roomsList, "treeitem", { name: videoRoomPrivate })).toBeVisible();
expect(queryByRole(roomsList, "treeitem", { name: videoRoomPublic })).toBeFalsy();
expect(queryByRole(roomsList, "treeitem", { name: videoRoomKnock })).toBeFalsy();
});
});
});
});

View File

@ -1,283 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type MatrixClient, type Room, EventType } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { act, render, screen, fireEvent, type RenderResult } from "jest-matrix-react";
import SpaceStore from "../../../../../src/stores/spaces/SpaceStore";
import { MetaSpace } from "../../../../../src/stores/spaces";
import _RoomListHeader from "../../../../../src/components/views/rooms/LegacyRoomListHeader";
import * as testUtils from "../../../../test-utils";
import { stubClient, mkSpace } from "../../../../test-utils";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { SettingLevel } from "../../../../../src/settings/SettingLevel";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
const RoomListHeader = testUtils.wrapInMatrixClientContext(_RoomListHeader);
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
const blockUIComponent = (component: UIComponent): void => {
mocked(shouldShowComponent).mockImplementation((feature) => feature !== component);
};
const setupSpace = (client: MatrixClient): Room => {
const testSpace: Room = mkSpace(client, "!space:server");
testSpace.name = "Test Space";
client.getRoom = () => testSpace;
return testSpace;
};
const setupMainMenu = async (client: MatrixClient, testSpace: Room): Promise<RenderResult> => {
await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client);
act(() => {
SpaceStore.instance.setActiveSpace(testSpace.roomId);
});
const wrapper = render(<RoomListHeader />);
expect(wrapper.container.textContent).toBe("Test Space");
act(() => {
wrapper.container.querySelector<HTMLElement>('[aria-label="Test Space menu"]')?.click();
});
return wrapper;
};
const setupPlusMenu = async (client: MatrixClient, testSpace: Room): Promise<RenderResult> => {
await testUtils.setupAsyncStoreWithClient(SpaceStore.instance, client);
act(() => {
SpaceStore.instance.setActiveSpace(testSpace.roomId);
});
const wrapper = render(<RoomListHeader />);
expect(wrapper.container.textContent).toBe("Test Space");
act(() => {
wrapper.container.querySelector<HTMLElement>('[aria-label="Add"]')?.click();
});
return wrapper;
};
const checkIsDisabled = (menuItem: HTMLElement): void => {
expect(menuItem).toHaveAttribute("disabled");
expect(menuItem).toHaveAttribute("aria-disabled", "true");
};
const checkMenuLabels = (items: NodeListOf<Element>, labelArray: Array<string>) => {
expect(items).toHaveLength(labelArray.length);
const checkLabel = (item: Element, label: string) => {
expect(item.querySelector(".mx_IconizedContextMenu_label")?.textContent).toBe(label);
};
labelArray.forEach((label, index) => {
console.log("index", index, "label", label);
checkLabel(items[index], label);
});
};
describe("RoomListHeader", () => {
let client: MatrixClient;
beforeEach(async () => {
// This tests the header from the old room list/left panel, the new one is now enabled by default,
// so needs to be explicitly disabled to test the old list here.
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, false);
jest.resetAllMocks();
const dmRoomMap = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
} as unknown as DMRoomMap;
DMRoomMap.setShared(dmRoomMap);
stubClient();
client = MatrixClientPeg.safeGet();
mocked(shouldShowComponent).mockReturnValue(true); // show all UIComponents
});
it("renders a main menu for the home space", () => {
act(() => {
SpaceStore.instance.setActiveSpace(MetaSpace.Home);
});
const { container } = render(<RoomListHeader />);
expect(container.textContent).toBe("Home");
fireEvent.click(screen.getByLabelText("Home options"));
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
expect(items).toHaveLength(1);
expect(items[0].textContent).toBe("Show all rooms");
});
it("renders a main menu for spaces", async () => {
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["Space home", "Manage & explore rooms", "Preferences", "Settings", "Room", "Space"]);
});
it("renders a plus menu for spaces", async () => {
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
});
it("closes menu if space changes from under it", async () => {
await SettingsStore.setValue("Spaces.enabledMetaSpaces", null, SettingLevel.DEVICE, {
[MetaSpace.Home]: true,
[MetaSpace.Favourites]: true,
});
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
act(() => {
SpaceStore.instance.setActiveSpace(MetaSpace.Favourites);
});
screen.getByText("Favourites");
expect(screen.queryByRole("menu")).toBeFalsy();
});
describe("UIComponents", () => {
describe("Main menu", () => {
it("does not render Add Space when user does not have permission to add spaces", async () => {
// User does not have permission to add spaces, anywhere
blockUIComponent(UIComponent.CreateSpaces);
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Manage & explore rooms",
"Preferences",
"Settings",
"Room",
// no add space
]);
});
it("does not render Add Room when user does not have permission to add rooms", async () => {
// User does not have permission to add rooms
blockUIComponent(UIComponent.CreateRooms);
const testSpace = setupSpace(client);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Explore rooms", // not Manage & explore rooms
"Preferences",
"Settings",
// no add room
"Space",
]);
});
});
describe("Plus menu", () => {
it("does not render Add Space when user does not have permission to add spaces", async () => {
// User does not have permission to add spaces, anywhere
blockUIComponent(UIComponent.CreateSpaces);
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"New room",
"Explore rooms",
"Add existing room",
// no Add space
]);
});
it("disables Add Room when user does not have permission to add rooms", async () => {
// User does not have permission to add rooms
blockUIComponent(UIComponent.CreateRooms);
const testSpace = setupSpace(client);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll<HTMLElement>(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
// "Add existing room" is disabled
checkIsDisabled(items[2]);
});
});
});
describe("adding children to space", () => {
it("if user cannot add children to space, MainMenu adding buttons are hidden", async () => {
const testSpace = setupSpace(client);
mocked(testSpace.currentState.maySendStateEvent).mockImplementation(
(stateEventType, userId) => stateEventType !== EventType.SpaceChild,
);
await setupMainMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll(".mx_IconizedContextMenu_item");
checkMenuLabels(items, [
"Space home",
"Explore rooms", // not Manage & explore rooms
"Preferences",
"Settings",
// no add room
// no add space
]);
});
it("if user cannot add children to space, PlusMenu add buttons are disabled", async () => {
const testSpace = setupSpace(client);
mocked(testSpace.currentState.maySendStateEvent).mockImplementation(
(stateEventType, userId) => stateEventType !== EventType.SpaceChild,
);
await setupPlusMenu(client, testSpace);
const menu = screen.getByRole("menu");
const items = menu.querySelectorAll<HTMLElement>(".mx_IconizedContextMenu_item");
checkMenuLabels(items, ["New room", "Explore rooms", "Add existing room", "Add space"]);
// "Add existing room" is disabled
checkIsDisabled(items[2]);
// "Add space" is disabled
checkIsDisabled(items[3]);
});
});
});

View File

@ -1,317 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, act, type RenderResult } from "jest-matrix-react";
import { mocked, type Mocked } from "jest-mock";
import {
type MatrixClient,
PendingEventOrdering,
Room,
RoomStateEvent,
type Thread,
type RoomMember,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { Widget } from "matrix-widget-api";
import type { ClientWidgetApi } from "matrix-widget-api";
import {
stubClient,
mkRoomMember,
MockedCall,
useMockedCalls,
setupAsyncStoreWithClient,
filterConsole,
flushPromises,
mkMessage,
useMockMediaDevices,
} from "../../../../test-utils";
import { CallStore } from "../../../../../src/stores/CallStore";
import RoomTile from "../../../../../src/components/views/rooms/RoomTile";
import { DefaultTagID } from "../../../../../src/stores/room-list/models";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import PlatformPeg from "../../../../../src/PlatformPeg";
import type BasePlatform from "../../../../../src/BasePlatform";
import { WidgetMessagingStore } from "../../../../../src/stores/widgets/WidgetMessagingStore";
import { TestSdkContext } from "../../../TestSdkContext";
import { SDKContext } from "../../../../../src/contexts/SDKContext";
import { shouldShowComponent } from "../../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../../src/settings/UIFeature";
import { MessagePreviewStore } from "../../../../../src/stores/room-list/MessagePreviewStore";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import SettingsStore from "../../../../../src/settings/SettingsStore";
import { ConnectionState } from "../../../../../src/models/Call";
jest.mock("../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("RoomTile", () => {
jest.spyOn(PlatformPeg, "get").mockReturnValue({
overrideBrowserShortcuts: () => false,
} as unknown as BasePlatform);
useMockedCalls();
const renderRoomTile = (): RenderResult => {
return render(
<SDKContext.Provider value={sdkContext}>
<RoomTile
room={room}
showMessagePreview={showMessagePreview}
isMinimized={false}
tag={DefaultTagID.Untagged}
/>
</SDKContext.Provider>,
);
};
let client: Mocked<MatrixClient>;
let room: Room;
let sdkContext: TestSdkContext;
let showMessagePreview = false;
filterConsole(
// irrelevant for this test
"Room !1:example.org does not have an m.room.create event",
);
const addMessageToRoom = (ts: number) => {
const message = mkMessage({
event: true,
room: room.roomId,
msg: "test message",
user: client.getSafeUserId(),
ts,
});
room.timeline.push(message);
};
const addThreadMessageToRoom = (ts: number) => {
const message = mkMessage({
event: true,
room: room.roomId,
msg: "test thread reply",
user: client.getSafeUserId(),
ts,
});
// Mock thread reply for tests.
jest.spyOn(room, "getThreads").mockReturnValue([
// @ts-ignore
{
lastReply: () => message,
timeline: [],
} as Thread,
]);
};
beforeEach(() => {
useMockMediaDevices();
sdkContext = new TestSdkContext();
client = mocked(stubClient());
sdkContext.client = client;
DMRoomMap.makeShared(client);
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
});
afterEach(() => {
// @ts-ignore
MessagePreviewStore.instance.previews = new Map<string, Map<TagID | TAG_ANY, MessagePreview | null>>();
jest.clearAllMocks();
});
describe("when message previews are not enabled", () => {
it("should render the room", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const { container } = renderRoomTile();
expect(container).toMatchSnapshot();
expect(container.querySelector(".mx_RoomTile_sticky")).not.toBeInTheDocument();
});
it("does not render the room options context menu when UIComponent customisations disable room options", () => {
mocked(shouldShowComponent).mockReturnValue(false);
renderRoomTile();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
it("renders the room options context menu when UIComponent customisations enable room options", () => {
mocked(shouldShowComponent).mockReturnValue(true);
renderRoomTile();
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(screen.queryByRole("button", { name: "Room options" })).toBeInTheDocument();
});
it("does not render the room options context menu when knocked to the room", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "feature_ask_to_join";
});
mocked(shouldShowComponent).mockReturnValue(true);
jest.spyOn(room, "getMyMembership").mockReturnValue(KnownMembership.Knock);
const { container } = renderRoomTile();
expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
it("does not render the room options context menu when knock has been denied", () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
return name === "feature_ask_to_join";
});
mocked(shouldShowComponent).mockReturnValue(true);
const roomMember = mkRoomMember(
room.roomId,
MatrixClientPeg.get()!.getSafeUserId(),
KnownMembership.Leave,
true,
{
membership: KnownMembership.Knock,
},
);
jest.spyOn(room, "getMember").mockReturnValue(roomMember);
const { container } = renderRoomTile();
expect(container.querySelector(".mx_RoomTile_sticky")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Room options" })).not.toBeInTheDocument();
});
describe("when a call starts", () => {
let call: MockedCall;
let widget: Widget;
beforeEach(() => {
setupAsyncStoreWithClient(CallStore.instance, client);
setupAsyncStoreWithClient(WidgetMessagingStore.instance, client);
MockedCall.create(room, "1");
const maybeCall = CallStore.instance.getCall(room.roomId);
if (!(maybeCall instanceof MockedCall)) throw new Error("Failed to create call");
call = maybeCall;
widget = new Widget(call.widget);
WidgetMessagingStore.instance.storeMessaging(widget, room.roomId, {
stop: () => {},
} as unknown as ClientWidgetApi);
});
afterEach(() => {
call.destroy();
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
WidgetMessagingStore.instance.stopMessaging(widget, room.roomId);
});
it("tracks connection state", async () => {
renderRoomTile();
screen.getByText("Video");
act(() => call.setConnectionState(ConnectionState.Connected));
screen.getByText("Joined");
await act(() => call.disconnect());
screen.getByText("Video");
});
it("tracks participants", () => {
renderRoomTile();
const alice: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@alice:example.org"),
new Set(["a"]),
];
const bob: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@bob:example.org"),
new Set(["b1", "b2"]),
];
const carol: [RoomMember, Set<string>] = [
mkRoomMember(room.roomId, "@carol:example.org"),
new Set(["c"]),
];
expect(screen.queryByLabelText(/participant/)).toBe(null);
act(() => {
call.participants = new Map([alice]);
});
expect(screen.getByLabelText("1 person joined").textContent).toBe("1");
act(() => {
call.participants = new Map([alice, bob, carol]);
});
expect(screen.getByLabelText("4 people joined").textContent).toBe("4");
act(() => {
call.participants = new Map();
});
expect(screen.queryByLabelText(/participant/)).toBe(null);
});
});
});
describe("when message previews are enabled", () => {
beforeEach(() => {
showMessagePreview = true;
});
it("should render a room without a message as expected", async () => {
const renderResult = renderRoomTile();
// flush promises here because the preview is created asynchronously
await flushPromises();
expect(renderResult.asFragment()).toMatchSnapshot();
});
describe("and there is a message in the room", () => {
beforeEach(() => {
addMessageToRoom(23);
});
it("should render as expected", async () => {
const renderResult = renderRoomTile();
expect(await screen.findByText("test message")).toBeInTheDocument();
expect(renderResult.asFragment()).toMatchSnapshot();
});
});
describe("and there is a message in a thread", () => {
beforeEach(() => {
addThreadMessageToRoom(23);
});
it("should render as expected", async () => {
const renderResult = renderRoomTile();
expect(await screen.findByText("test thread reply")).toBeInTheDocument();
expect(renderResult.asFragment()).toMatchSnapshot();
});
});
describe("and there is a message and a thread without a reply", () => {
beforeEach(() => {
addMessageToRoom(23);
// Mock thread reply for tests.
jest.spyOn(room, "getThreads").mockReturnValue([
// @ts-ignore
{
lastReply: () => null,
timeline: [],
findEventById: () => {},
} as Thread,
]);
});
it("should render the message preview", async () => {
renderRoomTile();
expect(await screen.findByText("test message")).toBeInTheDocument();
});
});
});
});

View File

@ -141,8 +141,6 @@ describe("SpaceStore", () => {
});
afterEach(async () => {
// Disable the new room list feature flag
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, false);
await testUtils.resetAsyncStoreWithClient(store);
});
@ -1402,10 +1400,7 @@ describe("SpaceStore", () => {
removeListener();
});
it("Favourites and People meta spaces should not be returned when the feature_new_room_list labs flag is enabled", async () => {
// Enable the new room list
await SettingsStore.setValue("feature_new_room_list", null, SettingLevel.DEVICE, true);
it("Favourites and People meta spaces should not be returned", async () => {
await run();
// Favourites and People meta spaces should not be returned
expect(SpaceStore.instance.enabledMetaSpaces).toStrictEqual([MetaSpace.Home, MetaSpace.Orphans]);