Add feature flag 'feature_new_room_decoration_ui' and segrate legacy UI component (#11345)

* Move RoomHeader to LegacyRoomHeader

* Create new RoomHeader component
This commit is contained in:
Germain 2023-08-01 08:32:53 +01:00 committed by GitHub
parent 89a92c6351
commit 6ae7c033d5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 2309 additions and 2103 deletions

View file

@ -59,7 +59,7 @@ describe("Create Room", () => {
cy.url().should("contain", "/#/room/#test-room-1:localhost");
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
cy.findByText(name);
cy.findByText(topic);
});

View file

@ -166,8 +166,8 @@ describe("Invite dialog", function () {
// Assert that the hovered user name on invitation UI does not have background color
// TODO: implement the test on room-header.spec.ts
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_RoomHeader_name--textonly")
cy.get(".mx_LegacyRoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader_name--textonly")
.realHover()
.should("have.css", "background-color", "rgba(0, 0, 0, 0)");
});

View file

@ -116,7 +116,7 @@ describe("Lazy Loading", () => {
}
function openMemberlist(): void {
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
cy.findByRole("button", { name: "Room info" }).click();
});

View file

@ -45,7 +45,7 @@ describe("1:1 chat room", () => {
it("should open new 1:1 chat room after leaving the old one", () => {
// leave 1:1 chat room
cy.get(".mx_RoomHeader_nametext").within(() => {
cy.get(".mx_LegacyRoomHeader_nametext").within(() => {
cy.findByText(username).click();
});
cy.findByRole("menuitem", { name: "Leave" }).click();
@ -60,7 +60,7 @@ describe("1:1 chat room", () => {
// open new 1:1 chat room
cy.visit(`/#/user/${user2.userId}?action=chat`);
cy.get(".mx_RoomHeader_nametext").within(() => {
cy.get(".mx_LegacyRoomHeader_nametext").within(() => {
cy.findByText(username);
});
});

View file

@ -38,8 +38,8 @@ describe("Room Header", () => {
it("should render default buttons properly", () => {
cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room");
cy.get(".mx_RoomHeader").within(() => {
// Names (aria-label) of every button rendered on mx_RoomHeader by default
cy.get(".mx_LegacyRoomHeader").within(() => {
// Names (aria-label) of every button rendered on mx_LegacyRoomHeader by default
const expectedButtonNames = [
"Room options", // The room name button next to the room avatar, which renders dropdown menu on click
"Voice call",
@ -55,11 +55,11 @@ describe("Room Header", () => {
cy.findByRole("button", { name }).should("be.visible");
}
// Assert that just those seven buttons exist on mx_RoomHeader by default
// Assert that just those seven buttons exist on mx_LegacyRoomHeader by default
cy.findAllByRole("button").should("have.length", 7);
});
cy.get(".mx_RoomHeader").percySnapshotElement("Room header");
cy.get(".mx_LegacyRoomHeader").percySnapshotElement("Room header");
});
it("should render the pin button for pinned messages card", () => {
@ -73,7 +73,7 @@ describe("Room Header", () => {
cy.findByRole("menuitem", { name: "Pin" }).should("be.visible").click();
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
cy.findByRole("button", { name: "Pinned messages" }).should("be.visible");
});
});
@ -88,22 +88,22 @@ describe("Room Header", () => {
cy.createRoom({ name: LONG_ROOM_NAME }).viewRoomByName(LONG_ROOM_NAME);
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
// Wait until the room name is set
cy.get(".mx_RoomHeader_nametext").within(() => {
cy.get(".mx_LegacyRoomHeader_nametext").within(() => {
cy.findByText(LONG_ROOM_NAME).should("exist");
});
// Assert the size of buttons on RoomHeader are specified and the buttons are not compressed
// Note these assertions do not check the size of mx_RoomHeader_name button
cy.get(".mx_RoomHeader_button")
// Note these assertions do not check the size of mx_LegacyRoomHeader_name button
cy.get(".mx_LegacyRoomHeader_button")
.should("have.length", 6)
.should("be.visible")
.should("have.css", "height", "32px")
.should("have.css", "width", "32px");
});
cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with a long room name", {
cy.get(".mx_LegacyRoomHeader").percySnapshotElement("Room header - with a long room name", {
widths: [300, 600], // Magic numbers to emulate the narrow RoomHeader on the actual UI
});
});
@ -111,7 +111,7 @@ describe("Room Header", () => {
it("should have buttons highlighted by being clicked", () => {
cy.createRoom({ name: "Test Room" }).viewRoomByName("Test Room");
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
// Check these buttons
const buttonsHighlighted = ["Threads", "Notifications", "Room info"];
@ -120,7 +120,7 @@ describe("Room Header", () => {
}
});
cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with a highlighted button");
cy.get(".mx_LegacyRoomHeader").percySnapshotElement("Room header - with a highlighted button");
});
describe("with a video room", () => {
@ -144,7 +144,7 @@ describe("Room Header", () => {
it("should render buttons for room options, beta pill, invite, chat, and room info", () => {
createVideoRoom();
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
// Names (aria-label) of the buttons on the video room header
const expectedButtonNames = [
"Room options",
@ -163,13 +163,13 @@ describe("Room Header", () => {
cy.findAllByRole("button").should("have.length", 7);
});
cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with a video room");
cy.get(".mx_LegacyRoomHeader").percySnapshotElement("Room header - with a video room");
});
it("should render a working chat button which opens the timeline on a right panel", () => {
createVideoRoom();
cy.get(".mx_RoomHeader").findByRole("button", { name: "Chat" }).click();
cy.get(".mx_LegacyRoomHeader").findByRole("button", { name: "Chat" }).click();
// Assert that the video is rendered
cy.get(".mx_CallView video").should("exist");
@ -250,20 +250,20 @@ describe("Room Header", () => {
// Assert that AppsDrawer is rendered
cy.get(".mx_AppsDrawer").should("exist");
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
// Assert that "Hide Widgets" button is rendered and aria-checked is set to true
cy.findByRole("button", { name: "Hide Widgets" })
.should("exist")
.should("have.attr", "aria-checked", "true");
});
cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with apps button (highlighted)");
cy.get(".mx_LegacyRoomHeader").percySnapshotElement("Room header - with apps button (highlighted)");
});
it("should support hiding a widget", () => {
cy.get(".mx_AppsDrawer").should("exist");
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
// Click the apps button to hide AppsDrawer
cy.findByRole("button", { name: "Hide Widgets" }).should("exist").click();
@ -276,7 +276,7 @@ describe("Room Header", () => {
// Assert that AppsDrawer is not rendered
cy.get(".mx_AppsDrawer").should("not.exist");
cy.get(".mx_RoomHeader").percySnapshotElement("Room header - with apps button (not highlighted)");
cy.get(".mx_LegacyRoomHeader").percySnapshotElement("Room header - with apps button (not highlighted)");
});
});
});

View file

@ -114,7 +114,7 @@ Cypress.Commands.add(
Cypress.Commands.add(
"roomHeaderName",
(options?: Partial<Loggable & Timeoutable & Withinable & Shadow>): Chainable<JQuery<HTMLElement>> => {
return cy.get(".mx_RoomHeader_nametext", options);
return cy.get(".mx_LegacyRoomHeader_nametext", options);
},
);
@ -204,7 +204,7 @@ describe("Spotlight", () => {
});
});
// wait for the room to have the right name
cy.get(".mx_RoomHeader").within(() => {
cy.get(".mx_LegacyRoomHeader").within(() => {
cy.findByText(room1Name);
});
});

View file

@ -279,7 +279,7 @@ describe("Threads", () => {
});
cy.findByRole("button", { name: "Threads" })
.should("have.class", "mx_RoomHeader_button--unread") // User asserts thread list unread indicator
.should("have.class", "mx_LegacyRoomHeader_button--unread") // User asserts thread list unread indicator
.click(); // User opens thread list
// User asserts thread with correct root & latest events & unread dot

View file

@ -755,7 +755,7 @@ describe("Timeline", () => {
sendEvent(roomId, true);
cy.visit("/#/room/" + roomId);
cy.get(".mx_RoomHeader").findByRole("button", { name: "Search" }).click();
cy.get(".mx_LegacyRoomHeader").findByRole("button", { name: "Search" }).click();
cy.get(".mx_SearchBar").percySnapshotElement("Search bar on the timeline", {
// Emulate narrow timeline
@ -791,7 +791,7 @@ describe("Timeline", () => {
.should("have.class", "mx_TextualEvent");
// Display the room search bar
cy.get(".mx_RoomHeader").findByRole("button", { name: "Search" }).click();
cy.get(".mx_LegacyRoomHeader").findByRole("button", { name: "Search" }).click();
// Search the string to display both the message and TextualEvent on search results panel
cy.get(".mx_SearchBar").within(() => {

View file

@ -275,6 +275,7 @@
@import "./views/rooms/_HistoryTile.pcss";
@import "./views/rooms/_IRCLayout.pcss";
@import "./views/rooms/_JumpToBottomButton.pcss";
@import "./views/rooms/_LegacyRoomHeader.pcss";
@import "./views/rooms/_LinkPreviewGroup.pcss";
@import "./views/rooms/_LinkPreviewWidget.pcss";
@import "./views/rooms/_LiveContentSummary.pcss";

View file

@ -27,7 +27,7 @@ limitations under the License.
/* The resizer should be centered: only half of the gap-width is handled by the right panel. */
/* The other half by the RoomView. */
padding-left: calc(var(--container-gap-width) / 2);
height: calc(100vh - 51px); /* height of .mx_RoomHeader.light-panel */
height: calc(100vh - 51px); /* height of .mx_LegacyRoomHeader.light-panel */
&:hover .mx_ResizeHandle--horizontal::before {
position: absolute;

View file

@ -190,7 +190,7 @@ limitations under the License.
}
/* Rooms with immersive content */
.mx_RoomView_immersive .mx_RoomHeader_wrapper {
.mx_RoomView_immersive .mx_LegacyRoomHeader_wrapper {
border: unset;
}

View file

@ -0,0 +1,333 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
:root {
--RoomHeader-indicator-dot-size: 8px;
--RoomHeader-indicator-dot-offset: -3px;
--RoomHeader-indicator-pulseColor: $alert;
}
.mx_LegacyRoomHeader {
flex: 0 0 50px;
border-bottom: 1px solid $primary-hairline-color;
background-color: $background;
.mx_LegacyRoomHeader_icon {
height: 12px;
width: 12px;
&.mx_LegacyRoomHeader_icon_video {
height: 14px;
width: 14px;
background-color: $secondary-content;
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
mask-size: 100%;
}
&.mx_E2EIcon {
margin: 0;
height: 100%; /* To give the tooltip room to breathe */
}
}
.mx_CallDuration {
margin-top: calc(($font-15px - $font-13px) / 2); /* To align with the name */
font-size: $font-13px;
}
}
.mx_LegacyRoomHeader_wrapper {
height: 44px;
display: flex;
align-items: center;
min-width: 0;
margin: 0 20px 0 16px;
padding-top: 6px;
border-bottom: 1px solid $separator;
.mx_InviteOnlyIcon_large {
margin: 0;
}
.mx_BetaCard_betaPill {
margin-right: $spacing-8;
}
}
.mx_LegacyRoomHeader_name {
flex: 0 1 auto;
overflow: hidden;
color: $primary-content;
font: var(--cpd-font-heading-sm-semibold);
font-weight: var(--cpd-font-weight-semibold);
min-height: 24px;
align-items: center;
border-radius: 6px;
margin: 0 3px;
padding: 1px 4px;
display: flex;
user-select: none;
cursor: pointer;
&:hover {
background-color: $quinary-content;
}
.mx_LegacyRoomHeader_nametext {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.mx_LegacyRoomHeader_chevron {
align-self: center;
width: 20px;
height: 20px;
mask-position: center;
mask-size: 20px;
mask-repeat: no-repeat;
mask-image: url("$(res)/img/feather-customised/chevron-down.svg");
background-color: $tertiary-content;
}
&.mx_LegacyRoomHeader_name--textonly {
cursor: unset;
&:hover {
background-color: unset;
}
}
&[aria-expanded="true"] {
background-color: $separator;
.mx_LegacyRoomHeader_chevron {
transform: rotate(180deg);
}
}
}
.mx_LegacyRoomHeader_settingsHint {
color: $settings-grey-fg-color !important;
}
.mx_LegacyRoomHeader_searchStatus {
font-weight: normal;
opacity: 0.6;
}
.mx_RoomTopic {
position: relative;
cursor: pointer;
}
.mx_LegacyRoomHeader_topic {
$lines: 2;
flex: 1;
color: $secondary-content;
font: var(--cpd-font-body-sm-regular);
line-height: 1rem;
max-height: calc(1rem * $lines);
overflow: hidden;
-webkit-line-clamp: $lines; /* See: https://drafts.csswg.org/css-overflow-3/#webkit-line-clamp */
-webkit-box-orient: vertical;
display: -webkit-box;
}
.mx_LegacyRoomHeader_topic .mx_Emoji {
/* Undo font size increase to prevent vertical cropping and ensure the same size */
/* as in plain text emojis */
font-size: inherit;
}
.mx_LegacyRoomHeader_avatar {
flex: 0;
margin: 0 7px;
position: relative;
cursor: pointer;
}
.mx_LegacyRoomHeader_avatar .mx_BaseAvatar_image {
object-fit: cover;
}
.mx_LegacyRoomHeader_button {
cursor: pointer;
flex: 0 0 auto;
margin-left: 1px;
margin-right: 1px;
height: 32px;
width: 32px;
position: relative;
border-radius: 100%;
&::before {
content: "";
position: absolute;
top: 4px; /* center with parent of 32px */
left: 4px; /* center with parent of 32px */
height: 24px;
width: 24px;
background-color: $icon-button-color;
mask-repeat: no-repeat;
mask-size: contain;
}
&:hover {
background: $accent-300;
&::before {
background-color: $accent;
}
}
}
.mx_LegacyRoomHeader_button_unreadIndicator_bg {
position: absolute;
right: var(--RoomHeader-indicator-dot-offset);
top: var(--RoomHeader-indicator-dot-offset);
margin: 4px;
width: var(--RoomHeader-indicator-dot-size);
height: var(--RoomHeader-indicator-dot-size);
border-radius: 50%;
transform: scale(1.6);
transform-origin: center center;
background: $background;
}
.mx_LegacyRoomHeader_button_unreadIndicator {
position: absolute;
right: var(--RoomHeader-indicator-dot-offset);
top: var(--RoomHeader-indicator-dot-offset);
margin: 4px;
&.mx_Indicator_red {
background: $alert;
box-shadow: $alert;
}
&.mx_Indicator_gray {
background: $room-icon-unread-color;
box-shadow: $room-icon-unread-color;
}
&.mx_Indicator_bold {
background: $primary-content;
box-shadow: $primary-content;
}
}
.mx_LegacyRoomHeader_button--unread {
&::before {
background-color: $room-icon-unread-color !important;
}
}
.mx_LegacyRoomHeader_button--highlight,
.mx_LegacyRoomHeader_button:hover {
&::before {
background-color: $accent !important;
}
}
.mx_LegacyRoomHeader_forgetButton::before {
mask-image: url("$(res)/img/element-icons/leave.svg");
width: 26px;
}
.mx_LegacyRoomHeader_appsButton::before {
mask-image: url("$(res)/img/element-icons/room/apps.svg");
}
.mx_LegacyRoomHeader_appsButton_highlight::before {
background-color: $accent;
}
.mx_LegacyRoomHeader_searchButton::before {
mask-image: url("$(res)/img/element-icons/room/search-inset.svg");
}
.mx_LegacyRoomHeader_inviteButton::before {
mask-image: url("$(res)/img/element-icons/room/invite.svg");
}
.mx_LegacyRoomHeader_voiceCallButton::before {
mask-image: url("$(res)/img/element-icons/call/voice-call.svg");
/* The call button SVG is padded slightly differently, so match it up to the size */
/* of the other icons */
mask-size: 20px;
mask-position: center;
}
.mx_LegacyRoomHeader_videoCallButton::before {
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
}
.mx_LegacyRoomHeader_layoutButton--freedom::before,
.mx_LegacyRoomHeader_freedomIcon::before {
mask-image: url("$(res)/img/element-icons/call/freedom.svg");
}
.mx_LegacyRoomHeader_layoutButton--spotlight::before,
.mx_LegacyRoomHeader_spotlightIcon::before {
mask-image: url("$(res)/img/element-icons/call/spotlight.svg");
}
.mx_LegacyRoomHeader_closeButton {
&::before {
mask-image: url("$(res)/img/cancel.svg");
mask-size: 20px;
mask-position: center;
}
&:hover {
background: unset; /* remove background color on hover */
&::before {
background-color: $icon-button-color; /* set the default background color */
}
}
}
.mx_LegacyRoomHeader_minimiseButton::before {
mask-image: url("$(res)/img/element-icons/reduce.svg");
}
.mx_LegacyRoomHeader_layoutMenu .mx_IconizedContextMenu_icon::before {
content: "";
width: 16px;
height: 16px;
display: block;
mask-position: center;
mask-size: 20px;
mask-repeat: no-repeat;
background: $primary-content;
}
@media only screen and (max-width: 480px) {
.mx_LegacyRoomHeader_wrapper {
padding: 0;
margin: 0;
}
.mx_LegacyRoomHeader {
overflow: hidden;
}
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -20,36 +20,13 @@ limitations under the License.
--RoomHeader-indicator-pulseColor: $alert;
}
.mx_RoomHeader {
.mx_LegacyRoomHeader {
flex: 0 0 50px;
border-bottom: 1px solid $primary-hairline-color;
background-color: $background;
.mx_RoomHeader_icon {
height: 12px;
width: 12px;
&.mx_RoomHeader_icon_video {
height: 14px;
width: 14px;
background-color: $secondary-content;
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
mask-size: 100%;
}
&.mx_E2EIcon {
margin: 0;
height: 100%; /* To give the tooltip room to breathe */
}
}
.mx_CallDuration {
margin-top: calc(($font-15px - $font-13px) / 2); /* To align with the name */
font-size: $font-13px;
}
}
.mx_RoomHeader_wrapper {
.mx_LegacyRoomHeader_wrapper {
height: 44px;
display: flex;
align-items: center;
@ -57,17 +34,9 @@ limitations under the License.
margin: 0 20px 0 16px;
padding-top: 6px;
border-bottom: 1px solid $separator;
.mx_InviteOnlyIcon_large {
margin: 0;
}
.mx_BetaCard_betaPill {
margin-right: $spacing-8;
}
}
.mx_RoomHeader_name {
.mx_LegacyRoomHeader_name {
flex: 0 1 auto;
overflow: hidden;
color: $primary-content;
@ -81,252 +50,4 @@ limitations under the License.
display: flex;
user-select: none;
cursor: pointer;
&:hover {
background-color: $quinary-content;
}
.mx_RoomHeader_nametext {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.mx_RoomHeader_chevron {
align-self: center;
width: 20px;
height: 20px;
mask-position: center;
mask-size: 20px;
mask-repeat: no-repeat;
mask-image: url("$(res)/img/feather-customised/chevron-down.svg");
background-color: $tertiary-content;
}
&.mx_RoomHeader_name--textonly {
cursor: unset;
&:hover {
background-color: unset;
}
}
&[aria-expanded="true"] {
background-color: $separator;
.mx_RoomHeader_chevron {
transform: rotate(180deg);
}
}
}
.mx_RoomHeader_settingsHint {
color: $settings-grey-fg-color !important;
}
.mx_RoomHeader_searchStatus {
font-weight: normal;
opacity: 0.6;
}
.mx_RoomTopic {
position: relative;
cursor: pointer;
}
.mx_RoomHeader_topic {
$lines: 2;
flex: 1;
color: $secondary-content;
font: var(--cpd-font-body-sm-regular);
line-height: 1rem;
max-height: calc(1rem * $lines);
overflow: hidden;
-webkit-line-clamp: $lines; /* See: https://drafts.csswg.org/css-overflow-3/#webkit-line-clamp */
-webkit-box-orient: vertical;
display: -webkit-box;
}
.mx_RoomHeader_topic .mx_Emoji {
/* Undo font size increase to prevent vertical cropping and ensure the same size */
/* as in plain text emojis */
font-size: inherit;
}
.mx_RoomHeader_avatar {
flex: 0;
margin: 0 7px;
position: relative;
cursor: pointer;
}
.mx_RoomHeader_avatar .mx_BaseAvatar_image {
object-fit: cover;
}
.mx_RoomHeader_button {
cursor: pointer;
flex: 0 0 auto;
margin-left: 1px;
margin-right: 1px;
height: 32px;
width: 32px;
position: relative;
border-radius: 100%;
&::before {
content: "";
position: absolute;
top: 4px; /* center with parent of 32px */
left: 4px; /* center with parent of 32px */
height: 24px;
width: 24px;
background-color: $icon-button-color;
mask-repeat: no-repeat;
mask-size: contain;
}
&:hover {
background: $accent-300;
&::before {
background-color: $accent;
}
}
}
.mx_RoomHeader_button_unreadIndicator_bg {
position: absolute;
right: var(--RoomHeader-indicator-dot-offset);
top: var(--RoomHeader-indicator-dot-offset);
margin: 4px;
width: var(--RoomHeader-indicator-dot-size);
height: var(--RoomHeader-indicator-dot-size);
border-radius: 50%;
transform: scale(1.6);
transform-origin: center center;
background: $background;
}
.mx_RoomHeader_button_unreadIndicator {
position: absolute;
right: var(--RoomHeader-indicator-dot-offset);
top: var(--RoomHeader-indicator-dot-offset);
margin: 4px;
&.mx_Indicator_red {
background: $alert;
box-shadow: $alert;
}
&.mx_Indicator_gray {
background: $room-icon-unread-color;
box-shadow: $room-icon-unread-color;
}
&.mx_Indicator_bold {
background: $primary-content;
box-shadow: $primary-content;
}
}
.mx_RoomHeader_button--unread {
&::before {
background-color: $room-icon-unread-color !important;
}
}
.mx_RoomHeader_button--highlight,
.mx_RoomHeader_button:hover {
&::before {
background-color: $accent !important;
}
}
.mx_RoomHeader_forgetButton::before {
mask-image: url("$(res)/img/element-icons/leave.svg");
width: 26px;
}
.mx_RoomHeader_appsButton::before {
mask-image: url("$(res)/img/element-icons/room/apps.svg");
}
.mx_RoomHeader_appsButton_highlight::before {
background-color: $accent;
}
.mx_RoomHeader_searchButton::before {
mask-image: url("$(res)/img/element-icons/room/search-inset.svg");
}
.mx_RoomHeader_inviteButton::before {
mask-image: url("$(res)/img/element-icons/room/invite.svg");
}
.mx_RoomHeader_voiceCallButton::before {
mask-image: url("$(res)/img/element-icons/call/voice-call.svg");
/* The call button SVG is padded slightly differently, so match it up to the size */
/* of the other icons */
mask-size: 20px;
mask-position: center;
}
.mx_RoomHeader_videoCallButton::before {
mask-image: url("$(res)/img/element-icons/call/video-call.svg");
}
.mx_RoomHeader_layoutButton--freedom::before,
.mx_RoomHeader_freedomIcon::before {
mask-image: url("$(res)/img/element-icons/call/freedom.svg");
}
.mx_RoomHeader_layoutButton--spotlight::before,
.mx_RoomHeader_spotlightIcon::before {
mask-image: url("$(res)/img/element-icons/call/spotlight.svg");
}
.mx_RoomHeader_closeButton {
&::before {
mask-image: url("$(res)/img/cancel.svg");
mask-size: 20px;
mask-position: center;
}
&:hover {
background: unset; /* remove background color on hover */
&::before {
background-color: $icon-button-color; /* set the default background color */
}
}
}
.mx_RoomHeader_minimiseButton::before {
mask-image: url("$(res)/img/element-icons/reduce.svg");
}
.mx_RoomHeader_layoutMenu .mx_IconizedContextMenu_icon::before {
content: "";
width: 16px;
height: 16px;
display: block;
mask-position: center;
mask-size: 20px;
mask-repeat: no-repeat;
background: $primary-content;
}
@media only screen and (max-width: 480px) {
.mx_RoomHeader_wrapper {
padding: 0;
margin: 0;
}
.mx_RoomHeader {
overflow: hidden;
}
}

View file

@ -63,7 +63,8 @@ import RoomPreviewCard from "../views/rooms/RoomPreviewCard";
import SearchBar, { SearchScope } from "../views/rooms/SearchBar";
import RoomUpgradeWarningBar from "../views/rooms/RoomUpgradeWarningBar";
import AuxPanel from "../views/rooms/AuxPanel";
import RoomHeader, { ISearchInfo } from "../views/rooms/RoomHeader";
import LegacyRoomHeader, { ISearchInfo } from "../views/rooms/LegacyRoomHeader";
import RoomHeader from "../views/rooms/RoomHeader";
import { IOOBData, IThreepidInvite } from "../../stores/ThreepidInviteStore";
import EffectsOverlay from "../views/elements/EffectsOverlay";
import { containsEmoji } from "../../effects/utils";
@ -295,22 +296,26 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
return (
<div className="mx_RoomView mx_RoomView--local">
<ErrorBoundary>
<RoomHeader
room={context.room}
searchInfo={undefined}
inRoom={true}
onSearchClick={null}
onInviteClick={null}
onForgetClick={null}
e2eStatus={room.encrypted ? E2EStatus.Normal : undefined}
onAppsClick={null}
appsShown={false}
excludedRightPanelPhaseButtons={[]}
showButtons={false}
enableRoomOptionsMenu={false}
viewingCall={false}
activeCall={null}
/>
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
<RoomHeader room={context.room} />
) : (
<LegacyRoomHeader
room={context.room}
searchInfo={undefined}
inRoom={true}
onSearchClick={null}
onInviteClick={null}
onForgetClick={null}
e2eStatus={room.encrypted ? E2EStatus.Normal : undefined}
onAppsClick={null}
appsShown={false}
excludedRightPanelPhaseButtons={[]}
showButtons={false}
enableRoomOptionsMenu={false}
viewingCall={false}
activeCall={null}
/>
)}
<main className="mx_RoomView_body" ref={props.roomView}>
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} />
<div className="mx_RoomView_timeline">
@ -345,22 +350,26 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement
return (
<div className="mx_RoomView mx_RoomView--local">
<ErrorBoundary>
<RoomHeader
room={context.room}
searchInfo={undefined}
inRoom={true}
onSearchClick={null}
onInviteClick={null}
onForgetClick={null}
e2eStatus={props.localRoom.encrypted ? E2EStatus.Normal : undefined}
onAppsClick={null}
appsShown={false}
excludedRightPanelPhaseButtons={[]}
showButtons={false}
enableRoomOptionsMenu={false}
viewingCall={false}
activeCall={null}
/>
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
<RoomHeader room={context.room} />
) : (
<LegacyRoomHeader
room={context.room}
searchInfo={undefined}
inRoom={true}
onSearchClick={null}
onInviteClick={null}
onForgetClick={null}
e2eStatus={props.localRoom.encrypted ? E2EStatus.Normal : undefined}
onAppsClick={null}
appsShown={false}
excludedRightPanelPhaseButtons={[]}
showButtons={false}
enableRoomOptionsMenu={false}
viewingCall={false}
activeCall={null}
/>
)}
<div className="mx_RoomView_body">
<LargeLoader text={text} />
</div>
@ -2460,23 +2469,27 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
<EffectsOverlay roomWidth={this.roomView.current.offsetWidth} />
)}
<ErrorBoundary>
<RoomHeader
room={this.state.room}
searchInfo={this.state.search}
oobData={this.props.oobData}
inRoom={myMembership === "join"}
onSearchClick={onSearchClick}
onInviteClick={onInviteClick}
onForgetClick={showForgetButton ? onForgetClick : null}
e2eStatus={this.state.e2eStatus}
onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null}
appsShown={this.state.showApps}
excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons}
showButtons={!this.viewsLocalRoom}
enableRoomOptionsMenu={!this.viewsLocalRoom}
viewingCall={viewingCall}
activeCall={this.state.activeCall}
/>
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
<RoomHeader room={this.state.room} />
) : (
<LegacyRoomHeader
room={this.state.room}
searchInfo={this.state.search}
oobData={this.props.oobData}
inRoom={myMembership === "join"}
onSearchClick={onSearchClick}
onInviteClick={onInviteClick}
onForgetClick={showForgetButton ? onForgetClick : null}
e2eStatus={this.state.e2eStatus}
onAppsClick={this.state.hasPinnedWidgets ? onAppsClick : null}
appsShown={this.state.showApps}
excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons}
showButtons={!this.viewsLocalRoom}
enableRoomOptionsMenu={!this.viewsLocalRoom}
viewingCall={viewingCall}
activeCall={this.state.activeCall}
/>
)}
<MainSplit
panel={rightPanel}
resizeNotifier={this.props.resizeNotifier}

View file

@ -22,6 +22,7 @@ import { useRoomContext } from "../../contexts/RoomContext";
import ResizeNotifier from "../../utils/ResizeNotifier";
import { E2EStatus } from "../../utils/ShieldUtils";
import ErrorBoundary from "../views/elements/ErrorBoundary";
import LegacyRoomHeader from "../views/rooms/LegacyRoomHeader";
import RoomHeader from "../views/rooms/RoomHeader";
import ScrollPanel from "./ScrollPanel";
import EventTileBubble from "../views/messages/EventTileBubble";
@ -29,6 +30,7 @@ import NewRoomIntro from "../views/rooms/NewRoomIntro";
import { UnwrappedEventTile } from "../views/rooms/EventTile";
import { _t } from "../../languageHandler";
import SdkConfig from "../../SdkConfig";
import SettingsStore from "../../settings/SettingsStore";
interface Props {
roomView: RefObject<HTMLElement>;
@ -48,21 +50,25 @@ export const WaitingForThirdPartyRoomView: React.FC<Props> = ({ roomView, resize
return (
<div className="mx_RoomView mx_RoomView--local">
<ErrorBoundary>
<RoomHeader
room={context.room}
inRoom={true}
onSearchClick={null}
onInviteClick={null}
onForgetClick={null}
e2eStatus={E2EStatus.Normal}
onAppsClick={null}
appsShown={false}
excludedRightPanelPhaseButtons={[]}
showButtons={false}
enableRoomOptionsMenu={false}
viewingCall={false}
activeCall={null}
/>
{SettingsStore.getValue("feature_new_room_decoration_ui") ? (
<RoomHeader room={context.room} />
) : (
<LegacyRoomHeader
room={context.room}
inRoom={true}
onSearchClick={null}
onInviteClick={null}
onForgetClick={null}
e2eStatus={E2EStatus.Normal}
onAppsClick={null}
appsShown={false}
excludedRightPanelPhaseButtons={[]}
showButtons={false}
enableRoomOptionsMenu={false}
viewingCall={false}
activeCall={null}
/>
)}
<main className="mx_RoomView_body" ref={roomView}>
<div className="mx_RoomView_timeline">
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={resizeNotifier}>

View file

@ -59,6 +59,7 @@ interface IProps extends IContextMenuProps {
/**
* Room context menu accessible via the room header.
* @deprecated will be removed as part of `feature_new_room_decoration_ui`
*/
const RoomContextMenu: React.FC<IProps> = ({ room, onFinished, ...props }) => {
const cli = useContext(MatrixClientContext);

View file

@ -45,9 +45,9 @@ export default class HeaderButton extends React.Component<IProps> {
const { isHighlighted, isUnread = false, onClick, name, title, ...props } = this.props;
const classes = classNames({
"mx_RoomHeader_button": true,
"mx_RoomHeader_button--highlight": isHighlighted,
"mx_RoomHeader_button--unread": isUnread,
"mx_LegacyRoomHeader_button": true,
"mx_LegacyRoomHeader_button--highlight": isHighlighted,
"mx_LegacyRoomHeader_button--unread": isUnread,
[`mx_RightPanel_${name}`]: true,
});

View file

@ -64,14 +64,14 @@ const UnreadIndicator: React.FC<IUnreadIndicatorProps> = ({ color }) => {
const classes = classNames({
mx_Indicator: true,
mx_RoomHeader_button_unreadIndicator: true,
mx_LegacyRoomHeader_button_unreadIndicator: true,
mx_Indicator_bold: color === NotificationColor.Bold,
mx_Indicator_gray: color === NotificationColor.Grey,
mx_Indicator_red: color === NotificationColor.Red,
});
return (
<>
<div className="mx_RoomHeader_button_unreadIndicator_bg" />
<div className="mx_LegacyRoomHeader_button_unreadIndicator_bg" />
<div className={classes} />
</>
);
@ -127,7 +127,10 @@ interface IProps {
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
}
export default class RoomHeaderButtons extends HeaderButtons<IProps> {
/**
* @deprecated will be removed as part of 'feature_new_room_decoration_ui'
*/
export default class LegacyRoomHeaderButtons extends HeaderButtons<IProps> {
private static readonly THREAD_PHASES = [RightPanelPhases.ThreadPanel, RightPanelPhases.ThreadView];
private globalNotificationState: SummarizedNotificationState;
@ -257,7 +260,7 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
};
private onThreadsPanelClicked = (ev: ButtonEvent): void => {
if (this.state.phase && RoomHeaderButtons.THREAD_PHASES.includes(this.state.phase)) {
if (this.state.phase && LegacyRoomHeaderButtons.THREAD_PHASES.includes(this.state.phase)) {
RightPanelStore.instance.togglePanel(this.props.room?.roomId ?? null);
} else {
showThreadPanel();
@ -300,7 +303,7 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
data-testid="threadsButton"
title={_t("Threads")}
onClick={this.onThreadsPanelClicked}
isHighlighted={this.isPhase(RoomHeaderButtons.THREAD_PHASES)}
isHighlighted={this.isPhase(LegacyRoomHeaderButtons.THREAD_PHASES)}
isUnread={this.state.threadNotificationColor > NotificationColor.None}
>
<UnreadIndicator color={this.state.threadNotificationColor} />

View file

@ -0,0 +1,825 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useState, useMemo, useCallback } from "react";
import classNames from "classnames";
import { throttle } from "lodash";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { ISearchResults } from "matrix-js-sdk/src/@types/search";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserTab";
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from "../right_panel/LegacyRoomHeaderButtons";
import E2EIcon from "./E2EIcon";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import { E2EStatus } from "../../../utils/ShieldUtils";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import { SearchScope } from "./SearchBar";
import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import RoomContextMenu from "../context_menus/RoomContextMenu";
import { contextMenuBelow } from "./RoomTile";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import RoomContext from "../../../contexts/RoomContext";
import RoomLiveShareWarning from "../beacon/RoomLiveShareWarning";
import { BetaPill } from "../beta/BetaCard";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings";
import SdkConfig from "../../../SdkConfig";
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useWidgets } from "../right_panel/RoomSummaryCard";
import { WidgetType } from "../../../widgets/WidgetType";
import { useCall, useLayout } from "../../../hooks/useCall";
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
import { Call, ElementCall, Layout } from "../../../models/Call";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { GroupCallDuration } from "../voip/CallDuration";
import { Alignment } from "../elements/Tooltip";
import RoomCallBanner from "../beacon/RoomCallBanner";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
class DisabledWithReason {
public constructor(public readonly reason: string) {}
}
interface VoiceCallButtonProps {
room: Room;
busy: boolean;
setBusy: (value: boolean) => void;
behavior: DisabledWithReason | "legacy_or_jitsi";
}
/**
* Button for starting voice calls, supporting only legacy 1:1 calls and Jitsi
* widgets.
*/
const VoiceCallButton: FC<VoiceCallButtonProps> = ({ room, busy, setBusy, behavior }) => {
const { onClick, tooltip, disabled } = useMemo(() => {
if (behavior instanceof DisabledWithReason) {
return {
onClick: () => {},
tooltip: behavior.reason,
disabled: true,
};
} else {
// behavior === "legacy_or_jitsi"
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
setBusy(true);
await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice);
setBusy(false);
},
disabled: false,
};
}
}, [behavior, room, setBusy]);
return (
<AccessibleTooltipButton
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_voiceCallButton"
onClick={onClick}
title={_t("Voice call")}
tooltip={tooltip ?? _t("Voice call")}
alignment={Alignment.Bottom}
disabled={disabled || busy}
/>
);
};
interface VideoCallButtonProps {
room: Room;
busy: boolean;
setBusy: (value: boolean) => void;
behavior: DisabledWithReason | "legacy_or_jitsi" | "element" | "jitsi_or_element";
}
/**
* Button for starting video calls, supporting both legacy 1:1 calls, Jitsi
* widgets, and native group calls. If multiple calling options are available,
* this shows a menu to pick between them.
*/
const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavior }) => {
const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu();
const startLegacyCall = useCallback(async (): Promise<void> => {
setBusy(true);
await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video);
setBusy(false);
}, [setBusy, room]);
const startElementCall = useCallback(() => {
setBusy(true);
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
metricsTrigger: undefined,
});
setBusy(false);
}, [setBusy, room]);
const { onClick, tooltip, disabled } = useMemo(() => {
if (behavior instanceof DisabledWithReason) {
return {
onClick: () => {},
tooltip: behavior.reason,
disabled: true,
};
} else if (behavior === "legacy_or_jitsi") {
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
await startLegacyCall();
},
disabled: false,
};
} else if (behavior === "element") {
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
startElementCall();
},
disabled: false,
};
} else {
// behavior === "jitsi_or_element"
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
openMenu();
},
disabled: false,
};
}
}, [behavior, startLegacyCall, startElementCall, openMenu]);
const onJitsiClick = useCallback(
async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
closeMenu();
await startLegacyCall();
},
[closeMenu, startLegacyCall],
);
const onElementClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
startElementCall();
},
[closeMenu, startElementCall],
);
let menu: JSX.Element | null = null;
if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect();
const brand = SdkConfig.get("element_call").brand;
menu = (
<IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption label={_t("Video call (Jitsi)")} onClick={onJitsiClick} />
<IconizedContextMenuOption
label={_t("Video call (%(brand)s)", { brand })}
onClick={onElementClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
return (
<>
<AccessibleTooltipButton
inputRef={buttonRef}
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_videoCallButton"
onClick={onClick}
title={_t("Video call")}
tooltip={tooltip ?? _t("Video call")}
alignment={Alignment.Bottom}
disabled={disabled || busy}
/>
{menu}
</>
);
};
interface CallButtonsProps {
room: Room;
}
// The header buttons for placing calls have become stupidly complex, so here
// they are as a separate component
const CallButtons: FC<CallButtonsProps> = ({ room }) => {
const [busy, setBusy] = useState(false);
const showButtons = useSettingValue<boolean>("showCallButtonsInComposer");
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]);
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively;
}, []);
const hasLegacyCall = useEventEmitterState(
LegacyCallHandler.instance,
LegacyCallHandlerEvent.CallsChanged,
useCallback(() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, [room]),
);
const widgets = useWidgets(room);
const hasJitsiWidget = useMemo(() => widgets.some((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]);
const hasGroupCall = useCall(room.roomId) !== null;
const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState(
room,
RoomStateEvent.Update,
useCallback(
() => [
getJoinedNonFunctionalMembers(room),
room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client),
],
[room],
),
);
const makeVoiceCallButton = (behavior: VoiceCallButtonProps["behavior"]): JSX.Element => (
<VoiceCallButton room={room} busy={busy} setBusy={setBusy} behavior={behavior} />
);
const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => (
<VideoCallButton room={room} busy={busy} setBusy={setBusy} behavior={behavior} />
);
if (isVideoRoom || !showButtons) {
return null;
} else if (groupCallsEnabled) {
if (useElementCallExclusively) {
if (hasGroupCall) {
return makeVideoCallButton(new DisabledWithReason(_t("Ongoing call")));
} else if (mayCreateElementCalls) {
return makeVideoCallButton("element");
} else {
return makeVideoCallButton(
new DisabledWithReason(_t("You do not have permission to start video calls")),
);
}
} else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call")))}
{makeVideoCallButton(new DisabledWithReason(_t("Ongoing call")))}
</>
);
} else if (functionalMembers.length <= 1) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call")))}
{makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call")))}
</>
);
} else if (functionalMembers.length === 2) {
return (
<>
{makeVoiceCallButton("legacy_or_jitsi")}
{makeVideoCallButton("legacy_or_jitsi")}
</>
);
} else if (mayEditWidgets) {
return (
<>
{makeVoiceCallButton("legacy_or_jitsi")}
{makeVideoCallButton(mayCreateElementCalls ? "jitsi_or_element" : "legacy_or_jitsi")}
</>
);
} else {
const videoCallBehavior = mayCreateElementCalls
? "element"
: new DisabledWithReason(_t("You do not have permission to start video calls"));
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls")))}
{makeVideoCallButton(videoCallBehavior)}
</>
);
}
} else if (hasLegacyCall || hasJitsiWidget) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call")))}
{makeVideoCallButton(new DisabledWithReason(_t("Ongoing call")))}
</>
);
} else if (functionalMembers.length <= 1) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call")))}
{makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call")))}
</>
);
} else if (functionalMembers.length === 2 || mayEditWidgets) {
return (
<>
{makeVoiceCallButton("legacy_or_jitsi")}
{makeVideoCallButton("legacy_or_jitsi")}
</>
);
} else {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls")))}
{makeVideoCallButton(new DisabledWithReason(_t("You do not have permission to start video calls")))}
</>
);
}
};
interface CallLayoutSelectorProps {
call: ElementCall;
}
const CallLayoutSelector: FC<CallLayoutSelectorProps> = ({ call }) => {
const layout = useLayout(call);
const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu();
const onClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
openMenu();
},
[openMenu],
);
const onFreedomClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
call.setLayout(Layout.Tile);
},
[closeMenu, call],
);
const onSpotlightClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
call.setLayout(Layout.Spotlight);
},
[closeMenu, call],
);
let menu: JSX.Element | null = null;
if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect();
menu = (
<IconizedContextMenu
className="mx_LegacyRoomHeader_layoutMenu"
{...aboveLeftOf(buttonRect)}
onFinished={closeMenu}
>
<IconizedContextMenuOptionList>
<IconizedContextMenuRadio
iconClassName="mx_LegacyRoomHeader_freedomIcon"
label={_t("Freedom")}
active={layout === Layout.Tile}
onClick={onFreedomClick}
/>
<IconizedContextMenuRadio
iconClassName="mx_LegacyRoomHeader_spotlightIcon"
label={_t("Spotlight")}
active={layout === Layout.Spotlight}
onClick={onSpotlightClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
return (
<>
<AccessibleTooltipButton
inputRef={buttonRef}
className={classNames("mx_LegacyRoomHeader_button", {
"mx_LegacyRoomHeader_layoutButton--freedom": layout === Layout.Tile,
"mx_LegacyRoomHeader_layoutButton--spotlight": layout === Layout.Spotlight,
})}
onClick={onClick}
title={_t("Change layout")}
alignment={Alignment.Bottom}
key="layout"
/>
{menu}
</>
);
};
export interface ISearchInfo {
searchId: number;
roomId?: string;
term: string;
scope: SearchScope;
promise: Promise<ISearchResults>;
abortController?: AbortController;
inProgress?: boolean;
count?: number;
}
export interface IProps {
room: Room;
oobData?: IOOBData;
inRoom: boolean;
onSearchClick: (() => void) | null;
onInviteClick: (() => void) | null;
onForgetClick: (() => void) | null;
onAppsClick: (() => void) | null;
e2eStatus: E2EStatus;
appsShown: boolean;
searchInfo?: ISearchInfo;
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
showButtons?: boolean;
enableRoomOptionsMenu?: boolean;
viewingCall: boolean;
activeCall: Call | null;
}
interface IState {
contextMenuPosition?: DOMRect;
rightPanelOpen: boolean;
}
export default class RoomHeader extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
inRoom: false,
excludedRightPanelPhaseButtons: [],
showButtons: true,
enableRoomOptionsMenu: true,
};
public static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
private readonly client = this.props.room.client;
public constructor(props: IProps, context: IState) {
super(props, context);
const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room);
notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.state = {
rightPanelOpen: RightPanelStore.instance.isOpen,
};
}
public componentDidMount(): void {
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
}
public componentWillUnmount(): void {
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room);
notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate);
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
}
private onRightPanelStoreUpdate = (): void => {
this.setState({ rightPanelOpen: RightPanelStore.instance.isOpen });
};
private onRoomStateEvents = (event: MatrixEvent): void => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return;
}
// redisplay the room name, topic, etc.
this.rateLimitedUpdate();
};
private onNotificationUpdate = (): void => {
this.forceUpdate();
};
private rateLimitedUpdate = throttle(
() => {
this.forceUpdate();
},
500,
{ leading: true, trailing: true },
);
private onContextMenuOpenClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onContextMenuCloseClick = (): void => {
this.setState({ contextMenuPosition: undefined });
};
private onHideCallClick = (ev: ButtonEvent): void => {
ev.preventDefault();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
view_call: false,
metricsTrigger: undefined,
});
};
private renderButtons(isVideoRoom: boolean): React.ReactNode {
const startButtons: JSX.Element[] = [];
if (!this.props.viewingCall && this.props.inRoom && !this.context.tombstone) {
startButtons.push(<CallButtons key="calls" room={this.props.room} />);
}
if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) {
startButtons.push(<CallLayoutSelector key="layout" call={this.props.activeCall} />);
}
if (!this.props.viewingCall && this.props.onForgetClick) {
startButtons.push(
<AccessibleTooltipButton
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_forgetButton"
onClick={this.props.onForgetClick}
title={_t("Forget room")}
alignment={Alignment.Bottom}
key="forget"
/>,
);
}
if (!this.props.viewingCall && this.props.onAppsClick) {
startButtons.push(
<AccessibleTooltipButton
className={classNames("mx_LegacyRoomHeader_button mx_LegacyRoomHeader_appsButton", {
mx_LegacyRoomHeader_appsButton_highlight: this.props.appsShown,
})}
onClick={this.props.onAppsClick}
title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")}
aria-checked={this.props.appsShown}
alignment={Alignment.Bottom}
key="apps"
/>,
);
}
if (!this.props.viewingCall && this.props.onSearchClick && this.props.inRoom) {
startButtons.push(
<AccessibleTooltipButton
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_searchButton"
onClick={this.props.onSearchClick}
title={_t("Search")}
alignment={Alignment.Bottom}
key="search"
/>,
);
}
if (this.props.onInviteClick && (!this.props.viewingCall || isVideoRoom) && this.props.inRoom) {
startButtons.push(
<AccessibleTooltipButton
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_inviteButton"
onClick={this.props.onInviteClick}
title={_t("Invite")}
alignment={Alignment.Bottom}
key="invite"
/>,
);
}
const endButtons: JSX.Element[] = [];
if (this.props.viewingCall && !isVideoRoom) {
if (this.props.activeCall === null) {
endButtons.push(
<AccessibleButton
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_closeButton"
onClick={this.onHideCallClick}
title={_t("Close call")}
key="close"
/>,
);
} else {
endButtons.push(
<AccessibleTooltipButton
className="mx_LegacyRoomHeader_button mx_LegacyRoomHeader_minimiseButton"
onClick={this.onHideCallClick}
title={_t("View chat timeline")}
alignment={Alignment.Bottom}
key="minimise"
/>,
);
}
}
return (
<>
{startButtons}
<RoomHeaderButtons
room={this.props.room}
excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons}
/>
{endButtons}
</>
);
}
private renderName(oobName: string): JSX.Element {
let contextMenu: JSX.Element | null = null;
if (this.state.contextMenuPosition && this.props.room) {
contextMenu = (
<RoomContextMenu
{...contextMenuBelow(this.state.contextMenuPosition)}
room={this.props.room}
onFinished={this.onContextMenuCloseClick}
/>
);
}
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
let settingsHint = false;
const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
if (members) {
if (members.length === 1 && members[0].userId === this.client.credentials.userId) {
const nameEvent = this.props.room.currentState.getStateEvents("m.room.name", "");
if (!nameEvent || !nameEvent.getContent().name) {
settingsHint = true;
}
}
}
const textClasses = classNames("mx_LegacyRoomHeader_nametext", {
mx_LegacyRoomHeader_settingsHint: settingsHint,
});
const roomName = (
<RoomName room={this.props.room}>
{(name) => {
const roomName = name || oobName;
return (
<div dir="auto" className={textClasses} title={roomName} role="heading" aria-level={1}>
{roomName}
</div>
);
}}
</RoomName>
);
if (this.props.enableRoomOptionsMenu && shouldShowComponent(UIComponent.RoomOptionsMenu)) {
return (
<ContextMenuTooltipButton
className="mx_LegacyRoomHeader_name"
onClick={this.onContextMenuOpenClick}
isExpanded={!!this.state.contextMenuPosition}
title={_t("Room options")}
alignment={Alignment.Bottom}
>
{roomName}
{this.props.room && <div className="mx_LegacyRoomHeader_chevron" />}
{contextMenu}
</ContextMenuTooltipButton>
);
}
return <div className="mx_LegacyRoomHeader_name mx_LegacyRoomHeader_name--textonly">{roomName}</div>;
}
public render(): React.ReactNode {
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
let roomAvatar: JSX.Element | null = null;
if (this.props.room) {
roomAvatar = (
<DecoratedRoomAvatar
room={this.props.room}
avatarSize={24}
oobData={this.props.oobData}
viewAvatarOnClick={true}
/>
);
}
const icon = this.props.viewingCall ? (
<div className="mx_LegacyRoomHeader_icon mx_LegacyRoomHeader_icon_video" />
) : this.props.e2eStatus ? (
<E2EIcon
className="mx_LegacyRoomHeader_icon"
status={this.props.e2eStatus}
tooltipAlignment={Alignment.Bottom}
/>
) : // If we're expecting an E2EE status to come in, but it hasn't
// yet been loaded, insert a blank div to reserve space
this.client.isRoomEncrypted(this.props.room.roomId) && this.client.isCryptoEnabled() ? (
<div className="mx_LegacyRoomHeader_icon" />
) : null;
const buttons = this.props.showButtons ? this.renderButtons(isVideoRoom) : null;
let oobName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) {
oobName = this.props.oobData.name;
}
const name = this.renderName(oobName);
if (this.props.viewingCall && !isVideoRoom) {
return (
<header className="mx_LegacyRoomHeader light-panel">
<div
className="mx_LegacyRoomHeader_wrapper"
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_LegacyRoomHeader_avatar">{roomAvatar}</div>
{icon}
{name}
{this.props.activeCall instanceof ElementCall && (
<GroupCallDuration groupCall={this.props.activeCall.groupCall} />
)}
{/* Empty topic element to fill out space */}
<div className="mx_LegacyRoomHeader_topic" />
{buttons}
</div>
</header>
);
}
let searchStatus: JSX.Element | null = null;
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
if (typeof this.props.searchInfo?.count === "number") {
searchStatus = (
<div className="mx_LegacyRoomHeader_searchStatus">
&nbsp;
{_t("(~%(count)s results)", { count: this.props.searchInfo.count })}
</div>
);
}
const topicElement = <RoomTopic room={this.props.room} className="mx_LegacyRoomHeader_topic" />;
const viewLabs = (): void =>
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
const betaPill = isVideoRoom ? (
<BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
) : null;
return (
<header className="mx_LegacyRoomHeader light-panel">
<div
className="mx_LegacyRoomHeader_wrapper"
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_LegacyRoomHeader_avatar">{roomAvatar}</div>
{icon}
{name}
{searchStatus}
{topicElement}
{betaPill}
{buttons}
</div>
{!isVideoRoom && <RoomCallBanner roomId={this.props.room.roomId} />}
<RoomLiveShareWarning roomId={this.props.room.roomId} />
</header>
);
}
}

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019, 2021 The Matrix.org Foundation C.I.C.
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,805 +14,41 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { FC, useState, useMemo, useCallback } from "react";
import classNames from "classnames";
import { throttle } from "lodash";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { ISearchResults } from "matrix-js-sdk/src/@types/search";
import React from "react";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { Room } from "matrix-js-sdk/src/models/room";
import { _t } from "../../../languageHandler";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserTab";
import SettingsStore from "../../../settings/SettingsStore";
import RoomHeaderButtons from "../right_panel/RoomHeaderButtons";
import E2EIcon from "./E2EIcon";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import RoomTopic from "../elements/RoomTopic";
import RoomName from "../elements/RoomName";
import { E2EStatus } from "../../../utils/ShieldUtils";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import { SearchScope } from "./SearchBar";
import { aboveLeftOf, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import RoomContextMenu from "../context_menus/RoomContextMenu";
import { contextMenuBelow } from "./RoomTile";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import RoomContext from "../../../contexts/RoomContext";
import RoomLiveShareWarning from "../beacon/RoomLiveShareWarning";
import { BetaPill } from "../beta/BetaCard";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../LegacyCallHandler";
import { useFeatureEnabled, useSettingValue } from "../../../hooks/useSettings";
import SdkConfig from "../../../SdkConfig";
import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
import { useWidgets } from "../right_panel/RoomSummaryCard";
import { WidgetType } from "../../../widgets/WidgetType";
import { useCall, useLayout } from "../../../hooks/useCall";
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
import { Call, ElementCall, Layout } from "../../../models/Call";
import IconizedContextMenu, {
IconizedContextMenuOption,
IconizedContextMenuOptionList,
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { GroupCallDuration } from "../voip/CallDuration";
import { Alignment } from "../elements/Tooltip";
import RoomCallBanner from "../beacon/RoomCallBanner";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
class DisabledWithReason {
public constructor(public readonly reason: string) {}
}
interface VoiceCallButtonProps {
room: Room;
busy: boolean;
setBusy: (value: boolean) => void;
behavior: DisabledWithReason | "legacy_or_jitsi";
}
/**
* Button for starting voice calls, supporting only legacy 1:1 calls and Jitsi
* widgets.
*/
const VoiceCallButton: FC<VoiceCallButtonProps> = ({ room, busy, setBusy, behavior }) => {
const { onClick, tooltip, disabled } = useMemo(() => {
if (behavior instanceof DisabledWithReason) {
return {
onClick: () => {},
tooltip: behavior.reason,
disabled: true,
};
} else {
// behavior === "legacy_or_jitsi"
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
setBusy(true);
await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice);
setBusy(false);
},
disabled: false,
};
}
}, [behavior, room, setBusy]);
return (
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
onClick={onClick}
title={_t("Voice call")}
tooltip={tooltip ?? _t("Voice call")}
alignment={Alignment.Bottom}
disabled={disabled || busy}
/>
);
};
interface VideoCallButtonProps {
room: Room;
busy: boolean;
setBusy: (value: boolean) => void;
behavior: DisabledWithReason | "legacy_or_jitsi" | "element" | "jitsi_or_element";
}
/**
* Button for starting video calls, supporting both legacy 1:1 calls, Jitsi
* widgets, and native group calls. If multiple calling options are available,
* this shows a menu to pick between them.
*/
const VideoCallButton: FC<VideoCallButtonProps> = ({ room, busy, setBusy, behavior }) => {
const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu();
const startLegacyCall = useCallback(async (): Promise<void> => {
setBusy(true);
await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video);
setBusy(false);
}, [setBusy, room]);
const startElementCall = useCallback(() => {
setBusy(true);
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
metricsTrigger: undefined,
});
setBusy(false);
}, [setBusy, room]);
const { onClick, tooltip, disabled } = useMemo(() => {
if (behavior instanceof DisabledWithReason) {
return {
onClick: () => {},
tooltip: behavior.reason,
disabled: true,
};
} else if (behavior === "legacy_or_jitsi") {
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
await startLegacyCall();
},
disabled: false,
};
} else if (behavior === "element") {
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
startElementCall();
},
disabled: false,
};
} else {
// behavior === "jitsi_or_element"
return {
onClick: async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
openMenu();
},
disabled: false,
};
}
}, [behavior, startLegacyCall, startElementCall, openMenu]);
const onJitsiClick = useCallback(
async (ev: ButtonEvent): Promise<void> => {
ev.preventDefault();
closeMenu();
await startLegacyCall();
},
[closeMenu, startLegacyCall],
);
const onElementClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
startElementCall();
},
[closeMenu, startElementCall],
);
let menu: JSX.Element | null = null;
if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect();
const brand = SdkConfig.get("element_call").brand;
menu = (
<IconizedContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu}>
<IconizedContextMenuOptionList>
<IconizedContextMenuOption label={_t("Video call (Jitsi)")} onClick={onJitsiClick} />
<IconizedContextMenuOption
label={_t("Video call (%(brand)s)", { brand })}
onClick={onElementClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
export default function RoomHeader({ room, oobData }: { room?: Room; oobData?: IOOBData }): JSX.Element {
let oobName = _t("Join Room");
if (oobData && oobData.name) {
oobName = oobData.name;
}
return (
<>
<AccessibleTooltipButton
inputRef={buttonRef}
className="mx_RoomHeader_button mx_RoomHeader_videoCallButton"
onClick={onClick}
title={_t("Video call")}
tooltip={tooltip ?? _t("Video call")}
alignment={Alignment.Bottom}
disabled={disabled || busy}
/>
{menu}
</>
<header className="mx_LegacyRoomHeader light-panel">
<div className="mx_LegacyRoomHeader_wrapper">
{room && (
<RoomName room={room}>
{(name) => {
const roomName = name || oobName;
return (
<div
className="mx_LegacyRoomHeader_name"
dir="auto"
title={roomName}
role="heading"
aria-level={1}
>
{roomName}
</div>
);
}}
</RoomName>
)}
</div>
</header>
);
};
interface CallButtonsProps {
room: Room;
}
// The header buttons for placing calls have become stupidly complex, so here
// they are as a separate component
const CallButtons: FC<CallButtonsProps> = ({ room }) => {
const [busy, setBusy] = useState(false);
const showButtons = useSettingValue<boolean>("showCallButtonsInComposer");
const groupCallsEnabled = useFeatureEnabled("feature_group_calls");
const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms");
const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]);
const useElementCallExclusively = useMemo(() => {
return SdkConfig.get("element_call").use_exclusively;
}, []);
const hasLegacyCall = useEventEmitterState(
LegacyCallHandler.instance,
LegacyCallHandlerEvent.CallsChanged,
useCallback(() => LegacyCallHandler.instance.getCallForRoom(room.roomId) !== null, [room]),
);
const widgets = useWidgets(room);
const hasJitsiWidget = useMemo(() => widgets.some((widget) => WidgetType.JITSI.matches(widget.type)), [widgets]);
const hasGroupCall = useCall(room.roomId) !== null;
const [functionalMembers, mayEditWidgets, mayCreateElementCalls] = useTypedEventEmitterState(
room,
RoomStateEvent.Update,
useCallback(
() => [
getJoinedNonFunctionalMembers(room),
room.currentState.mayClientSendStateEvent("im.vector.modular.widgets", room.client),
room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, room.client),
],
[room],
),
);
const makeVoiceCallButton = (behavior: VoiceCallButtonProps["behavior"]): JSX.Element => (
<VoiceCallButton room={room} busy={busy} setBusy={setBusy} behavior={behavior} />
);
const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => (
<VideoCallButton room={room} busy={busy} setBusy={setBusy} behavior={behavior} />
);
if (isVideoRoom || !showButtons) {
return null;
} else if (groupCallsEnabled) {
if (useElementCallExclusively) {
if (hasGroupCall) {
return makeVideoCallButton(new DisabledWithReason(_t("Ongoing call")));
} else if (mayCreateElementCalls) {
return makeVideoCallButton("element");
} else {
return makeVideoCallButton(
new DisabledWithReason(_t("You do not have permission to start video calls")),
);
}
} else if (hasLegacyCall || hasJitsiWidget || hasGroupCall) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call")))}
{makeVideoCallButton(new DisabledWithReason(_t("Ongoing call")))}
</>
);
} else if (functionalMembers.length <= 1) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call")))}
{makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call")))}
</>
);
} else if (functionalMembers.length === 2) {
return (
<>
{makeVoiceCallButton("legacy_or_jitsi")}
{makeVideoCallButton("legacy_or_jitsi")}
</>
);
} else if (mayEditWidgets) {
return (
<>
{makeVoiceCallButton("legacy_or_jitsi")}
{makeVideoCallButton(mayCreateElementCalls ? "jitsi_or_element" : "legacy_or_jitsi")}
</>
);
} else {
const videoCallBehavior = mayCreateElementCalls
? "element"
: new DisabledWithReason(_t("You do not have permission to start video calls"));
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls")))}
{makeVideoCallButton(videoCallBehavior)}
</>
);
}
} else if (hasLegacyCall || hasJitsiWidget) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("Ongoing call")))}
{makeVideoCallButton(new DisabledWithReason(_t("Ongoing call")))}
</>
);
} else if (functionalMembers.length <= 1) {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("There's no one here to call")))}
{makeVideoCallButton(new DisabledWithReason(_t("There's no one here to call")))}
</>
);
} else if (functionalMembers.length === 2 || mayEditWidgets) {
return (
<>
{makeVoiceCallButton("legacy_or_jitsi")}
{makeVideoCallButton("legacy_or_jitsi")}
</>
);
} else {
return (
<>
{makeVoiceCallButton(new DisabledWithReason(_t("You do not have permission to start voice calls")))}
{makeVideoCallButton(new DisabledWithReason(_t("You do not have permission to start video calls")))}
</>
);
}
};
interface CallLayoutSelectorProps {
call: ElementCall;
}
const CallLayoutSelector: FC<CallLayoutSelectorProps> = ({ call }) => {
const layout = useLayout(call);
const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu();
const onClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
openMenu();
},
[openMenu],
);
const onFreedomClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
call.setLayout(Layout.Tile);
},
[closeMenu, call],
);
const onSpotlightClick = useCallback(
(ev: ButtonEvent) => {
ev.preventDefault();
closeMenu();
call.setLayout(Layout.Spotlight);
},
[closeMenu, call],
);
let menu: JSX.Element | null = null;
if (menuOpen) {
const buttonRect = buttonRef.current!.getBoundingClientRect();
menu = (
<IconizedContextMenu
className="mx_RoomHeader_layoutMenu"
{...aboveLeftOf(buttonRect)}
onFinished={closeMenu}
>
<IconizedContextMenuOptionList>
<IconizedContextMenuRadio
iconClassName="mx_RoomHeader_freedomIcon"
label={_t("Freedom")}
active={layout === Layout.Tile}
onClick={onFreedomClick}
/>
<IconizedContextMenuRadio
iconClassName="mx_RoomHeader_spotlightIcon"
label={_t("Spotlight")}
active={layout === Layout.Spotlight}
onClick={onSpotlightClick}
/>
</IconizedContextMenuOptionList>
</IconizedContextMenu>
);
}
return (
<>
<AccessibleTooltipButton
inputRef={buttonRef}
className={classNames("mx_RoomHeader_button", {
"mx_RoomHeader_layoutButton--freedom": layout === Layout.Tile,
"mx_RoomHeader_layoutButton--spotlight": layout === Layout.Spotlight,
})}
onClick={onClick}
title={_t("Change layout")}
alignment={Alignment.Bottom}
key="layout"
/>
{menu}
</>
);
};
export interface ISearchInfo {
searchId: number;
roomId?: string;
term: string;
scope: SearchScope;
promise: Promise<ISearchResults>;
abortController?: AbortController;
inProgress?: boolean;
count?: number;
}
export interface IProps {
room: Room;
oobData?: IOOBData;
inRoom: boolean;
onSearchClick: (() => void) | null;
onInviteClick: (() => void) | null;
onForgetClick: (() => void) | null;
onAppsClick: (() => void) | null;
e2eStatus: E2EStatus;
appsShown: boolean;
searchInfo?: ISearchInfo;
excludedRightPanelPhaseButtons?: Array<RightPanelPhases>;
showButtons?: boolean;
enableRoomOptionsMenu?: boolean;
viewingCall: boolean;
activeCall: Call | null;
}
interface IState {
contextMenuPosition?: DOMRect;
rightPanelOpen: boolean;
}
export default class RoomHeader extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
inRoom: false,
excludedRightPanelPhaseButtons: [],
showButtons: true,
enableRoomOptionsMenu: true,
};
public static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
private readonly client = this.props.room.client;
public constructor(props: IProps, context: IState) {
super(props, context);
const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room);
notiStore.on(NotificationStateEvents.Update, this.onNotificationUpdate);
this.state = {
rightPanelOpen: RightPanelStore.instance.isOpen,
};
}
public componentDidMount(): void {
this.client.on(RoomStateEvent.Events, this.onRoomStateEvents);
RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate);
}
public componentWillUnmount(): void {
this.client.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
const notiStore = RoomNotificationStateStore.instance.getRoomState(this.props.room);
notiStore.removeListener(NotificationStateEvents.Update, this.onNotificationUpdate);
RightPanelStore.instance.off(UPDATE_EVENT, this.onRightPanelStoreUpdate);
}
private onRightPanelStoreUpdate = (): void => {
this.setState({ rightPanelOpen: RightPanelStore.instance.isOpen });
};
private onRoomStateEvents = (event: MatrixEvent): void => {
if (!this.props.room || event.getRoomId() !== this.props.room.roomId) {
return;
}
// redisplay the room name, topic, etc.
this.rateLimitedUpdate();
};
private onNotificationUpdate = (): void => {
this.forceUpdate();
};
private rateLimitedUpdate = throttle(
() => {
this.forceUpdate();
},
500,
{ leading: true, trailing: true },
);
private onContextMenuOpenClick = (ev: ButtonEvent): void => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLButtonElement;
this.setState({ contextMenuPosition: target.getBoundingClientRect() });
};
private onContextMenuCloseClick = (): void => {
this.setState({ contextMenuPosition: undefined });
};
private onHideCallClick = (ev: ButtonEvent): void => {
ev.preventDefault();
defaultDispatcher.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.props.room.roomId,
view_call: false,
metricsTrigger: undefined,
});
};
private renderButtons(isVideoRoom: boolean): React.ReactNode {
const startButtons: JSX.Element[] = [];
if (!this.props.viewingCall && this.props.inRoom && !this.context.tombstone) {
startButtons.push(<CallButtons key="calls" room={this.props.room} />);
}
if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) {
startButtons.push(<CallLayoutSelector key="layout" call={this.props.activeCall} />);
}
if (!this.props.viewingCall && this.props.onForgetClick) {
startButtons.push(
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_forgetButton"
onClick={this.props.onForgetClick}
title={_t("Forget room")}
alignment={Alignment.Bottom}
key="forget"
/>,
);
}
if (!this.props.viewingCall && this.props.onAppsClick) {
startButtons.push(
<AccessibleTooltipButton
className={classNames("mx_RoomHeader_button mx_RoomHeader_appsButton", {
mx_RoomHeader_appsButton_highlight: this.props.appsShown,
})}
onClick={this.props.onAppsClick}
title={this.props.appsShown ? _t("Hide Widgets") : _t("Show Widgets")}
aria-checked={this.props.appsShown}
alignment={Alignment.Bottom}
key="apps"
/>,
);
}
if (!this.props.viewingCall && this.props.onSearchClick && this.props.inRoom) {
startButtons.push(
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_searchButton"
onClick={this.props.onSearchClick}
title={_t("Search")}
alignment={Alignment.Bottom}
key="search"
/>,
);
}
if (this.props.onInviteClick && (!this.props.viewingCall || isVideoRoom) && this.props.inRoom) {
startButtons.push(
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_inviteButton"
onClick={this.props.onInviteClick}
title={_t("Invite")}
alignment={Alignment.Bottom}
key="invite"
/>,
);
}
const endButtons: JSX.Element[] = [];
if (this.props.viewingCall && !isVideoRoom) {
if (this.props.activeCall === null) {
endButtons.push(
<AccessibleButton
className="mx_RoomHeader_button mx_RoomHeader_closeButton"
onClick={this.onHideCallClick}
title={_t("Close call")}
key="close"
/>,
);
} else {
endButtons.push(
<AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_minimiseButton"
onClick={this.onHideCallClick}
title={_t("View chat timeline")}
alignment={Alignment.Bottom}
key="minimise"
/>,
);
}
}
return (
<>
{startButtons}
<RoomHeaderButtons
room={this.props.room}
excludedRightPanelPhaseButtons={this.props.excludedRightPanelPhaseButtons}
/>
{endButtons}
</>
);
}
private renderName(oobName: string): JSX.Element {
let contextMenu: JSX.Element | null = null;
if (this.state.contextMenuPosition && this.props.room) {
contextMenu = (
<RoomContextMenu
{...contextMenuBelow(this.state.contextMenuPosition)}
room={this.props.room}
onFinished={this.onContextMenuCloseClick}
/>
);
}
// XXX: this is a bit inefficient - we could just compare room.name for 'Empty room'...
let settingsHint = false;
const members = this.props.room ? this.props.room.getJoinedMembers() : undefined;
if (members) {
if (members.length === 1 && members[0].userId === this.client.credentials.userId) {
const nameEvent = this.props.room.currentState.getStateEvents("m.room.name", "");
if (!nameEvent || !nameEvent.getContent().name) {
settingsHint = true;
}
}
}
const textClasses = classNames("mx_RoomHeader_nametext", { mx_RoomHeader_settingsHint: settingsHint });
const roomName = (
<RoomName room={this.props.room}>
{(name) => {
const roomName = name || oobName;
return (
<div dir="auto" className={textClasses} title={roomName} role="heading" aria-level={1}>
{roomName}
</div>
);
}}
</RoomName>
);
if (this.props.enableRoomOptionsMenu && shouldShowComponent(UIComponent.RoomOptionsMenu)) {
return (
<ContextMenuTooltipButton
className="mx_RoomHeader_name"
onClick={this.onContextMenuOpenClick}
isExpanded={!!this.state.contextMenuPosition}
title={_t("Room options")}
alignment={Alignment.Bottom}
>
{roomName}
{this.props.room && <div className="mx_RoomHeader_chevron" />}
{contextMenu}
</ContextMenuTooltipButton>
);
}
return <div className="mx_RoomHeader_name mx_RoomHeader_name--textonly">{roomName}</div>;
}
public render(): React.ReactNode {
const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room);
let roomAvatar: JSX.Element | null = null;
if (this.props.room) {
roomAvatar = (
<DecoratedRoomAvatar
room={this.props.room}
avatarSize={24}
oobData={this.props.oobData}
viewAvatarOnClick={true}
/>
);
}
const icon = this.props.viewingCall ? (
<div className="mx_RoomHeader_icon mx_RoomHeader_icon_video" />
) : this.props.e2eStatus ? (
<E2EIcon className="mx_RoomHeader_icon" status={this.props.e2eStatus} tooltipAlignment={Alignment.Bottom} />
) : // If we're expecting an E2EE status to come in, but it hasn't
// yet been loaded, insert a blank div to reserve space
this.client.isRoomEncrypted(this.props.room.roomId) && this.client.isCryptoEnabled() ? (
<div className="mx_RoomHeader_icon" />
) : null;
const buttons = this.props.showButtons ? this.renderButtons(isVideoRoom) : null;
let oobName = _t("Join Room");
if (this.props.oobData && this.props.oobData.name) {
oobName = this.props.oobData.name;
}
const name = this.renderName(oobName);
if (this.props.viewingCall && !isVideoRoom) {
return (
<header className="mx_RoomHeader light-panel">
<div
className="mx_RoomHeader_wrapper"
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_RoomHeader_avatar">{roomAvatar}</div>
{icon}
{name}
{this.props.activeCall instanceof ElementCall && (
<GroupCallDuration groupCall={this.props.activeCall.groupCall} />
)}
{/* Empty topic element to fill out space */}
<div className="mx_RoomHeader_topic" />
{buttons}
</div>
</header>
);
}
let searchStatus: JSX.Element | null = null;
// don't display the search count until the search completes and
// gives us a valid (possibly zero) searchCount.
if (typeof this.props.searchInfo?.count === "number") {
searchStatus = (
<div className="mx_RoomHeader_searchStatus">
&nbsp;
{_t("(~%(count)s results)", { count: this.props.searchInfo.count })}
</div>
);
}
const topicElement = <RoomTopic room={this.props.room} className="mx_RoomHeader_topic" />;
const viewLabs = (): void =>
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
const betaPill = isVideoRoom ? (
<BetaPill onClick={viewLabs} tooltipTitle={_t("Video rooms are a beta feature")} />
) : null;
return (
<header className="mx_RoomHeader light-panel">
<div
className="mx_RoomHeader_wrapper"
aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined}
>
<div className="mx_RoomHeader_avatar">{roomAvatar}</div>
{icon}
{name}
{searchStatus}
{topicElement}
{betaPill}
{buttons}
</div>
{!isVideoRoom && <RoomCallBanner roomId={this.props.room.roomId} />}
<RoomLiveShareWarning roomId={this.props.room.roomId} />
</header>
);
}
}

