mirror of
https://github.com/vector-im/element-web.git
synced 2026-05-05 20:26:19 +02:00
Delabs room list
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
parent
16fbb27983
commit
3c673f7a8b
@ -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.
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -22,7 +22,6 @@ test.describe("Release announcement", () => {
|
||||
await app.viewRoomById(roomId);
|
||||
await use({ roomId });
|
||||
},
|
||||
labsFlags: ["feature_new_room_list"],
|
||||
});
|
||||
|
||||
test(
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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");
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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 */
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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";
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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")}>
|
||||
|
||||
@ -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")}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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 {
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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]);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user