From 6ae7c033d572fcb37a191a7cf61634c85b8dbaf9 Mon Sep 17 00:00:00 2001 From: Germain Date: Tue, 1 Aug 2023 08:32:53 +0100 Subject: [PATCH] Add feature flag 'feature_new_room_decoration_ui' and segrate legacy UI component (#11345) * Move RoomHeader to LegacyRoomHeader * Create new RoomHeader component --- cypress/e2e/create-room/create-room.spec.ts | 2 +- cypress/e2e/invite/invite-dialog.spec.ts | 4 +- cypress/e2e/lazy-loading/lazy-loading.spec.ts | 2 +- .../one-to-one-chat/one-to-one-chat.spec.ts | 4 +- cypress/e2e/room/room-header.spec.ts | 38 +- cypress/e2e/spotlight/spotlight.spec.ts | 4 +- cypress/e2e/threads/threads.spec.ts | 2 +- cypress/e2e/timeline/timeline.spec.ts | 4 +- res/css/_components.pcss | 1 + res/css/structures/_MainSplit.pcss | 2 +- res/css/structures/_RoomView.pcss | 2 +- res/css/views/rooms/_LegacyRoomHeader.pcss | 333 +++++++ res/css/views/rooms/_RoomHeader.pcss | 287 +----- src/components/structures/RoomView.tsx | 113 ++- .../WaitingForThirdPartyRoomView.tsx | 36 +- .../views/context_menus/RoomContextMenu.tsx | 1 + .../views/right_panel/HeaderButton.tsx | 6 +- ...uttons.tsx => LegacyRoomHeaderButtons.tsx} | 13 +- .../views/rooms/LegacyRoomHeader.tsx | 825 ++++++++++++++++ src/components/views/rooms/RoomHeader.tsx | 821 +--------------- src/i18n/strings/en_EN.json | 45 +- src/settings/Settings.tsx | 8 + src/utils/exportUtils/HtmlExport.tsx | 12 +- .../__snapshots__/RoomView-test.tsx.snap | 50 +- ...t.tsx => LegacyRoomHeaderButtons-test.tsx} | 10 +- ... => LegacyRoomHeaderButtons-test.tsx.snap} | 10 +- .../views/rooms/LegacyRoomHeader-test.tsx | 884 ++++++++++++++++++ .../views/rooms/RoomHeader-test.tsx | 868 +---------------- .../__snapshots__/RoomHeader-test.tsx.snap | 13 + .../__snapshots__/HTMLExport-test.ts.snap | 12 +- 30 files changed, 2309 insertions(+), 2103 deletions(-) create mode 100644 res/css/views/rooms/_LegacyRoomHeader.pcss rename src/components/views/right_panel/{RoomHeaderButtons.tsx => LegacyRoomHeaderButtons.tsx} (96%) create mode 100644 src/components/views/rooms/LegacyRoomHeader.tsx rename test/components/views/right_panel/{RoomHeaderButtons-test.tsx => LegacyRoomHeaderButtons-test.tsx} (94%) rename test/components/views/right_panel/__snapshots__/{RoomHeaderButtons-test.tsx.snap => LegacyRoomHeaderButtons-test.tsx.snap} (54%) create mode 100644 test/components/views/rooms/LegacyRoomHeader-test.tsx create mode 100644 test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap diff --git a/cypress/e2e/create-room/create-room.spec.ts b/cypress/e2e/create-room/create-room.spec.ts index d51e683abf..1ebc1a7df7 100644 --- a/cypress/e2e/create-room/create-room.spec.ts +++ b/cypress/e2e/create-room/create-room.spec.ts @@ -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); }); diff --git a/cypress/e2e/invite/invite-dialog.spec.ts b/cypress/e2e/invite/invite-dialog.spec.ts index 80edfa411d..bfc96bc74b 100644 --- a/cypress/e2e/invite/invite-dialog.spec.ts +++ b/cypress/e2e/invite/invite-dialog.spec.ts @@ -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)"); }); diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts index 05bed5cf68..db7fedfb0e 100644 --- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts +++ b/cypress/e2e/lazy-loading/lazy-loading.spec.ts @@ -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(); }); diff --git a/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts index 09bb5a3358..bce6bf2a04 100644 --- a/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts +++ b/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts @@ -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); }); }); diff --git a/cypress/e2e/room/room-header.spec.ts b/cypress/e2e/room/room-header.spec.ts index 1ad0d6bb96..835fb2bb3e 100644 --- a/cypress/e2e/room/room-header.spec.ts +++ b/cypress/e2e/room/room-header.spec.ts @@ -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)"); }); }); }); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index 507fc2d75f..b85e110398 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -114,7 +114,7 @@ Cypress.Commands.add( Cypress.Commands.add( "roomHeaderName", (options?: Partial): Chainable> => { - 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); }); }); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index a2dc7b02df..35b6410d5b 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -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 diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 6acf8a19b2..3502655f17 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -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(() => { diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 0fd24fbaf2..7b3e833014 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -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"; diff --git a/res/css/structures/_MainSplit.pcss b/res/css/structures/_MainSplit.pcss index 55e0dec103..1cca495bf2 100644 --- a/res/css/structures/_MainSplit.pcss +++ b/res/css/structures/_MainSplit.pcss @@ -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; diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index 73254b7f71..30583384b7 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -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; } diff --git a/res/css/views/rooms/_LegacyRoomHeader.pcss b/res/css/views/rooms/_LegacyRoomHeader.pcss new file mode 100644 index 0000000000..17f1dfec91 --- /dev/null +++ b/res/css/views/rooms/_LegacyRoomHeader.pcss @@ -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; + } +} diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index e4b3da447c..80f43c0b31 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -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; - } } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 0002435256..ecdfdf2d32 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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 (
- + {SettingsStore.getValue("feature_new_room_decoration_ui") ? ( + + ) : ( + + )}
@@ -345,22 +350,26 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement return (
- + {SettingsStore.getValue("feature_new_room_decoration_ui") ? ( + + ) : ( + + )}
@@ -2460,23 +2469,27 @@ export class RoomView extends React.Component { )} - + {SettingsStore.getValue("feature_new_room_decoration_ui") ? ( + + ) : ( + + )} ; @@ -48,21 +50,25 @@ export const WaitingForThirdPartyRoomView: React.FC = ({ roomView, resize return (
- + {SettingsStore.getValue("feature_new_room_decoration_ui") ? ( + + ) : ( + + )}
diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index a3e56c79e5..a8029d8f84 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -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 = ({ room, onFinished, ...props }) => { const cli = useContext(MatrixClientContext); diff --git a/src/components/views/right_panel/HeaderButton.tsx b/src/components/views/right_panel/HeaderButton.tsx index 5c6559262c..4fb8c1b513 100644 --- a/src/components/views/right_panel/HeaderButton.tsx +++ b/src/components/views/right_panel/HeaderButton.tsx @@ -45,9 +45,9 @@ export default class HeaderButton extends React.Component { 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, }); diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx similarity index 96% rename from src/components/views/right_panel/RoomHeaderButtons.tsx rename to src/components/views/right_panel/LegacyRoomHeaderButtons.tsx index 4cc292e263..693fed6b43 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/LegacyRoomHeaderButtons.tsx @@ -64,14 +64,14 @@ const UnreadIndicator: React.FC = ({ 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 ( <> -
+
); @@ -127,7 +127,10 @@ interface IProps { excludedRightPanelPhaseButtons?: Array; } -export default class RoomHeaderButtons extends HeaderButtons { +/** + * @deprecated will be removed as part of 'feature_new_room_decoration_ui' + */ +export default class LegacyRoomHeaderButtons extends HeaderButtons { private static readonly THREAD_PHASES = [RightPanelPhases.ThreadPanel, RightPanelPhases.ThreadView]; private globalNotificationState: SummarizedNotificationState; @@ -257,7 +260,7 @@ export default class RoomHeaderButtons extends HeaderButtons { }; 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 { 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} > diff --git a/src/components/views/rooms/LegacyRoomHeader.tsx b/src/components/views/rooms/LegacyRoomHeader.tsx new file mode 100644 index 0000000000..e3be6cca88 --- /dev/null +++ b/src/components/views/rooms/LegacyRoomHeader.tsx @@ -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 = ({ 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 => { + ev.preventDefault(); + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice); + setBusy(false); + }, + disabled: false, + }; + } + }, [behavior, room, setBusy]); + + return ( + + ); +}; + +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 = ({ room, busy, setBusy, behavior }) => { + const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); + + const startLegacyCall = useCallback(async (): Promise => { + setBusy(true); + await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video); + setBusy(false); + }, [setBusy, room]); + + const startElementCall = useCallback(() => { + setBusy(true); + defaultDispatcher.dispatch({ + 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 => { + ev.preventDefault(); + await startLegacyCall(); + }, + disabled: false, + }; + } else if (behavior === "element") { + return { + onClick: async (ev: ButtonEvent): Promise => { + ev.preventDefault(); + startElementCall(); + }, + disabled: false, + }; + } else { + // behavior === "jitsi_or_element" + return { + onClick: async (ev: ButtonEvent): Promise => { + ev.preventDefault(); + openMenu(); + }, + disabled: false, + }; + } + }, [behavior, startLegacyCall, startElementCall, openMenu]); + + const onJitsiClick = useCallback( + async (ev: ButtonEvent): Promise => { + 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 = ( + + + + + + + ); + } + + return ( + <> + + {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 = ({ room }) => { + const [busy, setBusy] = useState(false); + const showButtons = useSettingValue("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 => ( + + ); + const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => ( + + ); + + 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 = ({ 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 = ( + + + + + + + ); + } + + return ( + <> + + {menu} + + ); +}; + +export interface ISearchInfo { + searchId: number; + roomId?: string; + term: string; + scope: SearchScope; + promise: Promise; + 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; + showButtons?: boolean; + enableRoomOptionsMenu?: boolean; + viewingCall: boolean; + activeCall: Call | null; +} + +interface IState { + contextMenuPosition?: DOMRect; + rightPanelOpen: boolean; +} + +export default class RoomHeader extends React.Component { + public static defaultProps: Partial = { + inRoom: false, + excludedRightPanelPhaseButtons: [], + showButtons: true, + enableRoomOptionsMenu: true, + }; + + public static contextType = RoomContext; + public context!: React.ContextType; + 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({ + 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(); + } + + if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) { + startButtons.push(); + } + + if (!this.props.viewingCall && this.props.onForgetClick) { + startButtons.push( + , + ); + } + + if (!this.props.viewingCall && this.props.onAppsClick) { + startButtons.push( + , + ); + } + + if (!this.props.viewingCall && this.props.onSearchClick && this.props.inRoom) { + startButtons.push( + , + ); + } + + if (this.props.onInviteClick && (!this.props.viewingCall || isVideoRoom) && this.props.inRoom) { + startButtons.push( + , + ); + } + + const endButtons: JSX.Element[] = []; + + if (this.props.viewingCall && !isVideoRoom) { + if (this.props.activeCall === null) { + endButtons.push( + , + ); + } else { + endButtons.push( + , + ); + } + } + + return ( + <> + {startButtons} + + {endButtons} + + ); + } + + private renderName(oobName: string): JSX.Element { + let contextMenu: JSX.Element | null = null; + if (this.state.contextMenuPosition && this.props.room) { + contextMenu = ( + + ); + } + + // 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 = ( + + {(name) => { + const roomName = name || oobName; + return ( +
+ {roomName} +
+ ); + }} +
+ ); + + if (this.props.enableRoomOptionsMenu && shouldShowComponent(UIComponent.RoomOptionsMenu)) { + return ( + + {roomName} + {this.props.room &&
} + {contextMenu} + + ); + } + + return
{roomName}
; + } + + 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 = ( + + ); + } + + const icon = this.props.viewingCall ? ( +
+ ) : this.props.e2eStatus ? ( + + ) : // 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() ? ( +
+ ) : 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 ( +
+
+
{roomAvatar}
+ {icon} + {name} + {this.props.activeCall instanceof ElementCall && ( + + )} + {/* Empty topic element to fill out space */} +
+ {buttons} +
+
+ ); + } + + 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 = ( +
+   + {_t("(~%(count)s results)", { count: this.props.searchInfo.count })} +
+ ); + } + + const topicElement = ; + + const viewLabs = (): void => + defaultDispatcher.dispatch({ + action: Action.ViewUserSettings, + initialTabId: UserTab.Labs, + }); + const betaPill = isVideoRoom ? ( + + ) : null; + + return ( +
+
+
{roomAvatar}
+ {icon} + {name} + {searchStatus} + {topicElement} + {betaPill} + {buttons} +
+ {!isVideoRoom && } + +
+ ); + } +} diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index c0032a8936..8cf09e0d01 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -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 = ({ 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 => { - ev.preventDefault(); - setBusy(true); - await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Voice); - setBusy(false); - }, - disabled: false, - }; - } - }, [behavior, room, setBusy]); - - return ( - - ); -}; - -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 = ({ room, busy, setBusy, behavior }) => { - const [menuOpen, buttonRef, openMenu, closeMenu] = useContextMenu(); - - const startLegacyCall = useCallback(async (): Promise => { - setBusy(true); - await LegacyCallHandler.instance.placeCall(room.roomId, CallType.Video); - setBusy(false); - }, [setBusy, room]); - - const startElementCall = useCallback(() => { - setBusy(true); - defaultDispatcher.dispatch({ - 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 => { - ev.preventDefault(); - await startLegacyCall(); - }, - disabled: false, - }; - } else if (behavior === "element") { - return { - onClick: async (ev: ButtonEvent): Promise => { - ev.preventDefault(); - startElementCall(); - }, - disabled: false, - }; - } else { - // behavior === "jitsi_or_element" - return { - onClick: async (ev: ButtonEvent): Promise => { - ev.preventDefault(); - openMenu(); - }, - disabled: false, - }; - } - }, [behavior, startLegacyCall, startElementCall, openMenu]); - - const onJitsiClick = useCallback( - async (ev: ButtonEvent): Promise => { - 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 = ( - - - - - - - ); +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 ( - <> - - {menu} - +
+
+ {room && ( + + {(name) => { + const roomName = name || oobName; + return ( +
+ {roomName} +
+ ); + }} +
+ )} +
+
); -}; - -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 = ({ room }) => { - const [busy, setBusy] = useState(false); - const showButtons = useSettingValue("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 => ( - - ); - const makeVideoCallButton = (behavior: VideoCallButtonProps["behavior"]): JSX.Element => ( - - ); - - 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 = ({ 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 = ( - - - - - - - ); - } - - return ( - <> - - {menu} - - ); -}; - -export interface ISearchInfo { - searchId: number; - roomId?: string; - term: string; - scope: SearchScope; - promise: Promise; - 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; - showButtons?: boolean; - enableRoomOptionsMenu?: boolean; - viewingCall: boolean; - activeCall: Call | null; -} - -interface IState { - contextMenuPosition?: DOMRect; - rightPanelOpen: boolean; -} - -export default class RoomHeader extends React.Component { - public static defaultProps: Partial = { - inRoom: false, - excludedRightPanelPhaseButtons: [], - showButtons: true, - enableRoomOptionsMenu: true, - }; - - public static contextType = RoomContext; - public context!: React.ContextType; - 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({ - 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(); - } - - if (this.props.viewingCall && this.props.activeCall instanceof ElementCall) { - startButtons.push(); - } - - if (!this.props.viewingCall && this.props.onForgetClick) { - startButtons.push( - , - ); - } - - if (!this.props.viewingCall && this.props.onAppsClick) { - startButtons.push( - , - ); - } - - if (!this.props.viewingCall && this.props.onSearchClick && this.props.inRoom) { - startButtons.push( - , - ); - } - - if (this.props.onInviteClick && (!this.props.viewingCall || isVideoRoom) && this.props.inRoom) { - startButtons.push( - , - ); - } - - const endButtons: JSX.Element[] = []; - - if (this.props.viewingCall && !isVideoRoom) { - if (this.props.activeCall === null) { - endButtons.push( - , - ); - } else { - endButtons.push( - , - ); - } - } - - return ( - <> - {startButtons} - - {endButtons} - - ); - } - - private renderName(oobName: string): JSX.Element { - let contextMenu: JSX.Element | null = null; - if (this.state.contextMenuPosition && this.props.room) { - contextMenu = ( - - ); - } - - // 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 = ( - - {(name) => { - const roomName = name || oobName; - return ( -
- {roomName} -
- ); - }} -
- ); - - if (this.props.enableRoomOptionsMenu && shouldShowComponent(UIComponent.RoomOptionsMenu)) { - return ( - - {roomName} - {this.props.room &&
} - {contextMenu} - - ); - } - - return
{roomName}
; - } - - 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 = ( - - ); - } - - const icon = this.props.viewingCall ? ( -
- ) : this.props.e2eStatus ? ( - - ) : // 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() ? ( -
- ) : 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 ( -
-
-
{roomAvatar}
- {icon} - {name} - {this.props.activeCall instanceof ElementCall && ( - - )} - {/* Empty topic element to fill out space */} -
- {buttons} -
-
- ); - } - - 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 = ( -
-   - {_t("(~%(count)s results)", { count: this.props.searchInfo.count })} -
- ); - } - - const topicElement = ; - - const viewLabs = (): void => - defaultDispatcher.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Labs, - }); - const betaPill = isVideoRoom ? ( - - ) : null; - - return ( -
-
-
{roomAvatar}
- {icon} - {name} - {searchStatus} - {topicElement} - {betaPill} - {buttons} -
- {!isVideoRoom && } - -
- ); - } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index a909a69fab..f0f042cde2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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 Pin to stick them here.": "If you have permissions, open the menu on any message and select Pin 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 Pin to stick them here.": "If you have permissions, open the menu on any message and select Pin 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", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 3f32d27d77..bab6c1d302 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -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"), diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 41edfd93df..323e15a144 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -181,23 +181,23 @@ export default class HTMLExporter extends Exporter {
-
-
-
+
+
+
${roomAvatar}
-
+
${safeRoomName}
-
${safeTopic}
+
${safeTopic}
${previousMessagesLink} diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index bc54b24d51..c0a2989f96 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -6,13 +6,13 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1 class="mx_RoomView mx_RoomView--local" >
@@ -99,13 +99,13 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`] class="mx_RoomView mx_RoomView--local" >
@@ -289,13 +289,13 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] = class="mx_RoomView mx_RoomView--local" >
@@ -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" >
diff --git a/test/components/views/right_panel/RoomHeaderButtons-test.tsx b/test/components/views/right_panel/LegacyRoomHeaderButtons-test.tsx similarity index 94% rename from test/components/views/right_panel/RoomHeaderButtons-test.tsx rename to test/components/views/right_panel/LegacyRoomHeaderButtons-test.tsx index 751e8d0d88..fde3277db6 100644 --- a/test/components/views/right_panel/RoomHeaderButtons-test.tsx +++ b/test/components/views/right_panel/LegacyRoomHeaderButtons-test.tsx @@ -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(); + return render(); } 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); diff --git a/test/components/views/right_panel/__snapshots__/RoomHeaderButtons-test.tsx.snap b/test/components/views/right_panel/__snapshots__/LegacyRoomHeaderButtons-test.tsx.snap similarity index 54% rename from test/components/views/right_panel/__snapshots__/RoomHeaderButtons-test.tsx.snap rename to test/components/views/right_panel/__snapshots__/LegacyRoomHeaderButtons-test.tsx.snap index 83a35431e8..bd706048be 100644 --- a/test/components/views/right_panel/__snapshots__/RoomHeaderButtons-test.tsx.snap +++ b/test/components/views/right_panel/__snapshots__/LegacyRoomHeaderButtons-test.tsx.snap @@ -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`] = `
diff --git a/test/components/views/rooms/LegacyRoomHeader-test.tsx b/test/components/views/rooms/LegacyRoomHeader-test.tsx new file mode 100644 index 0000000000..ea16fe4b12 --- /dev/null +++ b/test/components/views/rooms/LegacyRoomHeader-test.tsx @@ -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; + 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): Promise => { + 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; + WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); + + await fn(call); + + call.destroy(); + WidgetMessagingStore.instance.stopMessaging(widget, call.roomId); + }; + + const renderHeader = (props: Partial = {}, roomContext: Partial = {}) => { + render( + + {}} + onInviteClick={null} + onForgetClick={() => {}} + onAppsClick={() => {}} + e2eStatus={E2EStatus.Normal} + appsShown={true} + searchInfo={{ + searchId: Math.random(), + promise: new Promise(() => {}), + term: "", + scope: SearchScope.Room, + count: 0, + }} + viewingCall={false} + activeCall={null} + {...props} + /> + , + ); + }; + + 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): RenderResult { + const props: RoomHeaderProps = { + room, + inRoom: true, + onSearchClick: () => {}, + onInviteClick: null, + onForgetClick: () => {}, + onAppsClick: () => {}, + e2eStatus: E2EStatus.Normal, + appsShown: true, + searchInfo: { + searchId: Math.random(), + promise: new Promise(() => {}), + term: "", + scope: SearchScope.Room, + count: 0, + }, + viewingCall: false, + activeCall: null, + ...propsOverride, + }; + + return render( + + + , + ); +} + +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 = {}; + for (const otherUserId of otherUsers) { + content[otherUserId] = [roomId]; + } + return mkEvent({ + event: true, + type: "m.direct", + room: roomId, + user: userId, + content, + }); +} diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index ea920cf4d5..819d98a2f6 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -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; 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(); + 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): Promise => { - 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; - WidgetMessagingStore.instance.storeMessaging(widget, call.roomId, messaging); - - await fn(call); - - call.destroy(); - WidgetMessagingStore.instance.stopMessaging(widget, call.roomId); - }; - - const renderHeader = (props: Partial = {}, roomContext: Partial = {}) => { - render( - - {}} - onInviteClick={null} - onForgetClick={() => {}} - onAppsClick={() => {}} - e2eStatus={E2EStatus.Normal} - appsShown={true} - searchInfo={{ - searchId: Math.random(), - promise: new Promise(() => {}), - term: "", - scope: SearchScope.Room, - count: 0, - }} - viewingCall={false} - activeCall={null} - {...props} - /> - , - ); - }; - - 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(); + 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): RenderResult { - const props: RoomHeaderProps = { - room, - inRoom: true, - onSearchClick: () => {}, - onInviteClick: null, - onForgetClick: () => {}, - onAppsClick: () => {}, - e2eStatus: E2EStatus.Normal, - appsShown: true, - searchInfo: { - searchId: Math.random(), - promise: new Promise(() => {}), - term: "", - scope: SearchScope.Room, - count: 0, - }, - viewingCall: false, - activeCall: null, - ...propsOverride, - }; - - return render( - - - , - ); -} - -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 = {}; - for (const otherUserId of otherUsers) { - content[otherUserId] = [roomId]; - } - return mkEvent({ - event: true, - type: "m.direct", - room: roomId, - user: userId, - content, - }); -} diff --git a/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap new file mode 100644 index 0000000000..01105de9cb --- /dev/null +++ b/test/components/views/rooms/__snapshots__/RoomHeader-test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Roomeader renders with no props 1`] = ` + +
+
+
+
+`; diff --git a/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index c244074575..2fe4ab5394 100644 --- a/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/test/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -21,23 +21,23 @@ exports[`HTMLExport should export 1`] = `
-
-
-
+
+
+
-
+
!myroom:example.org
-
+