View file

@ -1009,6 +1009,7 @@
"Hide notification dot (only display counters badges)": "Hide notification dot (only display counters badges)",
"Enable intentional mentions": "Enable intentional mentions",
"Enable ask to join": "Enable ask to join",
"Under active development, new room header & details interface": "Under active development, new room header & details interface",
"Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout",
"Show a placeholder for removed messages": "Show a placeholder for removed messages",
"Show join/leave messages (invites/removes/bans unaffected)": "Show join/leave messages (invites/removes/bans unaffected)",
@ -1941,6 +1942,26 @@
"Encrypted messages before this point are unavailable.": "Encrypted messages before this point are unavailable.",
"You can't see earlier messages": "You can't see earlier messages",
"Scroll to most recent messages": "Scroll to most recent messages",
"Video call (Jitsi)": "Video call (Jitsi)",
"Video call (%(brand)s)": "Video call (%(brand)s)",
"Ongoing call": "Ongoing call",
"You do not have permission to start video calls": "You do not have permission to start video calls",
"There's no one here to call": "There's no one here to call",
"You do not have permission to start voice calls": "You do not have permission to start voice calls",
"Freedom": "Freedom",
"Spotlight": "Spotlight",
"Change layout": "Change layout",
"Forget room": "Forget room",
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search",
"Close call": "Close call",
"View chat timeline": "View chat timeline",
"Room options": "Room options",
"Join Room": "Join Room",
"(~%(count)s results)|other": "(~%(count)s results)",
"(~%(count)s results)|one": "(~%(count)s result)",
"Video rooms are a beta feature": "Video rooms are a beta feature",
"Show %(count)s other previews|other": "Show %(count)s other previews",
"Show %(count)s other previews|one": "Show %(count)s other preview",
"Close preview": "Close preview",
@ -2018,26 +2039,6 @@
"Room %(name)s": "Room %(name)s",
"Recently visited rooms": "Recently visited rooms",
"No recently visited rooms": "No recently visited rooms",
"Video call (Jitsi)": "Video call (Jitsi)",
"Video call (%(brand)s)": "Video call (%(brand)s)",
"Ongoing call": "Ongoing call",
"You do not have permission to start video calls": "You do not have permission to start video calls",
"There's no one here to call": "There's no one here to call",
"You do not have permission to start voice calls": "You do not have permission to start voice calls",
"Freedom": "Freedom",
"Spotlight": "Spotlight",
"Change layout": "Change layout",
"Forget room": "Forget room",
"Hide Widgets": "Hide Widgets",
"Show Widgets": "Show Widgets",
"Search": "Search",
"Close call": "Close call",
"View chat timeline": "View chat timeline",
"Room options": "Room options",
"Join Room": "Join Room",
"(~%(count)s results)|other": "(~%(count)s results)",
"(~%(count)s results)|one": "(~%(count)s result)",
"Video rooms are a beta feature": "Video rooms are a beta feature",
"Video room": "Video room",
"Public space": "Public space",
"Public room": "Public room",
@ -2242,11 +2243,11 @@
"Yours, or the other users' session": "Yours, or the other users' session",
"Error starting verification": "Error starting verification",
"We were unable to start a chat with the other user.": "We were unable to start a chat with the other user.",
"Nothing pinned, yet": "Nothing pinned, yet",
"If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
"Pinned messages": "Pinned messages",
"Chat": "Chat",
"Room info": "Room info",
"Nothing pinned, yet": "Nothing pinned, yet",
"If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
"You can only pin up to %(count)s widgets|other": "You can only pin up to %(count)s widgets",
"Maximise": "Maximise",
"Unpin this widget to view it in this panel": "Unpin this widget to view it in this panel",

View file

@ -566,6 +566,14 @@ export const SETTINGS: { [setting: string]: ISetting } = {
labsGroup: LabGroup.Rooms,
supportedLevels: LEVELS_FEATURE,
},
"feature_new_room_decoration_ui": {
isFeature: true,
labsGroup: LabGroup.Rooms,
displayName: _td("Under active development, new room header & details interface"),
supportedLevels: LEVELS_FEATURE,
default: false,
controller: new ReloadOnChangeController(),
},
"useCompactLayout": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
displayName: _td("Use a more compact 'Modern' layout"),

View file

@ -181,23 +181,23 @@ export default class HTMLExporter extends Exporter {
<div class="mx_MatrixChat_wrapper" aria-hidden="false">
<div class="mx_MatrixChat">
<main class="mx_RoomView">
<div class="mx_RoomHeader light-panel">
<div class="mx_RoomHeader_wrapper" aria-owns="mx_RightPanel">
<div class="mx_RoomHeader_avatar">
<div class="mx_LegacyRoomHeader light-panel">
<div class="mx_LegacyRoomHeader_wrapper" aria-owns="mx_RightPanel">
<div class="mx_LegacyRoomHeader_avatar">
<div class="mx_DecoratedRoomAvatar">
${roomAvatar}
</div>
</div>
<div class="mx_RoomHeader_name">
<div class="mx_LegacyRoomHeader_name">
<div
dir="auto"
class="mx_RoomHeader_nametext"
class="mx_LegacyRoomHeader_nametext"
title="${safeRoomName}"
>
${safeRoomName}
</div>
</div>
<div class="mx_RoomHeader_topic" dir="auto"> ${safeTopic} </div>
<div class="mx_LegacyRoomHeader_topic" dir="auto"> ${safeTopic} </div>
</div>
</div>
${previousMessagesLink}

View file

@ -6,13 +6,13 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
class="mx_RoomView mx_RoomView--local"
>
<header
class="mx_RoomHeader light-panel"
class="mx_LegacyRoomHeader light-panel"
>
<div
class="mx_RoomHeader_wrapper"
class="mx_LegacyRoomHeader_wrapper"
>
<div
class="mx_RoomHeader_avatar"
class="mx_LegacyRoomHeader_avatar"
>
<div
class="mx_DecoratedRoomAvatar"
@ -41,11 +41,11 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
</div>
</div>
<div
class="mx_RoomHeader_name mx_RoomHeader_name--textonly"
class="mx_LegacyRoomHeader_name mx_LegacyRoomHeader_name--textonly"
>
<div
aria-level="1"
class="mx_RoomHeader_nametext"
class="mx_LegacyRoomHeader_nametext"
dir="auto"
role="heading"
title="@user:example.com"
@ -55,7 +55,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
</div>
<div
aria-describedby="mx_TooltipTarget_abdefghi"
class="mx_RoomHeader_topic mx_RoomTopic"
class="mx_LegacyRoomHeader_topic mx_RoomTopic"
dir="auto"
tabindex="0"
>
@ -99,13 +99,13 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
class="mx_RoomView mx_RoomView--local"
>
<header
class="mx_RoomHeader light-panel"
class="mx_LegacyRoomHeader light-panel"
>
<div
class="mx_RoomHeader_wrapper"
class="mx_LegacyRoomHeader_wrapper"
>
<div
class="mx_RoomHeader_avatar"
class="mx_LegacyRoomHeader_avatar"
>
<div
class="mx_DecoratedRoomAvatar"
@ -134,11 +134,11 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
</div>
<div
class="mx_RoomHeader_name mx_RoomHeader_name--textonly"
class="mx_LegacyRoomHeader_name mx_LegacyRoomHeader_name--textonly"
>
<div
aria-level="1"
class="mx_RoomHeader_nametext"
class="mx_LegacyRoomHeader_nametext"
dir="auto"
role="heading"
title="@user:example.com"
@ -148,7 +148,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
<div
aria-describedby="mx_TooltipTarget_abdefghi"
class="mx_RoomHeader_topic mx_RoomTopic"
class="mx_LegacyRoomHeader_topic mx_RoomTopic"
dir="auto"
tabindex="0"
>
@ -289,13 +289,13 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
class="mx_RoomView mx_RoomView--local"
>
<header
class="mx_RoomHeader light-panel"
class="mx_LegacyRoomHeader light-panel"
>
<div
class="mx_RoomHeader_wrapper"
class="mx_LegacyRoomHeader_wrapper"
>
<div
class="mx_RoomHeader_avatar"
class="mx_LegacyRoomHeader_avatar"
>
<div
class="mx_DecoratedRoomAvatar"
@ -324,11 +324,11 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
</div>
</div>
<div
class="mx_RoomHeader_name mx_RoomHeader_name--textonly"
class="mx_LegacyRoomHeader_name mx_LegacyRoomHeader_name--textonly"
>
<div
aria-level="1"
class="mx_RoomHeader_nametext"
class="mx_LegacyRoomHeader_nametext"
dir="auto"
role="heading"
title="@user:example.com"
@ -338,7 +338,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
</div>
<div
aria-describedby="mx_TooltipTarget_abdefghi"
class="mx_RoomHeader_topic mx_RoomTopic"
class="mx_LegacyRoomHeader_topic mx_RoomTopic"
dir="auto"
tabindex="0"
>
@ -554,13 +554,13 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
class="mx_RoomView mx_RoomView--local"
>
<header
class="mx_RoomHeader light-panel"
class="mx_LegacyRoomHeader light-panel"
>
<div
class="mx_RoomHeader_wrapper"
class="mx_LegacyRoomHeader_wrapper"
>
<div
class="mx_RoomHeader_avatar"
class="mx_LegacyRoomHeader_avatar"
>
<div
class="mx_DecoratedRoomAvatar"
@ -590,14 +590,14 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
</div>
<div
aria-label="This room is end-to-end encrypted"
class="mx_E2EIcon mx_E2EIcon_normal mx_RoomHeader_icon"
class="mx_E2EIcon mx_E2EIcon_normal mx_LegacyRoomHeader_icon"
/>
<div
class="mx_RoomHeader_name mx_RoomHeader_name--textonly"
class="mx_LegacyRoomHeader_name mx_LegacyRoomHeader_name--textonly"
>
<div
aria-level="1"
class="mx_RoomHeader_nametext"
class="mx_LegacyRoomHeader_nametext"
dir="auto"
role="heading"
title="@user:example.com"
@ -607,7 +607,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
</div>
<div
aria-describedby="mx_TooltipTarget_abdefghi"
class="mx_RoomHeader_topic mx_RoomTopic"
class="mx_LegacyRoomHeader_topic mx_RoomTopic"
dir="auto"
tabindex="0"
>

View file

@ -21,12 +21,12 @@ import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
import React from "react";
import RoomHeaderButtons from "../../../../src/components/views/right_panel/RoomHeaderButtons";
import LegacyRoomHeaderButtons from "../../../../src/components/views/right_panel/LegacyRoomHeaderButtons";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { mkEvent, stubClient } from "../../../test-utils";
import { mkThread } from "../../../test-utils/threads";
describe("RoomHeaderButtons-test.tsx", function () {
describe("LegacyRoomHeaderButtons-test.tsx", function () {
const ROOM_ID = "!roomId:example.org";
let room: Room;
let client: MatrixClient;
@ -43,7 +43,7 @@ describe("RoomHeaderButtons-test.tsx", function () {
});
function getComponent(room?: Room) {
return render(<RoomHeaderButtons room={room} excludedRightPanelPhaseButtons={[]} />);
return render(<LegacyRoomHeaderButtons room={room} excludedRightPanelPhaseButtons={[]} />);
}
function getThreadButton(container: HTMLElement) {
@ -75,10 +75,10 @@ describe("RoomHeaderButtons-test.tsx", function () {
it("thread notification does change the thread button", () => {
const { container } = getComponent(room);
expect(getThreadButton(container)!.className.includes("mx_RoomHeader_button--unread")).toBeFalsy();
expect(getThreadButton(container)!.className.includes("mx_LegacyRoomHeader_button--unread")).toBeFalsy();
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1);
expect(getThreadButton(container)!.className.includes("mx_RoomHeader_button--unread")).toBeTruthy();
expect(getThreadButton(container)!.className.includes("mx_LegacyRoomHeader_button--unread")).toBeTruthy();
expect(isIndicatorOfType(container, "gray")).toBe(true);
room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);

View file

@ -1,18 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RoomHeaderButtons-test.tsx should render 1`] = `
exports[`LegacyRoomHeaderButtons-test.tsx should render 1`] = `
<DocumentFragment>
<div
aria-current="false"
aria-label="Chat"
class="mx_AccessibleButton mx_RoomHeader_button mx_RightPanel_timelineCardButton"
class="mx_AccessibleButton mx_LegacyRoomHeader_button mx_RightPanel_timelineCardButton"
role="button"
tabindex="0"
/>
<div
aria-current="false"
aria-label="Threads"
class="mx_AccessibleButton mx_RoomHeader_button mx_RightPanel_threadsButton"
class="mx_AccessibleButton mx_LegacyRoomHeader_button mx_RightPanel_threadsButton"
data-testid="threadsButton"
role="button"
tabindex="0"
@ -20,14 +20,14 @@ exports[`RoomHeaderButtons-test.tsx should render 1`] = `
<div
aria-current="false"
aria-label="Notifications"
class="mx_AccessibleButton mx_RoomHeader_button mx_RightPanel_notifsButton"
class="mx_AccessibleButton mx_LegacyRoomHeader_button mx_RightPanel_notifsButton"
role="button"
tabindex="0"
/>
<div
aria-current="false"
aria-label="Room info"
class="mx_AccessibleButton mx_RoomHeader_button mx_RightPanel_roomSummaryButton"
class="mx_AccessibleButton mx_LegacyRoomHeader_button mx_RightPanel_roomSummaryButton"
role="button"
tabindex="0"
/>

View file

@ -0,0 +1,884 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, screen, act, fireEvent, waitFor, getByRole, RenderResult } from "@testing-library/react";
import { mocked, Mocked } from "jest-mock";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import EventEmitter from "events";
import { ISearchResults } from "matrix-js-sdk/src/@types/search";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import {
stubClient,
mkRoomMember,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
mockPlatformPeg,
} from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import RoomHeader, { IProps as RoomHeaderProps } from "../../../../src/components/views/rooms/LegacyRoomHeader";
import { SearchScope } from "../../../../src/components/views/rooms/SearchBar";
import { E2EStatus } from "../../../../src/utils/ShieldUtils";
import { mkEvent } from "../../../test-utils";
import { IRoomState } from "../../../../src/components/structures/RoomView";
import RoomContext from "../../../../src/contexts/RoomContext";
import SdkConfig from "../../../../src/SdkConfig";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { ElementCall, JitsiCall } from "../../../../src/models/Call";
import { CallStore } from "../../../../src/stores/CallStore";
import LegacyCallHandler from "../../../../src/LegacyCallHandler";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import WidgetStore from "../../../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import WidgetUtils from "../../../../src/utils/WidgetUtils";
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler";
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../src/settings/UIFeature";
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("LegacyRoomHeader", () => {
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let carol: RoomMember;
beforeEach(async () => {
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
stubClient();
client = mocked(MatrixClientPeg.safeGet());
client.getUserId.mockReturnValue("@alice:example.org");
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
room.currentState.setStateEvents([mkCreationEvent(room.roomId, "@alice:example.org")]);
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
if (roomId !== room.roomId) throw new Error("Unknown room");
const event = mkEvent({
event: true,
type: eventType,
room: roomId,
user: alice.userId,
skey: stateKey,
content,
});
room.addLiveEvents([event]);
return { event_id: event.getId()! };
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org");
carol = mkRoomMember(room.roomId, "@carol:example.org");
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
await Promise.all(
[CallStore.instance, WidgetStore.instance].map((store) => setupAsyncStoreWithClient(store, client)),
);
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
[MediaDeviceKindEnum.AudioInput]: [],
[MediaDeviceKindEnum.VideoInput]: [],
[MediaDeviceKindEnum.AudioOutput]: [],
});
DMRoomMap.makeShared(client);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(carol.userId);
});
afterEach(async () => {
await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.restoreAllMocks();
SdkConfig.reset();
});
const mockRoomType = (type: string) => {
jest.spyOn(room, "getType").mockReturnValue(type);
};
const mockRoomMembers = (members: RoomMember[]) => {
jest.spyOn(room, "getJoinedMembers").mockReturnValue(members);
jest.spyOn(room, "getMember").mockImplementation(
(userId) => members.find((member) => member.userId === userId) ?? null,
);
};
const mockEnabledSettings = (settings: string[]) => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settings.includes(settingName));
};
const mockEventPowerLevels = (events: { [eventType: string]: number }) => {
room.currentState.setStateEvents([
mkEvent({
event: true,
type: EventType.RoomPowerLevels,
room: room.roomId,
user: alice.userId,
skey: "",
content: { events, state_default: 0 },
}),
]);
};
const mockLegacyCall = () => {
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue({} as unknown as MatrixCall);
};
const withCall = async (fn: (call: ElementCall) => void | Promise<void>): Promise<void> => {
await ElementCall.create(room);
const call = CallStore.instance.getCall(room.roomId);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
const widget = new Widget(call.widget);
const eventEmitter = new EventEmitter();
const messaging = {
on: eventEmitter.on.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter),
once: eventEmitter.once.bind(eventEmitter),
emit: eventEmitter.emit.bind(eventEmitter),
stop: jest.fn(),
transport: {
send: jest.fn(),
reply: jest.fn(),
},
} as unknown as Mocked<ClientWidgetApi>;
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
await fn(call);
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
};
const renderHeader = (props: Partial<RoomHeaderProps> = {}, roomContext: Partial<IRoomState> = {}) => {
render(
<RoomContext.Provider value={{ ...roomContext, room } as IRoomState}>
<RoomHeader
room={room}
inRoom={true}
onSearchClick={() => {}}
onInviteClick={null}
onForgetClick={() => {}}
onAppsClick={() => {}}
e2eStatus={E2EStatus.Normal}
appsShown={true}
searchInfo={{
searchId: Math.random(),
promise: new Promise<ISearchResults>(() => {}),
term: "",
scope: SearchScope.Room,
count: 0,
}}
viewingCall={false}
activeCall={null}
{...props}
/>
</RoomContext.Provider>,
);
};
it("hides call buttons in video rooms", () => {
mockRoomType(RoomType.UnstableCall);
mockEnabledSettings(["showCallButtonsInComposer", "feature_video_rooms", "feature_element_call_video_rooms"]);
renderHeader();
expect(screen.queryByRole("button", { name: /call/i })).toBeNull();
});
it("hides call buttons if showCallButtonsInComposer is disabled", () => {
mockEnabledSettings([]);
renderHeader();
expect(screen.queryByRole("button", { name: /call/i })).toBeNull();
});
it(
"hides the voice call button and disables the video call button if configured to use Element Call exclusively " +
"and there's an ongoing call",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
SdkConfig.put({
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
});
await ElementCall.create(room);
renderHeader();
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
},
);
it(
"hides the voice call button and starts an Element call when the video call button is pressed if configured to " +
"use Element Call exclusively",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
SdkConfig.put({
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
});
renderHeader();
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}),
);
defaultDispatcher.unregister(dispatcherRef);
},
);
it(
"hides the voice call button and disables the video call button if configured to use Element Call exclusively " +
"and the user lacks permission",
() => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
SdkConfig.put({
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
});
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 });
renderHeader();
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
},
);
it("disables call buttons in the new group call experience if there's an ongoing Element call", async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
await ElementCall.create(room);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons in the new group call experience if there's an ongoing legacy 1:1 call", () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockLegacyCall();
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons in the new group call experience if there's an existing Jitsi widget", async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
await JitsiCall.create(room);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons in the new group call experience if there's no other members", () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it(
"starts a legacy 1:1 call when call buttons are pressed in the new group call experience if there's 1 other " +
"member",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob]);
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
},
);
it(
"creates a Jitsi widget when call buttons are pressed in the new group call experience if the user lacks " +
"permission to start Element calls",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob, carol]);
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 });
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
},
);
it(
"creates a Jitsi widget when the voice call button is pressed and shows a menu when the video call button is " +
"pressed in the new group call experience",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob, carol]);
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
// First try creating a Jitsi widget from the menu
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /jitsi/i }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
// Then try starting an Element call from the menu
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /element/i }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}),
);
defaultDispatcher.unregister(dispatcherRef);
},
);
it(
"disables the voice call button and starts an Element call when the video call button is pressed in the new " +
"group call experience if the user lacks permission to edit widgets",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob, carol]);
mockEventPowerLevels({ "im.vector.modular.widgets": 100 });
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}),
);
defaultDispatcher.unregister(dispatcherRef);
},
);
it("disables call buttons in the new group call experience if the user lacks permission", () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob, carol]);
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100, "im.vector.modular.widgets": 100 });
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons if there's an ongoing legacy 1:1 call", () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
mockLegacyCall();
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons if there's an existing Jitsi widget", async () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
await JitsiCall.create(room);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons if there's no other members", () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("starts a legacy 1:1 call when call buttons are pressed if there's 1 other member", async () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
mockRoomMembers([alice, bob]);
mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); // Just to verify that it doesn't try to use Jitsi
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
});
it("creates a Jitsi widget when call buttons are pressed", async () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
mockRoomMembers([alice, bob, carol]);
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
});
it("disables call buttons if the user lacks permission", () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
mockRoomMembers([alice, bob, carol]);
mockEventPowerLevels({ "im.vector.modular.widgets": 100 });
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("shows a close button when viewing a call lobby that returns to the timeline when pressed", async () => {
mockEnabledSettings(["feature_group_calls"]);
renderHeader({ viewingCall: true });
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: /close/i }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: false,
}),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("shows a reduce button when viewing a call that returns to the timeline when pressed", async () => {
mockEnabledSettings(["feature_group_calls"]);
await withCall(async (call) => {
renderHeader({ viewingCall: true, activeCall: call });
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: /timeline/i }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: false,
}),
);
defaultDispatcher.unregister(dispatcherRef);
});
});
it("shows a layout button when viewing a call that shows a menu when pressed", async () => {
mockEnabledSettings(["feature_group_calls"]);
await withCall(async (call) => {
await call.connect();
const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!;
renderHeader({ viewingCall: true, activeCall: call });
// Should start with Freedom selected
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
screen.getByRole("menuitemradio", { name: "Freedom", checked: true });
// Clicking Spotlight should tell the widget to switch and close the menu
fireEvent.click(screen.getByRole("menuitemradio", { name: "Spotlight" }));
expect(mocked(messaging.transport).send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
expect(screen.queryByRole("menu")).toBeNull();
// When the widget responds and the user reopens the menu, they should see Spotlight selected
act(() => {
messaging.emit(
`action:${ElementWidgetActions.SpotlightLayout}`,
new CustomEvent("widgetapirequest", { detail: { data: {} } }),
);
});
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
screen.getByRole("menuitemradio", { name: "Spotlight", checked: true });
// Now try switching back to Freedom
fireEvent.click(screen.getByRole("menuitemradio", { name: "Freedom" }));
expect(mocked(messaging.transport).send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
expect(screen.queryByRole("menu")).toBeNull();
// When the widget responds and the user reopens the menu, they should see Freedom selected
act(() => {
messaging.emit(
`action:${ElementWidgetActions.TileLayout}`,
new CustomEvent("widgetapirequest", { detail: { data: {} } }),
);
});
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
screen.getByRole("menuitemradio", { name: "Freedom", checked: true });
});
});
it("shows an invite button in video rooms", () => {
mockEnabledSettings(["feature_video_rooms", "feature_element_call_video_rooms"]);
mockRoomType(RoomType.UnstableCall);
const onInviteClick = jest.fn();
renderHeader({ onInviteClick, viewingCall: true });
fireEvent.click(screen.getByRole("button", { name: /invite/i }));
expect(onInviteClick).toHaveBeenCalled();
});
it("hides the invite button in non-video rooms when viewing a call", () => {
renderHeader({ onInviteClick: () => {}, viewingCall: true });
expect(screen.queryByRole("button", { name: /invite/i })).toBeNull();
});
it("shows the room avatar in a room with only ourselves", () => {
// When we render a non-DM room with 1 person in it
const room = createRoom({ name: "X Room", isDm: false, userIds: [] });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("X");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("shows the room avatar in a room with 2 people", () => {
// When we render a non-DM room with 2 people in it
const room = createRoom({ name: "Y Room", isDm: false, userIds: ["other"] });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("Y");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("shows the room avatar in a room with >2 people", () => {
// When we render a non-DM room with 3 people in it
const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("Z");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("shows the room avatar in a DM with only ourselves", () => {
// When we render a non-DM room with 1 person in it
const room = createRoom({ name: "Z Room", isDm: true, userIds: [] });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("Z");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("shows the user avatar in a DM with 2 people", () => {
// Note: this is the interesting case - this is the ONLY
// time we should use the user's avatar.
// When we render a DM room with only 2 people in it
const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] });
const rendered = mountHeader(room);
// Then we use the other user's avatar as our room's image avatar
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "http://this.is.a.url/example.org/other");
// And there is no initial avatar
expect(rendered.container.querySelector(".mx_BaseAvatar_initial")).toBeFalsy();
});
it("shows the room avatar in a DM with >2 people", () => {
// When we render a DM room with 3 people in it
const room = createRoom({
name: "Z Room",
isDm: true,
userIds: ["other1", "other2"],
});
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("Z");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("renders call buttons normally", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] });
const wrapper = mountHeader(room);
expect(wrapper.container.querySelector('[aria-label="Voice call"]')).toBeDefined();
expect(wrapper.container.querySelector('[aria-label="Video call"]')).toBeDefined();
});
it("hides call buttons when the room is tombstoned", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(
room,
{},
{
tombstone: mkEvent({
event: true,
type: "m.room.tombstone",
room: room.roomId,
user: "@user1:server",
skey: "",
content: {},
ts: Date.now(),
}),
},
);
expect(wrapper.container.querySelector('[aria-label="Voice call"]')).toBeFalsy();
expect(wrapper.container.querySelector('[aria-label="Video call"]')).toBeFalsy();
});
it("should render buttons if not passing showButtons (default true)", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(room);
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_button")).toBeDefined();
});
it("should not render buttons if passing showButtons = false", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(room, { showButtons: false });
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_button")).toBeFalsy();
});
it("should render the room options context menu if not passing enableRoomOptionsMenu (default true) and UIComponent customisations room options enabled", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(room);
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_name.mx_AccessibleButton")).toBeDefined();
});
it.each([
[false, true],
[true, false],
])(
"should not render the room options context menu if passing enableRoomOptionsMenu = %s and UIComponent customisations room options enable = %s",
(enableRoomOptionsMenu, showRoomOptionsMenu) => {
mocked(shouldShowComponent).mockReturnValue(showRoomOptionsMenu);
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(room, { enableRoomOptionsMenu });
expect(wrapper.container.querySelector(".mx_LegacyRoomHeader_name.mx_AccessibleButton")).toBeFalsy();
},
);
});
interface IRoomCreationInfo {
name: string;
isDm: boolean;
userIds: string[];
}
function createRoom(info: IRoomCreationInfo) {
stubClient();
const client: MatrixClient = MatrixClientPeg.safeGet();
const roomId = "!1234567890:domain";
const userId = client.getUserId()!;
if (info.isDm) {
client.getAccountData = (eventType) => {
expect(eventType).toEqual("m.direct");
return mkDirectEvent(roomId, userId, info.userIds);
};
}
DMRoomMap.makeShared(client).start();
const room = new Room(roomId, client, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const otherJoinEvents: MatrixEvent[] = [];
for (const otherUserId of info.userIds) {
otherJoinEvents.push(mkJoinEvent(roomId, otherUserId));
}
room.currentState.setStateEvents([
mkCreationEvent(roomId, userId),
mkNameEvent(roomId, userId, info.name),
mkJoinEvent(roomId, userId),
...otherJoinEvents,
]);
room.recalculate();
return room;
}
function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial<IRoomState>): RenderResult {
const props: RoomHeaderProps = {
room,
inRoom: true,
onSearchClick: () => {},
onInviteClick: null,
onForgetClick: () => {},
onAppsClick: () => {},
e2eStatus: E2EStatus.Normal,
appsShown: true,
searchInfo: {
searchId: Math.random(),
promise: new Promise<ISearchResults>(() => {}),
term: "",
scope: SearchScope.Room,
count: 0,
},
viewingCall: false,
activeCall: null,
...propsOverride,
};
return render(
<RoomContext.Provider value={{ ...roomContext, room } as IRoomState}>
<RoomHeader {...props} />
</RoomContext.Provider>,
);
}
function mkCreationEvent(roomId: string, userId: string): MatrixEvent {
return mkEvent({
event: true,
type: "m.room.create",
room: roomId,
user: userId,
content: {
creator: userId,
room_version: "5",
predecessor: {
room_id: "!prevroom",
event_id: "$someevent",
},
},
});
}
function mkNameEvent(roomId: string, userId: string, name: string): MatrixEvent {
return mkEvent({
event: true,
type: "m.room.name",
room: roomId,
user: userId,
content: { name },
});
}
function mkJoinEvent(roomId: string, userId: string) {
const ret = mkEvent({
event: true,
type: "m.room.member",
room: roomId,
user: userId,
content: {
membership: "join",
avatar_url: "mxc://example.org/" + userId,
},
});
ret.event.state_key = userId;
return ret;
}
function mkDirectEvent(roomId: string, userId: string, otherUsers: string[]): MatrixEvent {
const content: Record<string, string[]> = {};
for (const otherUserId of otherUsers) {
content[otherUserId] = [roomId];
}
return mkEvent({
event: true,
type: "m.direct",
room: roomId,
user: userId,
content,
});
}

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,870 +15,32 @@ limitations under the License.
*/
import React from "react";
import { render, screen, act, fireEvent, waitFor, getByRole, RenderResult } from "@testing-library/react";
import { mocked, Mocked } from "jest-mock";
import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import { Mocked } from "jest-mock";
import { render } from "@testing-library/react";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { ClientWidgetApi, Widget } from "matrix-widget-api";
import EventEmitter from "events";
import { ISearchResults } from "matrix-js-sdk/src/@types/search";
import { stubClient } from "../../../test-utils";
import RoomHeader from "../../../../src/components/views/rooms/RoomHeader";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { MatrixEvent } from "matrix-js-sdk/src/models/event";
import type { RoomMember } from "matrix-js-sdk/src/models/room-member";
import type { MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import {
stubClient,
mkRoomMember,
setupAsyncStoreWithClient,
resetAsyncStoreWithClient,
mockPlatformPeg,
} from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import RoomHeader, { IProps as RoomHeaderProps } from "../../../../src/components/views/rooms/RoomHeader";
import { SearchScope } from "../../../../src/components/views/rooms/SearchBar";
import { E2EStatus } from "../../../../src/utils/ShieldUtils";
import { mkEvent } from "../../../test-utils";
import { IRoomState } from "../../../../src/components/structures/RoomView";
import RoomContext from "../../../../src/contexts/RoomContext";
import SdkConfig from "../../../../src/SdkConfig";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { ElementCall, JitsiCall } from "../../../../src/models/Call";
import { CallStore } from "../../../../src/stores/CallStore";
import LegacyCallHandler from "../../../../src/LegacyCallHandler";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import WidgetStore from "../../../../src/stores/WidgetStore";
import { WidgetMessagingStore } from "../../../../src/stores/widgets/WidgetMessagingStore";
import WidgetUtils from "../../../../src/utils/WidgetUtils";
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../../src/MediaDeviceHandler";
import { shouldShowComponent } from "../../../../src/customisations/helpers/UIComponents";
import { UIComponent } from "../../../../src/settings/UIFeature";
jest.mock("../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(),
}));
describe("RoomHeader", () => {
describe("Roomeader", () => {
let client: Mocked<MatrixClient>;
let room: Room;
let alice: RoomMember;
let bob: RoomMember;
let carol: RoomMember;
const ROOM_ID = "!1:example.org";
beforeEach(async () => {
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
stubClient();
client = mocked(MatrixClientPeg.safeGet());
client.getUserId.mockReturnValue("@alice:example.org");
room = new Room("!1:example.org", client, "@alice:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
room.currentState.setStateEvents([mkCreationEvent(room.roomId, "@alice:example.org")]);
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
client.sendStateEvent.mockImplementation(async (roomId, eventType, content, stateKey = "") => {
if (roomId !== room.roomId) throw new Error("Unknown room");
const event = mkEvent({
event: true,
type: eventType,
room: roomId,
user: alice.userId,
skey: stateKey,
content,
});
room.addLiveEvents([event]);
return { event_id: event.getId()! };
});
alice = mkRoomMember(room.roomId, "@alice:example.org");
bob = mkRoomMember(room.roomId, "@bob:example.org");
carol = mkRoomMember(room.roomId, "@carol:example.org");
client.getRoom.mockImplementation((roomId) => (roomId === room.roomId ? room : null));
client.getRooms.mockReturnValue([room]);
client.reEmitter.reEmit(room, [RoomStateEvent.Events]);
await Promise.all(
[CallStore.instance, WidgetStore.instance].map((store) => setupAsyncStoreWithClient(store, client)),
);
jest.spyOn(MediaDeviceHandler, "getDevices").mockResolvedValue({
[MediaDeviceKindEnum.AudioInput]: [],
[MediaDeviceKindEnum.VideoInput]: [],
[MediaDeviceKindEnum.AudioOutput]: [],
});
DMRoomMap.makeShared(client);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(carol.userId);
room = new Room(ROOM_ID, client, "@alice:example.org");
});
afterEach(async () => {
await Promise.all([CallStore.instance, WidgetStore.instance].map(resetAsyncStoreWithClient));
client.reEmitter.stopReEmitting(room, [RoomStateEvent.Events]);
jest.restoreAllMocks();
SdkConfig.reset();
it("renders with no props", () => {
const { asFragment } = render(<RoomHeader />);
expect(asFragment()).toMatchSnapshot();
});
const mockRoomType = (type: string) => {
jest.spyOn(room, "getType").mockReturnValue(type);
};
const mockRoomMembers = (members: RoomMember[]) => {
jest.spyOn(room, "getJoinedMembers").mockReturnValue(members);
jest.spyOn(room, "getMember").mockImplementation(
(userId) => members.find((member) => member.userId === userId) ?? null,
);
};
const mockEnabledSettings = (settings: string[]) => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName) => settings.includes(settingName));
};
const mockEventPowerLevels = (events: { [eventType: string]: number }) => {
room.currentState.setStateEvents([
mkEvent({
event: true,
type: EventType.RoomPowerLevels,
room: room.roomId,
user: alice.userId,
skey: "",
content: { events, state_default: 0 },
}),
]);
};
const mockLegacyCall = () => {
jest.spyOn(LegacyCallHandler.instance, "getCallForRoom").mockReturnValue({} as unknown as MatrixCall);
};
const withCall = async (fn: (call: ElementCall) => void | Promise<void>): Promise<void> => {
await ElementCall.create(room);
const call = CallStore.instance.getCall(room.roomId);
if (!(call instanceof ElementCall)) throw new Error("Failed to create call");
const widget = new Widget(call.widget);
const eventEmitter = new EventEmitter();
const messaging = {
on: eventEmitter.on.bind(eventEmitter),
off: eventEmitter.off.bind(eventEmitter),
once: eventEmitter.once.bind(eventEmitter),
emit: eventEmitter.emit.bind(eventEmitter),
stop: jest.fn(),
transport: {
send: jest.fn(),
reply: jest.fn(),
},
} as unknown as Mocked<ClientWidgetApi>;
WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging);
await fn(call);
call.destroy();
WidgetMessagingStore.instance.stopMessaging(widget, call.roomId);
};
const renderHeader = (props: Partial<RoomHeaderProps> = {}, roomContext: Partial<IRoomState> = {}) => {
render(
<RoomContext.Provider value={{ ...roomContext, room } as IRoomState}>
<RoomHeader
room={room}
inRoom={true}
onSearchClick={() => {}}
onInviteClick={null}
onForgetClick={() => {}}
onAppsClick={() => {}}
e2eStatus={E2EStatus.Normal}
appsShown={true}
searchInfo={{
searchId: Math.random(),
promise: new Promise<ISearchResults>(() => {}),
term: "",
scope: SearchScope.Room,
count: 0,
}}
viewingCall={false}
activeCall={null}
{...props}
/>
</RoomContext.Provider>,
);
};
it("hides call buttons in video rooms", () => {
mockRoomType(RoomType.UnstableCall);
mockEnabledSettings(["showCallButtonsInComposer", "feature_video_rooms", "feature_element_call_video_rooms"]);
renderHeader();
expect(screen.queryByRole("button", { name: /call/i })).toBeNull();
it("renders the room header", () => {
const { container } = render(<RoomHeader room={room} />);
expect(container).toHaveTextContent(ROOM_ID);
});
it("hides call buttons if showCallButtonsInComposer is disabled", () => {
mockEnabledSettings([]);
renderHeader();
expect(screen.queryByRole("button", { name: /call/i })).toBeNull();
});
it(
"hides the voice call button and disables the video call button if configured to use Element Call exclusively " +
"and there's an ongoing call",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
SdkConfig.put({
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
});
await ElementCall.create(room);
renderHeader();
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
},
);
it(
"hides the voice call button and starts an Element call when the video call button is pressed if configured to " +
"use Element Call exclusively",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
SdkConfig.put({
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
});
renderHeader();
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}),
);
defaultDispatcher.unregister(dispatcherRef);
},
);
it(
"hides the voice call button and disables the video call button if configured to use Element Call exclusively " +
"and the user lacks permission",
() => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
SdkConfig.put({
element_call: { url: "https://call.element.io", use_exclusively: true, brand: "Element Call" },
});
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 });
renderHeader();
expect(screen.queryByRole("button", { name: "Voice call" })).toBeNull();
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
},
);
it("disables call buttons in the new group call experience if there's an ongoing Element call", async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
await ElementCall.create(room);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons in the new group call experience if there's an ongoing legacy 1:1 call", () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockLegacyCall();
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons in the new group call experience if there's an existing Jitsi widget", async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
await JitsiCall.create(room);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons in the new group call experience if there's no other members", () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it(
"starts a legacy 1:1 call when call buttons are pressed in the new group call experience if there's 1 other " +
"member",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob]);
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
},
);
it(
"creates a Jitsi widget when call buttons are pressed in the new group call experience if the user lacks " +
"permission to start Element calls",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob, carol]);
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100 });
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
},
);
it(
"creates a Jitsi widget when the voice call button is pressed and shows a menu when the video call button is " +
"pressed in the new group call experience",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob, carol]);
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
// First try creating a Jitsi widget from the menu
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /jitsi/i }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
// Then try starting an Element call from the menu
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
fireEvent.click(getByRole(screen.getByRole("menu"), "menuitem", { name: /element/i }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}),
);
defaultDispatcher.unregister(dispatcherRef);
},
);
it(
"disables the voice call button and starts an Element call when the video call button is pressed in the new " +
"group call experience if the user lacks permission to edit widgets",
async () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob, carol]);
mockEventPowerLevels({ "im.vector.modular.widgets": 100 });
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: true,
}),
);
defaultDispatcher.unregister(dispatcherRef);
},
);
it("disables call buttons in the new group call experience if the user lacks permission", () => {
mockEnabledSettings(["showCallButtonsInComposer", "feature_group_calls"]);
mockRoomMembers([alice, bob, carol]);
mockEventPowerLevels({ [ElementCall.CALL_EVENT_TYPE.name]: 100, "im.vector.modular.widgets": 100 });
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons if there's an ongoing legacy 1:1 call", () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
mockLegacyCall();
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons if there's an existing Jitsi widget", async () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
await JitsiCall.create(room);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("disables call buttons if there's no other members", () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("starts a legacy 1:1 call when call buttons are pressed if there's 1 other member", async () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
mockRoomMembers([alice, bob]);
mockEventPowerLevels({ "im.vector.modular.widgets": 100 }); // Just to verify that it doesn't try to use Jitsi
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
});
it("creates a Jitsi widget when call buttons are pressed", async () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
mockRoomMembers([alice, bob, carol]);
renderHeader();
const placeCallSpy = jest.spyOn(LegacyCallHandler.instance, "placeCall").mockResolvedValue(undefined);
fireEvent.click(screen.getByRole("button", { name: "Voice call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Voice);
placeCallSpy.mockClear();
fireEvent.click(screen.getByRole("button", { name: "Video call" }));
await act(() => Promise.resolve()); // Allow effects to settle
expect(placeCallSpy).toHaveBeenCalledWith(room.roomId, CallType.Video);
});
it("disables call buttons if the user lacks permission", () => {
mockEnabledSettings(["showCallButtonsInComposer"]);
mockRoomMembers([alice, bob, carol]);
mockEventPowerLevels({ "im.vector.modular.widgets": 100 });
renderHeader();
expect(screen.getByRole("button", { name: "Voice call" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("button", { name: "Video call" })).toHaveAttribute("aria-disabled", "true");
});
it("shows a close button when viewing a call lobby that returns to the timeline when pressed", async () => {
mockEnabledSettings(["feature_group_calls"]);
renderHeader({ viewingCall: true });
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: /close/i }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: false,
}),
);
defaultDispatcher.unregister(dispatcherRef);
});
it("shows a reduce button when viewing a call that returns to the timeline when pressed", async () => {
mockEnabledSettings(["feature_group_calls"]);
await withCall(async (call) => {
renderHeader({ viewingCall: true, activeCall: call });
const dispatcherSpy = jest.fn();
const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
fireEvent.click(screen.getByRole("button", { name: /timeline/i }));
await waitFor(() =>
expect(dispatcherSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
room_id: room.roomId,
view_call: false,
}),
);
defaultDispatcher.unregister(dispatcherRef);
});
});
it("shows a layout button when viewing a call that shows a menu when pressed", async () => {
mockEnabledSettings(["feature_group_calls"]);
await withCall(async (call) => {
await call.connect();
const messaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(call.widget))!;
renderHeader({ viewingCall: true, activeCall: call });
// Should start with Freedom selected
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
screen.getByRole("menuitemradio", { name: "Freedom", checked: true });
// Clicking Spotlight should tell the widget to switch and close the menu
fireEvent.click(screen.getByRole("menuitemradio", { name: "Spotlight" }));
expect(mocked(messaging.transport).send).toHaveBeenCalledWith(ElementWidgetActions.SpotlightLayout, {});
expect(screen.queryByRole("menu")).toBeNull();
// When the widget responds and the user reopens the menu, they should see Spotlight selected
act(() => {
messaging.emit(
`action:${ElementWidgetActions.SpotlightLayout}`,
new CustomEvent("widgetapirequest", { detail: { data: {} } }),
);
});
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
screen.getByRole("menuitemradio", { name: "Spotlight", checked: true });
// Now try switching back to Freedom
fireEvent.click(screen.getByRole("menuitemradio", { name: "Freedom" }));
expect(mocked(messaging.transport).send).toHaveBeenCalledWith(ElementWidgetActions.TileLayout, {});
expect(screen.queryByRole("menu")).toBeNull();
// When the widget responds and the user reopens the menu, they should see Freedom selected
act(() => {
messaging.emit(
`action:${ElementWidgetActions.TileLayout}`,
new CustomEvent("widgetapirequest", { detail: { data: {} } }),
);
});
fireEvent.click(screen.getByRole("button", { name: /layout/i }));
screen.getByRole("menuitemradio", { name: "Freedom", checked: true });
});
});
it("shows an invite button in video rooms", () => {
mockEnabledSettings(["feature_video_rooms", "feature_element_call_video_rooms"]);
mockRoomType(RoomType.UnstableCall);
const onInviteClick = jest.fn();
renderHeader({ onInviteClick, viewingCall: true });
fireEvent.click(screen.getByRole("button", { name: /invite/i }));
expect(onInviteClick).toHaveBeenCalled();
});
it("hides the invite button in non-video rooms when viewing a call", () => {
renderHeader({ onInviteClick: () => {}, viewingCall: true });
expect(screen.queryByRole("button", { name: /invite/i })).toBeNull();
});
it("shows the room avatar in a room with only ourselves", () => {
// When we render a non-DM room with 1 person in it
const room = createRoom({ name: "X Room", isDm: false, userIds: [] });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("X");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("shows the room avatar in a room with 2 people", () => {
// When we render a non-DM room with 2 people in it
const room = createRoom({ name: "Y Room", isDm: false, userIds: ["other"] });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("Y");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("shows the room avatar in a room with >2 people", () => {
// When we render a non-DM room with 3 people in it
const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("Z");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("shows the room avatar in a DM with only ourselves", () => {
// When we render a non-DM room with 1 person in it
const room = createRoom({ name: "Z Room", isDm: true, userIds: [] });
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("Z");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("shows the user avatar in a DM with 2 people", () => {
// Note: this is the interesting case - this is the ONLY
// time we should use the user's avatar.
// When we render a DM room with only 2 people in it
const room = createRoom({ name: "Y Room", isDm: true, userIds: ["other"] });
const rendered = mountHeader(room);
// Then we use the other user's avatar as our room's image avatar
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "http://this.is.a.url/example.org/other");
// And there is no initial avatar
expect(rendered.container.querySelector(".mx_BaseAvatar_initial")).toBeFalsy();
});
it("shows the room avatar in a DM with >2 people", () => {
// When we render a DM room with 3 people in it
const room = createRoom({
name: "Z Room",
isDm: true,
userIds: ["other1", "other2"],
});
const rendered = mountHeader(room);
// Then the room's avatar is the initial of its name
const initial = rendered.container.querySelector(".mx_BaseAvatar_initial");
expect(initial).toHaveTextContent("Z");
// And there is no image avatar (because it's not set on this room)
const image = rendered.container.querySelector(".mx_BaseAvatar_image");
expect(image).toHaveAttribute("src", "data:image/png;base64,00");
});
it("renders call buttons normally", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: ["other"] });
const wrapper = mountHeader(room);
expect(wrapper.container.querySelector('[aria-label="Voice call"]')).toBeDefined();
expect(wrapper.container.querySelector('[aria-label="Video call"]')).toBeDefined();
});
it("hides call buttons when the room is tombstoned", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(
room,
{},
{
tombstone: mkEvent({
event: true,
type: "m.room.tombstone",
room: room.roomId,
user: "@user1:server",
skey: "",
content: {},
ts: Date.now(),
}),
},
);
expect(wrapper.container.querySelector('[aria-label="Voice call"]')).toBeFalsy();
expect(wrapper.container.querySelector('[aria-label="Video call"]')).toBeFalsy();
});
it("should render buttons if not passing showButtons (default true)", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(room);
expect(wrapper.container.querySelector(".mx_RoomHeader_button")).toBeDefined();
});
it("should not render buttons if passing showButtons = false", () => {
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(room, { showButtons: false });
expect(wrapper.container.querySelector(".mx_RoomHeader_button")).toBeFalsy();
});
it("should render the room options context menu if not passing enableRoomOptionsMenu (default true) and UIComponent customisations room options enabled", () => {
mocked(shouldShowComponent).mockReturnValue(true);
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(room);
expect(shouldShowComponent).toHaveBeenCalledWith(UIComponent.RoomOptionsMenu);
expect(wrapper.container.querySelector(".mx_RoomHeader_name.mx_AccessibleButton")).toBeDefined();
});
it.each([
[false, true],
[true, false],
])(
"should not render the room options context menu if passing enableRoomOptionsMenu = %s and UIComponent customisations room options enable = %s",
(enableRoomOptionsMenu, showRoomOptionsMenu) => {
mocked(shouldShowComponent).mockReturnValue(showRoomOptionsMenu);
const room = createRoom({ name: "Room", isDm: false, userIds: [] });
const wrapper = mountHeader(room, { enableRoomOptionsMenu });
expect(wrapper.container.querySelector(".mx_RoomHeader_name.mx_AccessibleButton")).toBeFalsy();
},
);
});
interface IRoomCreationInfo {
name: string;
isDm: boolean;
userIds: string[];
}
function createRoom(info: IRoomCreationInfo) {
stubClient();
const client: MatrixClient = MatrixClientPeg.safeGet();
const roomId = "!1234567890:domain";
const userId = client.getUserId()!;
if (info.isDm) {
client.getAccountData = (eventType) => {
expect(eventType).toEqual("m.direct");
return mkDirectEvent(roomId, userId, info.userIds);
};
}
DMRoomMap.makeShared(client).start();
const room = new Room(roomId, client, userId, {
pendingEventOrdering: PendingEventOrdering.Detached,
});
const otherJoinEvents: MatrixEvent[] = [];
for (const otherUserId of info.userIds) {
otherJoinEvents.push(mkJoinEvent(roomId, otherUserId));
}
room.currentState.setStateEvents([
mkCreationEvent(roomId, userId),
mkNameEvent(roomId, userId, info.name),
mkJoinEvent(roomId, userId),
...otherJoinEvents,
]);
room.recalculate();
return room;
}
function mountHeader(room: Room, propsOverride = {}, roomContext?: Partial<IRoomState>): RenderResult {
const props: RoomHeaderProps = {
room,
inRoom: true,
onSearchClick: () => {},
onInviteClick: null,
onForgetClick: () => {},
onAppsClick: () => {},
e2eStatus: E2EStatus.Normal,
appsShown: true,
searchInfo: {
searchId: Math.random(),
promise: new Promise<ISearchResults>(() => {}),
term: "",
scope: SearchScope.Room,
count: 0,
},
viewingCall: false,
activeCall: null,
...propsOverride,
};
return render(
<RoomContext.Provider value={{ ...roomContext, room } as IRoomState}>
<RoomHeader {...props} />
</RoomContext.Provider>,
);
}
function mkCreationEvent(roomId: string, userId: string): MatrixEvent {
return mkEvent({
event: true,
type: "m.room.create",
room: roomId,
user: userId,
content: {
creator: userId,
room_version: "5",
predecessor: {
room_id: "!prevroom",
event_id: "$someevent",
},
},
});
}
function mkNameEvent(roomId: string, userId: string, name: string): MatrixEvent {
return mkEvent({
event: true,
type: "m.room.name",
room: roomId,
user: userId,
content: { name },
});
}
function mkJoinEvent(roomId: string, userId: string) {
const ret = mkEvent({
event: true,
type: "m.room.member",
room: roomId,
user: userId,
content: {
membership: "join",
avatar_url: "mxc://example.org/" + userId,
},
});
ret.event.state_key = userId;
return ret;
}
function mkDirectEvent(roomId: string, userId: string, otherUsers: string[]): MatrixEvent {
const content: Record<string, string[]> = {};
for (const otherUserId of otherUsers) {
content[otherUserId] = [roomId];
}
return mkEvent({
event: true,
type: "m.direct",
room: roomId,
user: userId,
content,
});
}

View file

@ -0,0 +1,13 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Roomeader renders with no props 1`] = `
<DocumentFragment>
<header
class="mx_LegacyRoomHeader light-panel"
>
<div
class="mx_LegacyRoomHeader_wrapper"
/>
</header>
</DocumentFragment>
`;

View file

@ -21,23 +21,23 @@ exports[`HTMLExport should export 1`] = `
<div class="mx_MatrixChat_wrapper" aria-hidden="false">
<div class="mx_MatrixChat">
<main class="mx_RoomView">
<div class="mx_RoomHeader light-panel">
<div class="mx_RoomHeader_wrapper" aria-owns="mx_RightPanel">
<div class="mx_RoomHeader_avatar">
<div class="mx_LegacyRoomHeader light-panel">
<div class="mx_LegacyRoomHeader_wrapper" aria-owns="mx_RightPanel">
<div class="mx_LegacyRoomHeader_avatar">
<div class="mx_DecoratedRoomAvatar">
<span class="mx_BaseAvatar" role="presentation"><span class="mx_BaseAvatar_initial" aria-hidden="true" style="font-size:20.8px;width:32px;line-height:32px">!</span><img loading="lazy" class="mx_BaseAvatar_image" src="data:image/png;base64,00" alt="" title="!myroom:example.org" style="width:32px;height:32px" aria-hidden="true" data-testid="avatar-img"/></span>
</div>
</div>
<div class="mx_RoomHeader_name">
<div class="mx_LegacyRoomHeader_name">
<div
dir="auto"
class="mx_RoomHeader_nametext"
class="mx_LegacyRoomHeader_nametext"
title="!myroom:example.org"
>
!myroom:example.org
</div>
</div>
<div class="mx_RoomHeader_topic" dir="auto"> </div>
<div class="mx_LegacyRoomHeader_topic" dir="auto"> </div>
</div>
</div>