diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 99bde7ad1d..650f8d585c 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -14,9 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; + import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; -import type { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import { SynapseInstance } from "../../plugins/synapsedocker"; import Chainable = Cypress.Chainable; @@ -60,7 +61,6 @@ const testMessages = function(this: CryptoTestContext) { // check the invite message cy.contains(".mx_EventTile_body", "Hey!").closest(".mx_EventTile").within(() => { cy.get(".mx_EventTile_e2eIcon_warning").should("not.exist"); - cy.get(".mx_EventTile_receiptSent").should("exist"); }); // Bob sends a response @@ -73,17 +73,31 @@ const testMessages = function(this: CryptoTestContext) { }; const bobJoin = function(this: CryptoTestContext) { - cy.botJoinRoomByName(this.bob, "Alice").as("bobsRoom"); + cy.window({ log: false }).then(async win => { + const bobRooms = this.bob.getRooms(); + if (!bobRooms.length) { + await new Promise(resolve => { + const onMembership = (_event) => { + this.bob.off(win.matrixcs.RoomMemberEvent.Membership, onMembership); + resolve(); + }; + this.bob.on(win.matrixcs.RoomMemberEvent.Membership, onMembership); + }); + } + }).then(() => { + cy.botJoinRoomByName(this.bob, "Alice").as("bobsRoom"); + }); + cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist"); }; const handleVerificationRequest = (request: VerificationRequest): Chainable => { return cy.wrap(new Promise((resolve) => { const onShowSas = (event: ISasEvent) => { - resolve(event.sas.emoji); verifier.off("show_sas", onShowSas); event.confirm(); verifier.done(); + resolve(event.sas.emoji); }; const verifier = request.beginKeyVerification("m.sas.v1"); diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts index 7d26b48676..be27c19346 100644 --- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts +++ b/cypress/e2e/lazy-loading/lazy-loading.spec.ts @@ -109,7 +109,7 @@ describe("Lazy Loading", () => { } function openMemberlist(): void { - cy.get('.mx_HeaderButtons [aria-label="Room Info"]').click(); + cy.get('.mx_HeaderButtons [aria-label="Room info"]').click(); cy.get(".mx_RoomSummaryCard").within(() => { cy.get(".mx_RoomSummaryCard_icon_people").click(); }); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts index ea3a5239f0..2ba2e33f9b 100644 --- a/cypress/e2e/login/login.spec.ts +++ b/cypress/e2e/login/login.spec.ts @@ -58,7 +58,7 @@ describe("Login", () => { cy.startMeasuring("from-submit-to-home"); cy.get(".mx_Login_submit").click(); - cy.url().should('contain', '/#/home'); + cy.url().should('contain', '/#/home', { timeout: 30000 }); cy.stopMeasuring("from-submit-to-home"); }); }); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index ecfd4af90e..470c69d8cf 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -105,6 +105,8 @@ describe("Polls", () => { roomId = _roomId; cy.inviteUser(roomId, bot.getUserId()); cy.visit('/#/room/' + roomId); + // wait until Bob joined + cy.contains(".mx_TextualEvent", "BotBob joined the room").should("exist"); }); cy.openMessageComposerOptions().within(() => { @@ -173,6 +175,8 @@ describe("Polls", () => { cy.inviteUser(roomId, botBob.getUserId()); cy.inviteUser(roomId, botCharlie.getUserId()); cy.visit('/#/room/' + roomId); + // wait until the bots joined + cy.contains(".mx_TextualEvent", "and one other were invited and joined").should("exist"); }); cy.openMessageComposerOptions().within(() => { diff --git a/cypress/e2e/register/register.spec.ts b/cypress/e2e/register/register.spec.ts index a0c120414d..1945eb7fec 100644 --- a/cypress/e2e/register/register.spec.ts +++ b/cypress/e2e/register/register.spec.ts @@ -72,7 +72,7 @@ describe("Registration", () => { cy.startMeasuring("from-submit-to-home"); cy.get(".mx_InteractiveAuthEntryComponents_termsSubmit").click(); - cy.get(".mx_UseCaseSelection_skip").should("exist"); + cy.get(".mx_UseCaseSelection_skip", { timeout: 30000 }).should("exist"); cy.percySnapshot("Use-case selection screen"); cy.checkA11y(); cy.get(".mx_UseCaseSelection_skip .mx_AccessibleButton").click(); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index 06bd675e19..71340d44f6 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -162,7 +162,7 @@ describe("Spotlight", () => { cy.window({ log: false }).then(({ matrixcs: { Visibility } }) => { cy.createRoom({ name: room1Name, visibility: Visibility.Public }).then(_room1Id => { room1Id = _room1Id; - cy.inviteUser(room1Id, bot1.getUserId()); + bot1.joinRoom(room1Id); cy.visit("/#/room/" + room1Id); }); bot2.createRoom({ name: room2Name, visibility: Visibility.Public }) diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 1cc6848ec5..5af2d07d79 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -73,7 +73,10 @@ describe("Threads", () => { it("should be usable for a conversation", () => { let bot: MatrixClient; - cy.getBot(synapse, { displayName: "BotBob" }).then(_bot => { + cy.getBot(synapse, { + displayName: "BotBob", + autoAcceptInvites: false, + }).then(_bot => { bot = _bot; }); @@ -81,6 +84,7 @@ describe("Threads", () => { cy.createRoom({}).then(_roomId => { roomId = _roomId; cy.inviteUser(roomId, bot.getUserId()); + bot.joinRoom(roomId); cy.visit("/#/room/" + roomId); }); diff --git a/cypress/e2e/widgets/layout.spec.ts b/cypress/e2e/widgets/layout.spec.ts new file mode 100644 index 0000000000..25264c6622 --- /dev/null +++ b/cypress/e2e/widgets/layout.spec.ts @@ -0,0 +1,121 @@ +/* +Copyright 2022 Oliver Sand +Copyright 2022 Nordeck IT + Consulting GmbH. + +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 { IWidget } from "matrix-widget-api"; + +import { SynapseInstance } from "../../plugins/synapsedocker"; + +const ROOM_NAME = 'Test Room'; +const WIDGET_ID = "fake-widget"; +const WIDGET_HTML = ` + + + Fake Widget + + + Hello World + + +`; + +describe('Widget Layout', () => { + let widgetUrl: string; + let synapse: SynapseInstance; + let roomId: string; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Sally"); + }); + cy.serveHtmlFile(WIDGET_HTML).then(url => { + widgetUrl = url; + }); + + cy.createRoom({ + name: ROOM_NAME, + }).then((id) => { + roomId = id; + + // setup widget via state event + cy.getClient().then(async matrixClient => { + const content: IWidget = { + id: WIDGET_ID, + creatorUserId: 'somebody', + type: 'widget', + name: 'widget', + url: widgetUrl, + }; + await matrixClient.sendStateEvent(roomId, 'im.vector.modular.widgets', content, WIDGET_ID); + }).as('widgetEventSent'); + + // set initial layout + cy.getClient().then(async matrixClient => { + const content = { + widgets: { + [WIDGET_ID]: { + container: 'top', index: 1, width: 100, height: 0, + }, + }, + }; + await matrixClient.sendStateEvent(roomId, 'io.element.widgets.layout', content, ""); + }).as('layoutEventSent'); + }); + + cy.all([ + cy.get("@widgetEventSent"), + cy.get("@layoutEventSent"), + ]).then(() => { + // open the room + cy.viewRoomByName(ROOM_NAME); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + cy.stopWebServers(); + }); + + it('manually resize the height of the top container layout', () => { + cy.get('iframe[title="widget"]').invoke('height').should('be.lessThan', 250); + + cy.get('.mx_AppsContainer_resizerHandle') + .trigger('mousedown') + .trigger('mousemove', { clientX: 0, clientY: 550, force: true }) + .trigger('mouseup', { clientX: 0, clientY: 550, force: true }); + + cy.get('iframe[title="widget"]').invoke('height').should('be.greaterThan', 400); + }); + + it('programatically resize the height of the top container layout', () => { + cy.get('iframe[title="widget"]').invoke('height').should('be.lessThan', 250); + + cy.getClient().then(async matrixClient => { + const content = { + widgets: { + [WIDGET_ID]: { + container: 'top', index: 1, width: 100, height: 100, + }, + }, + }; + await matrixClient.sendStateEvent(roomId, 'io.element.widgets.layout', content, ""); + }); + + cy.get('iframe[title="widget"]').invoke('height').should('be.greaterThan', 400); + }); +}); diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index bfbda1e360..d8772cbab8 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -100,7 +100,9 @@ async function synapseStart(template: string): Promise { console.log(`Starting synapse with config dir ${synCfg.configDir}...`); const synapseId = await dockerRun({ - image: "matrixdotorg/synapse:develop", + // XXX: switch back to `develop` tag once the threads receipts issue is fixed + // https://github.com/vector-im/element-web/issues/23451 + image: "matrixdotorg/synapse:latest", containerName: `react-sdk-cypress-synapse`, params: [ "--rm", diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index f724d6b3d3..35da14ebd7 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -128,7 +128,7 @@ Cypress.Commands.add("botJoinRoomByName", (cli: MatrixClient, roomName: string): return cy.botJoinRoom(cli, room.roomId); } - return cy.wrap(Promise.reject()); + return cy.wrap(Promise.reject(`Bot room join failed. Cannot find room '${roomName}'`)); }); Cypress.Commands.add("botSendMessage", ( diff --git a/cypress/support/login.ts b/cypress/support/login.ts index 4cdfd6a84d..46b1b7a89f 100644 --- a/cypress/support/login.ts +++ b/cypress/support/login.ts @@ -97,7 +97,7 @@ Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: str return cy.visit("/").then(() => { // wait for the app to load - return cy.get(".mx_MatrixChat", { timeout: 15000 }); + return cy.get(".mx_MatrixChat", { timeout: 30000 }); }).then(() => ({ password, accessToken: response.body.access_token, diff --git a/package.json b/package.json index 23228f9e4d..a8bee96ee4 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@sentry/browser": "^6.11.0", "@sentry/tracing": "^6.11.0", "@types/geojson": "^7946.0.8", + "@types/ua-parser-js": "^0.7.36", "await-lock": "^2.1.0", "blurhash": "^1.1.3", "browser-request": "^0.3.3", @@ -113,6 +114,7 @@ "rfc4648": "^1.4.0", "sanitize-html": "^2.3.2", "tar-js": "^0.3.0", + "ua-parser-js": "^1.0.2", "url": "^0.11.0", "what-input": "^5.2.10", "zxcvbn": "^4.4.2" diff --git a/res/css/_common.pcss b/res/css/_common.pcss index db663a8e25..4da58c1d37 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -453,7 +453,7 @@ legend { } @define-mixin customisedCancelButton { - mask: url('$(res)/img/feather-customised/cancel.svg'); + mask: url('$(res)/img/cancel.svg'); mask-repeat: no-repeat; mask-position: center; mask-size: cover; @@ -466,8 +466,8 @@ legend { .mx_Dialog_cancelButton { @mixin customisedCancelButton; - width: 14px; - height: 14px; + width: 18px; + height: 18px; position: absolute; top: 10px; right: 0; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 435ed9158b..b2a3752628 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -34,7 +34,7 @@ @import "./components/views/settings/devices/_DeviceExpandDetailsButton.pcss"; @import "./components/views/settings/devices/_DeviceSecurityCard.pcss"; @import "./components/views/settings/devices/_DeviceTile.pcss"; -@import "./components/views/settings/devices/_DeviceType.pcss"; +@import "./components/views/settings/devices/_DeviceTypeIcon.pcss"; @import "./components/views/settings/devices/_FilteredDeviceList.pcss"; @import "./components/views/settings/devices/_FilteredDeviceListHeader.pcss"; @import "./components/views/settings/devices/_SecurityRecommendations.pcss"; @@ -338,6 +338,7 @@ @import "./views/spaces/_SpacePublicShare.pcss"; @import "./views/terms/_InlineTermsAgreement.pcss"; @import "./views/toasts/_AnalyticsToast.pcss"; +@import "./views/toasts/_IncomingCallToast.pcss"; @import "./views/toasts/_IncomingLegacyCallToast.pcss"; @import "./views/toasts/_NonUrgentEchoFailureToast.pcss"; @import "./views/typography/_Heading.pcss"; diff --git a/res/css/components/views/settings/devices/_DeviceType.pcss b/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss similarity index 86% rename from res/css/components/views/settings/devices/_DeviceType.pcss rename to res/css/components/views/settings/devices/_DeviceTypeIcon.pcss index 66372bbdea..a092112d8a 100644 --- a/res/css/components/views/settings/devices/_DeviceType.pcss +++ b/res/css/components/views/settings/devices/_DeviceTypeIcon.pcss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_DeviceType { +.mx_DeviceTypeIcon { flex: 0 0 auto; position: relative; margin-right: $spacing-8; @@ -22,7 +22,7 @@ limitations under the License. padding: 0 $spacing-8 $spacing-8 0; } -.mx_DeviceType_deviceIcon { +.mx_DeviceTypeIcon_deviceIconWrapper { --background-color: $system; --icon-color: $secondary-content; @@ -36,12 +36,17 @@ limitations under the License. background-color: var(--background-color); } -.mx_DeviceType_selected .mx_DeviceType_deviceIcon { +.mx_DeviceTypeIcon_selected .mx_DeviceTypeIcon_deviceIconWrapper { --background-color: $primary-content; --icon-color: $background; } -.mx_DeviceType_verificationIcon { +.mx_DeviceTypeIcon_deviceIcon { + height: 24px; + width: 24px; +} + +.mx_DeviceTypeIcon_verificationIcon { position: absolute; bottom: 0; right: 0; diff --git a/res/css/structures/_HeaderButtons.pcss b/res/css/structures/_HeaderButtons.pcss index 96f6f2e9f9..4a3de48376 100644 --- a/res/css/structures/_HeaderButtons.pcss +++ b/res/css/structures/_HeaderButtons.pcss @@ -17,20 +17,3 @@ limitations under the License. .mx_HeaderButtons { display: flex; } - -.mx_RoomHeader_buttons + .mx_HeaderButtons { - /* remove the | separator line for when next to RoomHeaderButtons */ - /* TODO: remove this once when we redo communities and make the right panel similar to the new rooms one */ - &::before { - content: unset; - } -} - -.mx_HeaderButtons::before { - content: ""; - background-color: $header-panel-text-primary-color; - opacity: 0.5; - margin: 6px 8px; - border-radius: 1px; - width: 1px; -} diff --git a/res/css/views/dialogs/_RoomSettingsDialog.pcss b/res/css/views/dialogs/_RoomSettingsDialog.pcss index a242a99596..8631ec5d7d 100644 --- a/res/css/views/dialogs/_RoomSettingsDialog.pcss +++ b/res/css/views/dialogs/_RoomSettingsDialog.pcss @@ -21,6 +21,10 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/settings.svg'); } +.mx_RoomSettingsDialog_voiceIcon::before { + mask-image: url('$(res)/img/element-icons/call/voice-call.svg'); +} + .mx_RoomSettingsDialog_securityIcon::before { mask-image: url('$(res)/img/element-icons/security.svg'); } diff --git a/res/css/views/elements/_Tooltip.pcss b/res/css/views/elements/_Tooltip.pcss index 8793cec41c..7c49d2b59a 100644 --- a/res/css/views/elements/_Tooltip.pcss +++ b/res/css/views/elements/_Tooltip.pcss @@ -76,11 +76,6 @@ limitations under the License. border: 0; text-align: center; - &:not(.mx_Tooltip_noMargin) { - margin-left: 6px; - margin-right: 6px; - } - .mx_Tooltip_chevron { display: none; } diff --git a/res/css/views/rooms/_JumpToBottomButton.pcss b/res/css/views/rooms/_JumpToBottomButton.pcss index 3530d36690..4e7f180c21 100644 --- a/res/css/views/rooms/_JumpToBottomButton.pcss +++ b/res/css/views/rooms/_JumpToBottomButton.pcss @@ -68,8 +68,10 @@ limitations under the License. bottom: 0; left: 0; right: 0; - mask-image: url('$(res)/img/feather-customised/chevron-down-thin.svg'); + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); mask-repeat: no-repeat; - mask-size: contain; + mask-size: 20px; + mask-position: center 6px; + transform: rotate(180deg); background: $muted-fg-color; } diff --git a/res/css/views/rooms/_RoomHeader.pcss b/res/css/views/rooms/_RoomHeader.pcss index 80e3bfd306..1b325db906 100644 --- a/res/css/views/rooms/_RoomHeader.pcss +++ b/res/css/views/rooms/_RoomHeader.pcss @@ -19,16 +19,27 @@ limitations under the License. border-bottom: 1px solid $primary-hairline-color; background-color: $background; - .mx_RoomHeader_e2eIcon { + .mx_RoomHeader_icon { height: 12px; width: 12px; - .mx_E2EIcon { - margin: 0; - position: absolute; - 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; } } @@ -38,7 +49,7 @@ limitations under the License. align-items: center; min-width: 0; margin: 0 20px 0 16px; - padding-top: 8px; + padding-top: 6px; border-bottom: 1px solid $system; .mx_InviteOnlyIcon_large { @@ -77,11 +88,6 @@ limitations under the License. padding-right: 12px; } -.mx_RoomHeader_buttons { - display: flex; - background-color: $background; -} - .mx_RoomHeader_info { display: flex; flex: 1; @@ -93,9 +99,11 @@ limitations under the License. overflow: hidden; color: $primary-content; font-weight: $font-semi-bold; - font-size: $font-18px; + font-size: $font-15px; + min-height: 24px; + align-items: center; border-radius: 6px; - margin: 0 7px; + margin: 0 3px; padding: 1px 4px; display: flex; user-select: none; @@ -112,10 +120,10 @@ limitations under the License. .mx_RoomHeader_chevron { align-self: center; - width: 16px; - height: 16px; + width: 20px; + height: 20px; mask-position: center; - mask-size: contain; + mask-size: 20px; mask-repeat: no-repeat; mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); background-color: $tertiary-content; @@ -160,9 +168,6 @@ limitations under the License. line-height: $lineHeight; max-height: calc($lineHeight * $lines); - /* to align baseline of topic with room name */ - margin: 4px 7px 0; - overflow: hidden; -webkit-line-clamp: $lines; /* See: https://drafts.csswg.org/css-overflow-3/#webkit-line-clamp */ -webkit-box-orient: vertical; @@ -177,7 +182,7 @@ limitations under the License. .mx_RoomHeader_avatar { flex: 0; - margin: 0 6px 0 7px; + margin: 0 7px; position: relative; } @@ -206,7 +211,7 @@ limitations under the License. mask-size: contain; } - &:hover { + &:not(.mx_RoomHeader_closeButton):hover { background: rgba($accent, 0.1); &::before { @@ -249,6 +254,37 @@ limitations under the License. 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; +} + +.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; diff --git a/res/css/views/rooms/_TopUnreadMessagesBar.pcss b/res/css/views/rooms/_TopUnreadMessagesBar.pcss index fbb7cb0b1e..12daa641db 100644 --- a/res/css/views/rooms/_TopUnreadMessagesBar.pcss +++ b/res/css/views/rooms/_TopUnreadMessagesBar.pcss @@ -51,11 +51,11 @@ limitations under the License. position: absolute; width: 36px; height: 36px; - mask-image: url('$(res)/img/feather-customised/chevron-down-thin.svg'); + mask-image: url('$(res)/img/element-icons/message/chevron-up.svg'); mask-repeat: no-repeat; - mask-size: contain; + mask-size: 20px; + mask-position: center; background: $muted-fg-color; - transform: rotate(180deg); } .mx_TopUnreadMessagesBar_markAsRead { diff --git a/res/css/views/settings/_DevicesPanel.pcss b/res/css/views/settings/_DevicesPanel.pcss index 23a737c977..8a7842d4d0 100644 --- a/res/css/views/settings/_DevicesPanel.pcss +++ b/res/css/views/settings/_DevicesPanel.pcss @@ -58,7 +58,7 @@ limitations under the License. min-height: 35px; padding: 0 $spacing-8; - .mx_DeviceType { + .mx_DeviceTypeIcon { /* hide the new device type in legacy device list for backwards compat reasons */ display: none; diff --git a/res/css/views/toasts/_IncomingCallToast.pcss b/res/css/views/toasts/_IncomingCallToast.pcss new file mode 100644 index 0000000000..e66e1c31d4 --- /dev/null +++ b/res/css/views/toasts/_IncomingCallToast.pcss @@ -0,0 +1,105 @@ +/* +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. +*/ + +.mx_IncomingCallToast { + position: relative; + display: flex; + flex-direction: row; + pointer-events: initial; /* restore pointer events so the user can accept/decline */ + width: 250px; + + .mx_IncomingCallToast_content { + display: flex; + flex-direction: column; + margin-left: 8px; + width: 100%; + + .mx_IncomingCallToast_info { + margin-bottom: $spacing-16; + + .mx_IncomingCallToast_room { + display: inline-block; + + font-weight: bold; + font-size: $font-15px; + line-height: $font-24px; + + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + margin-bottom: $spacing-4; + } + + .mx_IncomingCallToast_message { + font-size: $font-12px; + line-height: $font-15px; + + margin-bottom: $spacing-4; + } + + .mx_LiveContentSummary { + font-size: $font-12px; + line-height: $font-15px; + + .mx_LiveContentSummary_participants::before { + width: 15px; + height: 15px; + } + } + } + + .mx_IncomingCallToast_joinButton { + position: relative; + + bottom: $spacing-4; + right: $spacing-4; + + align-self: flex-end; + + box-sizing: border-box; + min-width: 120px; + + padding: $spacing-4 0; + + line-height: $font-24px; + } + } + + .mx_IncomingCallToast_closeButton { + position: absolute; + + top: $spacing-4; + right: $spacing-4; + + display: flex; + height: 16px; + width: 16px; + + &::before { + content: ''; + + mask-image: url('$(res)/img/cancel.svg'); + + height: inherit; + width: inherit; + background-color: $secondary-content; + mask-repeat: no-repeat; + mask-size: contain; + mask-position: center; + } + } +} diff --git a/res/css/views/voip/_LegacyCallViewHeader.pcss b/res/css/views/voip/_LegacyCallViewHeader.pcss index 3d8d4d2fd9..9849cd1430 100644 --- a/res/css/views/voip/_LegacyCallViewHeader.pcss +++ b/res/css/views/voip/_LegacyCallViewHeader.pcss @@ -25,7 +25,7 @@ limitations under the License. width: 100%; &.mx_LegacyCallViewHeader_pip { - cursor: pointer; + cursor: grab; } } diff --git a/res/img/cancel.svg b/res/img/cancel.svg index e32060025e..2b7083e875 100644 --- a/res/img/cancel.svg +++ b/res/img/cancel.svg @@ -1,10 +1,3 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file + + + diff --git a/res/img/element-icons/call/freedom.svg b/res/img/element-icons/call/freedom.svg new file mode 100644 index 0000000000..0a883b7833 --- /dev/null +++ b/res/img/element-icons/call/freedom.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/call/spotlight.svg b/res/img/element-icons/call/spotlight.svg new file mode 100644 index 0000000000..f9d96a1e85 --- /dev/null +++ b/res/img/element-icons/call/spotlight.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/reduce.svg b/res/img/element-icons/reduce.svg new file mode 100644 index 0000000000..3179e33a23 --- /dev/null +++ b/res/img/element-icons/reduce.svg @@ -0,0 +1,4 @@ + + + + diff --git a/res/img/element-icons/settings/desktop.svg b/res/img/element-icons/settings/desktop.svg new file mode 100644 index 0000000000..7d6ca10079 --- /dev/null +++ b/res/img/element-icons/settings/desktop.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/settings/mobile.svg b/res/img/element-icons/settings/mobile.svg new file mode 100644 index 0000000000..45170b2c15 --- /dev/null +++ b/res/img/element-icons/settings/mobile.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/element-icons/settings/web.svg b/res/img/element-icons/settings/web.svg new file mode 100644 index 0000000000..95bd1ba24e --- /dev/null +++ b/res/img/element-icons/settings/web.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/img/feather-customised/cancel.svg b/res/img/feather-customised/cancel.svg deleted file mode 100644 index 6b734e4053..0000000000 --- a/res/img/feather-customised/cancel.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - Slice 1 - Created with Sketch. - - - - - \ No newline at end of file diff --git a/res/img/feather-customised/chevron-down-thin.svg b/res/img/feather-customised/chevron-down-thin.svg deleted file mode 100644 index 109c83def6..0000000000 --- a/res/img/feather-customised/chevron-down-thin.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/src/@types/common.ts b/src/@types/common.ts index b4d01a75a5..b18fefc253 100644 --- a/src/@types/common.ts +++ b/src/@types/common.ts @@ -49,3 +49,8 @@ export type KeysWithObjectShape = { ? (Input[P] extends Array ? never : P) : never; }[keyof Input]; + +export type KeysStartingWith = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + [P in keyof Input]: P extends `${Str}${infer _X}` ? P : never; // we don't use _X +}[keyof Input]; diff --git a/src/IConfigOptions.ts b/src/IConfigOptions.ts index b877cb90af..91391fc2a9 100644 --- a/src/IConfigOptions.ts +++ b/src/IConfigOptions.ts @@ -117,8 +117,10 @@ export interface IConfigOptions { obey_asserted_identity?: boolean; // MSC3086 }; element_call: { - url: string; - use_exclusively: boolean; + url?: string; + use_exclusively?: boolean; + participant_limit?: number; + brand?: string; }; logout_redirect_url?: string; @@ -179,9 +181,6 @@ export interface IConfigOptions { sync_timeline_limit?: number; dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option - - // XXX: Undocumented URL for the "Learn more about spaces" link in the "Communities don't exist" messaging. - spaces_learn_more_url?: string; } export interface ISsoRedirectOptions { diff --git a/src/Notifier.ts b/src/Notifier.ts index 8c7a8e4bed..64f4a6547f 100644 --- a/src/Notifier.ts +++ b/src/Notifier.ts @@ -26,6 +26,7 @@ import { M_LOCATION } from "matrix-js-sdk/src/@types/location"; import { PermissionChanged as PermissionChangedEvent, } from "@matrix-org/analytics-events/types/typescript/PermissionChanged"; +import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { MatrixClientPeg } from './MatrixClientPeg'; import { PosthogAnalytics } from "./PosthogAnalytics"; @@ -47,6 +48,10 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import LegacyCallHandler from "./LegacyCallHandler"; import VoipUserMapper from "./VoipUserMapper"; import { localNotificationsAreSilenced } from "./utils/notifications"; +import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast"; +import ToastStore from "./stores/ToastStore"; +import { ElementCall } from "./models/Call"; +import { createLocalNotificationSettingsIfNeeded } from './utils/notifications'; /* * Dispatches: @@ -348,17 +353,25 @@ export const Notifier = { return this.toolbarHidden; }, - onSyncStateChange: function(state: string) { - if (state === "SYNCING") { + onSyncStateChange: function(state: SyncState, prevState?: SyncState, data?: ISyncStateData) { + if (state === SyncState.Syncing) { this.isSyncing = true; - } else if (state === "STOPPED" || state === "ERROR") { + } else if (state === SyncState.Stopped || state === SyncState.Error) { this.isSyncing = false; } + + // wait for first non-cached sync to complete + if ( + ![SyncState.Stopped, SyncState.Error].includes(state) && + !data?.fromCache + ) { + createLocalNotificationSettingsIfNeeded(MatrixClientPeg.get()); + } }, onEvent: function(ev: MatrixEvent) { if (!this.isSyncing) return; // don't alert for any messages initially - if (ev.getSender() === MatrixClientPeg.get().credentials.userId) return; + if (ev.getSender() === MatrixClientPeg.get().getUserId()) return; MatrixClientPeg.get().decryptEventIfNeeded(ev); @@ -419,6 +432,8 @@ export const Notifier = { const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); if (actions?.notify) { + this._performCustomEventHandling(ev); + if (RoomViewStore.instance.getRoomId() === room.roomId && UserActivity.sharedInstance().userActiveRecently() && !Modal.hasDialogs() @@ -436,6 +451,24 @@ export const Notifier = { } } }, + + /** + * Some events require special handling such as showing in-app toasts + */ + _performCustomEventHandling: function(ev: MatrixEvent) { + if ( + ElementCall.CALL_EVENT_TYPE.names.includes(ev.getType()) + && SettingsStore.getValue("feature_group_calls") + ) { + ToastStore.sharedInstance().addOrReplaceToast({ + key: getIncomingCallToastKey(ev.getStateKey()), + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { callEvent: ev }, + }); + } + }, }; if (!window.mxNotifier) { diff --git a/src/PageTypes.ts b/src/PageTypes.ts index fb0424f6e0..1e181b4e3f 100644 --- a/src/PageTypes.ts +++ b/src/PageTypes.ts @@ -20,7 +20,6 @@ enum PageType { HomePage = "home_page", RoomView = "room_view", UserView = "user_view", - LegacyGroupView = "legacy_group_view", } export default PageType; diff --git a/src/PosthogTrackers.ts b/src/PosthogTrackers.ts index 0422f0bf9b..4814daa6f4 100644 --- a/src/PosthogTrackers.ts +++ b/src/PosthogTrackers.ts @@ -41,7 +41,6 @@ const loggedInPageTypeMap: Record = { [PageType.HomePage]: "Home", [PageType.RoomView]: "Room", [PageType.UserView]: "User", - [PageType.LegacyGroupView]: "Group", }; export default class PosthogTrackers { diff --git a/src/SdkConfig.ts b/src/SdkConfig.ts index 7a86982723..235ada7382 100644 --- a/src/SdkConfig.ts +++ b/src/SdkConfig.ts @@ -33,6 +33,8 @@ export const DEFAULTS: IConfigOptions = { element_call: { url: "https://call.element.io", use_exclusively: false, + participant_limit: 8, + brand: "Element Call", }, // @ts-ignore - we deliberately use the camelCase version here so we trigger @@ -44,7 +46,6 @@ export const DEFAULTS: IConfigOptions = { logo: require("../res/img/element-desktop-logo.svg").default, url: "https://element.io/get-started", }, - spaces_learn_more_url: "https://element.io/blog/spaces-blast-out-of-beta/", }; export default class SdkConfig { diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 6be8dee332..361edcb1e2 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -45,6 +45,7 @@ import AccessibleButton from './components/views/elements/AccessibleButton'; import RightPanelStore from './stores/right-panel/RightPanelStore'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; import { isLocationEvent } from './utils/EventUtils'; +import { ElementCall } from "./models/Call"; export function getSenderName(event: MatrixEvent): string { return event.sender?.name ?? event.getSender() ?? _t("Someone"); @@ -57,6 +58,15 @@ function getRoomMemberDisplayname(event: MatrixEvent, userId = event.getSender() return member?.name || member?.rawDisplayName || userId || _t("Someone"); } +function textForCallEvent(event: MatrixEvent): () => string { + const roomName = MatrixClientPeg.get().getRoom(event.getRoomId()!).name; + const isSupported = MatrixClientPeg.get().supportsVoip(); + + return isSupported + ? () => _t("Video call started in %(roomName)s.", { roomName }) + : () => _t("Video call started in %(roomName)s. (not supported by this browser)", { roomName }); +} + // These functions are frequently used just to check whether an event has // any text to display at all. For this reason they return deferred values // to avoid the expense of looking up translations when they're not needed. @@ -798,6 +808,11 @@ for (const evType of ALL_RULE_TYPES) { stateHandlers[evType] = textForMjolnirEvent; } +// Add both stable and unstable m.call events +for (const evType of ElementCall.CALL_EVENT_TYPE.names) { + stateHandlers[evType] = textForCallEvent; +} + /** * Determines whether the given event has text to display. * @param ev The event diff --git a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx index 2f2bc36ec1..e020dbeea1 100644 --- a/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx +++ b/src/async-components/views/dialogs/security/ExportE2eKeysDialog.tsx @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +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. @@ -15,7 +16,7 @@ limitations under the License. */ import FileSaver from 'file-saver'; -import React, { createRef } from 'react'; +import React from 'react'; import { MatrixClient } from 'matrix-js-sdk/src/client'; import { logger } from "matrix-js-sdk/src/logger"; @@ -23,6 +24,8 @@ import { _t } from '../../../../languageHandler'; import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryption'; import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Field from "../../../../components/views/elements/Field"; +import { KeysStartingWith } from "../../../../@types/common"; enum Phase { Edit = "edit", @@ -36,12 +39,14 @@ interface IProps extends IDialogProps { interface IState { phase: Phase; errStr: string; + passphrase1: string; + passphrase2: string; } +type AnyPassphrase = KeysStartingWith; + export default class ExportE2eKeysDialog extends React.Component { private unmounted = false; - private passphrase1 = createRef(); - private passphrase2 = createRef(); constructor(props: IProps) { super(props); @@ -49,6 +54,8 @@ export default class ExportE2eKeysDialog extends React.Component this.state = { phase: Phase.Edit, errStr: null, + passphrase1: "", + passphrase2: "", }; } @@ -59,8 +66,8 @@ export default class ExportE2eKeysDialog extends React.Component private onPassphraseFormSubmit = (ev: React.FormEvent): boolean => { ev.preventDefault(); - const passphrase = this.passphrase1.current.value; - if (passphrase !== this.passphrase2.current.value) { + const passphrase = this.state.passphrase1; + if (passphrase !== this.state.passphrase2) { this.setState({ errStr: _t('Passphrases must match') }); return false; } @@ -112,6 +119,12 @@ export default class ExportE2eKeysDialog extends React.Component return false; }; + private onPassphraseChange = (ev: React.ChangeEvent, phrase: AnyPassphrase) => { + this.setState({ + [phrase]: ev.target.value, + } as Pick); + }; + public render(): JSX.Element { const disableForm = (this.state.phase === Phase.Exporting); @@ -146,36 +159,25 @@ export default class ExportE2eKeysDialog extends React.Component
-
- -
-
- -
+ this.onPassphraseChange(e, "passphrase1")} + autoFocus={true} + size={64} + type="password" + disabled={disableForm} + />
-
- -
-
- -
+ this.onPassphraseChange(e, "passphrase2")} + size={64} + type="password" + disabled={disableForm} + />
diff --git a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx index 65bbe0a70e..7c710cf676 100644 --- a/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx +++ b/src/async-components/views/dialogs/security/ImportE2eKeysDialog.tsx @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +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. @@ -22,6 +23,7 @@ import * as MegolmExportEncryption from '../../../../utils/MegolmExportEncryptio import { _t } from '../../../../languageHandler'; import { IDialogProps } from "../../../../components/views/dialogs/IDialogProps"; import BaseDialog from "../../../../components/views/dialogs/BaseDialog"; +import Field from "../../../../components/views/elements/Field"; function readFileAsArrayBuffer(file: File): Promise { return new Promise((resolve, reject) => { @@ -48,12 +50,12 @@ interface IState { enableSubmit: boolean; phase: Phase; errStr: string; + passphrase: string; } export default class ImportE2eKeysDialog extends React.Component { private unmounted = false; private file = createRef(); - private passphrase = createRef(); constructor(props: IProps) { super(props); @@ -62,6 +64,7 @@ export default class ImportE2eKeysDialog extends React.Component enableSubmit: false, phase: Phase.Edit, errStr: null, + passphrase: "", }; } @@ -69,16 +72,22 @@ export default class ImportE2eKeysDialog extends React.Component this.unmounted = true; } - private onFormChange = (ev: React.FormEvent): void => { + private onFormChange = (): void => { const files = this.file.current.files || []; this.setState({ - enableSubmit: (this.passphrase.current.value !== "" && files.length > 0), + enableSubmit: (this.state.passphrase !== "" && files.length > 0), }); }; + private onPassphraseChange = (ev: React.ChangeEvent): void => { + this.setState({ passphrase: ev.target.value }); + this.onFormChange(); // update general form state too + }; + private onFormSubmit = (ev: React.FormEvent): boolean => { ev.preventDefault(); - this.startImport(this.file.current.files[0], this.passphrase.current.value); + // noinspection JSIgnoredPromiseFromCall + this.startImport(this.file.current.files[0], this.state.passphrase); return false; }; @@ -161,20 +170,14 @@ export default class ImportE2eKeysDialog extends React.Component
-
- -
-
- -
+
diff --git a/src/components/structures/LegacyGroupView.tsx b/src/components/structures/LegacyGroupView.tsx deleted file mode 100644 index 236c4f6ecd..0000000000 --- a/src/components/structures/LegacyGroupView.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* -Copyright 2020 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import * as React from "react"; - -import AutoHideScrollbar from './AutoHideScrollbar'; -import { _t } from "../../languageHandler"; -import SdkConfig, { DEFAULTS } from "../../SdkConfig"; - -interface IProps { - groupId: string; -} - -const LegacyGroupView: React.FC = ({ groupId }) => { - // XXX: Stealing classes from the HomePage component for CSS simplicity. - // XXX: Inline CSS because this is all temporary - const learnMoreUrl = SdkConfig.get().spaces_learn_more_url ?? DEFAULTS.spaces_learn_more_url; - return -
-

{ _t("That link is no longer supported") }

-

- { _t( - "You're trying to access a community link (%(groupId)s).
" + - "Communities are no longer supported and have been replaced by spaces." + - "Learn more about spaces here.", - { groupId }, - { - br: () =>
, - br2: () =>
, - a: (sub) => { sub }, - }, - ) } -

-
-
; -}; - -export default LegacyGroupView; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 6c8aad5f71..872c69c01d 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -67,7 +67,6 @@ import RightPanelStore from '../../stores/right-panel/RightPanelStore'; import { TimelineRenderingType } from "../../contexts/RoomContext"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { SwitchSpacePayload } from "../../dispatcher/payloads/SwitchSpacePayload"; -import LegacyGroupView from "./LegacyGroupView"; import { IConfigOptions } from "../../IConfigOptions"; import LeftPanelLiveShareWarning from '../views/beacon/LeftPanelLiveShareWarning'; import { UserOnboardingPage } from '../views/user-onboarding/UserOnboardingPage'; @@ -103,8 +102,6 @@ interface IProps { justRegistered?: boolean; roomJustCreatedOpts?: IOpts; forceTimeline?: boolean; // see props on MatrixChat - - currentGroupId?: string; } interface IState { @@ -641,10 +638,6 @@ class LoggedInView extends React.Component { case PageTypes.UserView: pageElement = ; break; - - case PageTypes.LegacyGroupView: - pageElement = ; - break; } const wrapperClasses = classNames({ diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index 1d24e1da08..6dd2820aa1 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -188,8 +188,6 @@ interface IState { currentRoomId?: string; // If we're trying to just view a user ID (i.e. /user URL), this is it currentUserId?: string; - // Group ID for legacy "communities don't exist" page - currentGroupId?: string; // this is persisted as mx_lhs_size, loaded in LoggedInView collapseLhs: boolean; // Parameters used in the registration dance with the IS @@ -679,9 +677,6 @@ export default class MatrixChat extends React.PureComponent { } break; } - case 'view_legacy_group': - this.viewLegacyGroup(payload.groupId); - break; case Action.ViewUserSettings: { const tabPayload = payload as OpenToTabPayload; Modal.createDialog(UserSettingsDialog, @@ -1023,16 +1018,6 @@ export default class MatrixChat extends React.PureComponent { }); } - private viewLegacyGroup(groupId: string) { - this.setStateForNewView({ - view: Views.LOGGED_IN, - currentRoomId: null, - currentGroupId: groupId, - }); - this.notifyNewScreen('group/' + groupId); - this.setPage(PageType.LegacyGroupView); - } - private async createRoom(defaultPublic = false, defaultName?: string, type?: RoomType) { const modal = Modal.createDialog(CreateRoomDialog, { type, @@ -1803,12 +1788,6 @@ export default class MatrixChat extends React.PureComponent { userId: userId, subAction: params.action, }); - } else if (screen.indexOf('group/') === 0) { - const groupId = screen.substring(6); - dis.dispatch({ - action: 'view_legacy_group', - groupId: groupId, - }); } else { logger.info("Ignoring showScreen for '%s'", screen); } diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 7af7b3e2a4..6425709ea7 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -121,6 +121,7 @@ import { LargeLoader } from './LargeLoader'; import { VoiceBroadcastInfoEventType } from '../../voice-broadcast'; import { isVideoRoom } from '../../utils/video-rooms'; import { CallStore, CallStoreEvent } from "../../stores/CallStore"; +import { Call } from "../../models/Call"; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -178,6 +179,7 @@ export interface IRoomState { searchHighlights?: string[]; searchInProgress?: boolean; callState?: CallState; + activeCall: Call | null; canPeek: boolean; canSelfRedact: boolean; showApps: boolean; @@ -303,6 +305,8 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement { excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} + viewingCall={false} + activeCall={null} />
@@ -353,6 +357,8 @@ function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement excludedRightPanelPhaseButtons={[]} showButtons={false} enableRoomOptionsMenu={false} + viewingCall={false} + activeCall={null} />
@@ -391,6 +397,7 @@ export class RoomView extends React.Component { numUnreadMessages: 0, searchResults: null, callState: null, + activeCall: null, canPeek: false, canSelfRedact: false, showApps: false, @@ -497,13 +504,6 @@ export class RoomView extends React.Component { if (WidgetLayoutStore.instance.hasMaximisedWidget(this.state.room)) { // Show chat in right panel when a widget is maximised RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }); - } else if ( - RightPanelStore.instance.isOpen && - RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) - ) { - // hide chat in right panel when the widget is minimized - RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); - RightPanelStore.instance.togglePanel(this.state.roomId); } this.checkWidgets(this.state.room); }; @@ -571,8 +571,22 @@ export class RoomView extends React.Component { mainSplitContentType: room === null ? undefined : this.getMainSplitContentType(room), initialEventId: null, // default to clearing this, will get set later in the method if needed showRightPanel: RightPanelStore.instance.isOpenForRoom(roomId), + activeCall: CallStore.instance.getActiveCall(roomId), }; + if ( + this.state.mainSplitContentType !== MainSplitContentType.Timeline + && newState.mainSplitContentType === MainSplitContentType.Timeline + && RightPanelStore.instance.isOpen + && RightPanelStore.instance.currentCard.phase === RightPanelPhases.Timeline + && RightPanelStore.instance.roomPhaseHistory.some(card => (card.phase === RightPanelPhases.Timeline)) + ) { + // We're returning to the main timeline, so hide the right panel timeline + RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); + RightPanelStore.instance.togglePanel(this.state.roomId ?? null); + newState.showRightPanel = false; + } + const initialEventId = RoomViewStore.instance.getInitialEventId(); if (initialEventId) { let initialEvent = room?.findEventById(initialEventId); @@ -701,7 +715,10 @@ export class RoomView extends React.Component { }; private onActiveCalls = () => { - if (this.state.roomId !== undefined && !CallStore.instance.hasActiveCall(this.state.roomId)) { + if (this.state.roomId === undefined) return; + const activeCall = CallStore.instance.getActiveCall(this.state.roomId); + + if (activeCall === null) { // We disconnected from the call, so stop viewing it dis.dispatch({ action: Action.ViewRoom, @@ -710,6 +727,8 @@ export class RoomView extends React.Component { metricsTrigger: undefined, }, true); // Synchronous so that CallView disappears immediately } + + this.setState({ activeCall }); }; private getRoomId = () => { @@ -2404,6 +2423,7 @@ export class RoomView extends React.Component { let onForgetClick = this.onForgetClick; let onSearchClick = this.onSearchClick; let onInviteClick = null; + let viewingCall = false; // Simplify the header for other main split types switch (this.state.mainSplitContentType) { @@ -2422,12 +2442,19 @@ export class RoomView extends React.Component { RightPanelPhases.PinnedMessages, RightPanelPhases.NotificationPanel, ]; + if (!isVideoRoom(this.state.room)) { + excludedRightPanelPhaseButtons.push(RightPanelPhases.RoomSummary); + if (this.state.activeCall === null) { + excludedRightPanelPhaseButtons.push(RightPanelPhases.Timeline); + } + } onAppsClick = null; onForgetClick = null; onSearchClick = null; if (this.state.room.canInvite(this.context.credentials.userId)) { onInviteClick = this.onInviteClick; } + viewingCall = true; } return ( @@ -2451,6 +2478,8 @@ export class RoomView extends React.Component { excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} showButtons={!this.viewsLocalRoom} enableRoomOptionsMenu={!this.viewsLocalRoom} + viewingCall={viewingCall} + activeCall={this.state.activeCall} />
diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index b33e3d3747..ecddd435b9 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -493,7 +493,6 @@ export class EmailIdentityAuthEntry extends ? _t("Resent!") : _t("Resend")} alignment={Alignment.Right} - tooltipClassName="mx_Tooltip_noMargin" onHideTooltip={this.state.requested ? () => this.setState({ requested: false }) : undefined} diff --git a/src/components/views/dialogs/RoomSettingsDialog.tsx b/src/components/views/dialogs/RoomSettingsDialog.tsx index ce8d24cd3f..d20aca98d9 100644 --- a/src/components/views/dialogs/RoomSettingsDialog.tsx +++ b/src/components/views/dialogs/RoomSettingsDialog.tsx @@ -32,8 +32,10 @@ import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; import BaseDialog from "./BaseDialog"; import { Action } from '../../../dispatcher/actions'; +import { VoipRoomSettingsTab } from "../settings/tabs/room/VoipRoomSettingsTab"; export const ROOM_GENERAL_TAB = "ROOM_GENERAL_TAB"; +export const ROOM_VOIP_TAB = "ROOM_VOIP_TAB"; export const ROOM_SECURITY_TAB = "ROOM_SECURITY_TAB"; export const ROOM_ROLES_TAB = "ROOM_ROLES_TAB"; export const ROOM_NOTIFICATIONS_TAB = "ROOM_NOTIFICATIONS_TAB"; @@ -96,6 +98,14 @@ export default class RoomSettingsDialog extends React.Component , "RoomSettingsGeneral", )); + if (SettingsStore.getValue("feature_group_calls")) { + tabs.push(new Tab( + ROOM_VOIP_TAB, + _td("Voice & Video"), + "mx_RoomSettingsDialog_voiceIcon", + , + )); + } tabs.push(new Tab( ROOM_SECURITY_TAB, _td("Security & Privacy"), diff --git a/src/components/views/elements/AccessibleTooltipButton.tsx b/src/components/views/elements/AccessibleTooltipButton.tsx index 0f52879cc8..8fd3a17096 100644 --- a/src/components/views/elements/AccessibleTooltipButton.tsx +++ b/src/components/views/elements/AccessibleTooltipButton.tsx @@ -21,7 +21,7 @@ import AccessibleButton from "./AccessibleButton"; import Tooltip, { Alignment } from './Tooltip'; interface IProps extends React.ComponentProps { - title: string; + title?: string; tooltip?: React.ReactNode; label?: string; tooltipClassName?: string; @@ -78,7 +78,7 @@ export default class AccessibleTooltipButton extends React.PureComponent { children } { this.props.label } diff --git a/src/components/views/elements/LabelledToggleSwitch.tsx b/src/components/views/elements/LabelledToggleSwitch.tsx index 90b419c735..eb251d1bd6 100644 --- a/src/components/views/elements/LabelledToggleSwitch.tsx +++ b/src/components/views/elements/LabelledToggleSwitch.tsx @@ -27,6 +27,8 @@ interface IProps { label: string; // The translated caption for the switch caption?: string; + // Tooltip to display + tooltip?: string; // Whether or not to disable the toggle switch disabled?: boolean; // True to put the toggle in front of the label @@ -53,7 +55,8 @@ export default class LabelledToggleSwitch extends React.PureComponent { checked={this.props.value} disabled={this.props.disabled} onChange={this.props.onChange} - aria-label={this.props.label} + title={this.props.label} + tooltip={this.props.tooltip} />; if (this.props.toggleInFront) { @@ -66,7 +69,7 @@ export default class LabelledToggleSwitch extends React.PureComponent { "mx_SettingsFlag_toggleInFront": this.props.toggleInFront, }); return ( -
+
{ firstPart } { secondPart }
diff --git a/src/components/views/elements/SettingsFlag.tsx b/src/components/views/elements/SettingsFlag.tsx index e369b29c18..76348342a9 100644 --- a/src/components/views/elements/SettingsFlag.tsx +++ b/src/components/views/elements/SettingsFlag.tsx @@ -114,7 +114,7 @@ export default class SettingsFlag extends React.Component { checked={this.state.value} onChange={this.onChange} disabled={this.props.disabled || !canChange} - aria-label={label} + title={label} />
); diff --git a/src/components/views/elements/ToggleSwitch.tsx b/src/components/views/elements/ToggleSwitch.tsx index f56633786a..6a95b5d9a0 100644 --- a/src/components/views/elements/ToggleSwitch.tsx +++ b/src/components/views/elements/ToggleSwitch.tsx @@ -18,21 +18,27 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import AccessibleButton from "./AccessibleButton"; +import AccessibleTooltipButton from "./AccessibleTooltipButton"; interface IProps { // Whether or not this toggle is in the 'on' position. checked: boolean; + // Title to use + title?: string; + // Whether or not the user can interact with the switch disabled?: boolean; + // Tooltip to show + tooltip?: string; + // Called when the checked state changes. First argument will be the new state. onChange(checked: boolean): void; } // Controlled Toggle Switch element, written with Accessibility in mind -export default ({ checked, disabled = false, onChange, ...props }: IProps) => { +export default ({ checked, disabled = false, title, tooltip, onChange, ...props }: IProps) => { const _onClick = () => { if (disabled) return; onChange(!checked); @@ -45,14 +51,16 @@ export default ({ checked, disabled = false, onChange, ...props }: IProps) => { }); return ( -
- + ); }; diff --git a/src/components/views/elements/Tooltip.tsx b/src/components/views/elements/Tooltip.tsx index 2741a69936..ab27f4f9d8 100644 --- a/src/components/views/elements/Tooltip.tsx +++ b/src/components/views/elements/Tooltip.tsx @@ -149,18 +149,24 @@ export default class Tooltip extends React.PureComponent { break; case Alignment.Top: style.top = baseTop - spacing; - style.left = horizontalCenter; - style.transform = "translate(-50%, -100%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))), -100%)`; break; case Alignment.Bottom: style.top = baseTop + parentBox.height + spacing; - style.left = horizontalCenter; - style.transform = "translate(-50%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`; break; case Alignment.InnerBottom: style.top = baseTop + parentBox.height - 50; - style.left = horizontalCenter; - style.transform = "translate(-50%)"; + // Attempt to center the tooltip on the element while clamping + // its horizontal translation to keep it on screen + // eslint-disable-next-line max-len + style.transform = `translate(max(10px, min(calc(${horizontalCenter}px - 50%), calc(100vw - 100% - 10px))))`; break; case Alignment.TopRight: style.top = baseTop - spacing; diff --git a/src/components/views/messages/CallEvent.tsx b/src/components/views/messages/CallEvent.tsx index 151adaa9f5..6680345bbc 100644 --- a/src/components/views/messages/CallEvent.tsx +++ b/src/components/views/messages/CallEvent.tsx @@ -20,7 +20,7 @@ import type { MatrixEvent } from "matrix-js-sdk/src/models/event"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { Call, ConnectionState } from "../../../models/Call"; import { _t } from "../../../languageHandler"; -import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall"; +import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; @@ -28,9 +28,9 @@ import type { ButtonEvent } from "../elements/AccessibleButton"; import MemberAvatar from "../avatars/MemberAvatar"; import { LiveContentSummary, LiveContentType } from "../rooms/LiveContentSummary"; import FacePile from "../elements/FacePile"; -import AccessibleButton from "../elements/AccessibleButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { CallDuration, CallDurationFromEvent } from "../voip/CallDuration"; +import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; const MAX_FACES = 8; @@ -39,6 +39,8 @@ interface ActiveCallEventProps { participants: Set; buttonText: string; buttonKind: string; + buttonTooltip?: string; + buttonDisabled?: boolean; onButtonClick: ((ev: ButtonEvent) => void) | null; } @@ -49,6 +51,8 @@ const ActiveCallEvent = forwardRef( participants, buttonText, buttonKind, + buttonDisabled, + buttonTooltip, onButtonClick, }, ref, @@ -80,14 +84,15 @@ const ActiveCallEvent = forwardRef(
- { buttonText } - +
; }, @@ -101,6 +106,7 @@ interface ActiveLoadedCallEventProps { const ActiveLoadedCallEvent = forwardRef(({ mxEvent, call }, ref) => { const connectionState = useConnectionState(call); const participants = useParticipants(call); + const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call); const connect = useCallback((ev: ButtonEvent) => { ev.preventDefault(); @@ -132,6 +138,8 @@ const ActiveLoadedCallEvent = forwardRef(({ mxE participants={participants} buttonText={buttonText} buttonKind={buttonKind} + buttonDisabled={Boolean(joinCallButtonDisabledTooltip)} + buttonTooltip={joinCallButtonDisabledTooltip} onButtonClick={onButtonClick} />; }); diff --git a/src/components/views/right_panel/HeaderButton.tsx b/src/components/views/right_panel/HeaderButton.tsx index d78dbb867d..3e8aef6586 100644 --- a/src/components/views/right_panel/HeaderButton.tsx +++ b/src/components/views/right_panel/HeaderButton.tsx @@ -23,6 +23,7 @@ import classNames from 'classnames'; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { ButtonEvent } from "../elements/AccessibleButton"; +import { Alignment } from "../elements/Tooltip"; interface IProps { // Whether this button is highlighted @@ -54,6 +55,7 @@ export default class HeaderButton extends React.Component { aria-selected={isHighlighted} role="tab" title={title} + alignment={Alignment.Bottom} className={classes} onClick={onClick} />; diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index d950177e06..262b8fc38d 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -282,7 +282,7 @@ export default class RoomHeaderButtons extends HeaderButtons { , diff --git a/src/components/views/rooms/AppsDrawer.tsx b/src/components/views/rooms/AppsDrawer.tsx index 601cc9ee34..15eb042877 100644 --- a/src/components/views/rooms/AppsDrawer.tsx +++ b/src/components/views/rooms/AppsDrawer.tsx @@ -31,7 +31,6 @@ import Resizer from "../../../resizer/resizer"; import PercentageDistributor from "../../../resizer/distributors/percentage"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { clamp, percentageOf, percentageWithin } from "../../../utils/numbers"; -import { useStateCallback } from "../../../hooks/useStateCallback"; import UIStore from "../../../stores/UIStore"; import { IApp } from "../../../stores/WidgetStore"; import { ActionPayload } from "../../../dispatcher/payloads"; @@ -330,13 +329,8 @@ const PersistentVResizer: React.FC = ({ defaultHeight = 280; } - const [height, setHeight] = useStateCallback(defaultHeight, newHeight => { - newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100; - WidgetLayoutStore.instance.setContainerHeight(room, Container.Top, newHeight); - }); - return { @@ -346,7 +340,15 @@ const PersistentVResizer: React.FC = ({ resizeNotifier.notifyTimelineHeightChanged(); }} onResizeStop={(e, dir, ref, d) => { - setHeight(height + d.height); + let newHeight = defaultHeight + d.height; + newHeight = percentageOf(newHeight, minHeight, maxHeight) * 100; + + WidgetLayoutStore.instance.setContainerHeight( + room, + Container.Top, + newHeight, + ); + resizeNotifier.stopResizing(); }} handleWrapperClass={handleWrapperClass} diff --git a/src/components/views/rooms/E2EIcon.tsx b/src/components/views/rooms/E2EIcon.tsx index 1a6db4606c..5750febe0e 100644 --- a/src/components/views/rooms/E2EIcon.tsx +++ b/src/components/views/rooms/E2EIcon.tsx @@ -20,7 +20,7 @@ import classNames from 'classnames'; import { _t, _td } from '../../../languageHandler'; import AccessibleButton from "../elements/AccessibleButton"; -import Tooltip from "../elements/Tooltip"; +import Tooltip, { Alignment } from "../elements/Tooltip"; import { E2EStatus } from "../../../utils/ShieldUtils"; export enum E2EState { @@ -49,10 +49,20 @@ interface IProps { size?: number; onClick?: () => void; hideTooltip?: boolean; + tooltipAlignment?: Alignment; bordered?: boolean; } -const E2EIcon: React.FC = ({ isUser, status, className, size, onClick, hideTooltip, bordered }) => { +const E2EIcon: React.FC = ({ + isUser, + status, + className, + size, + onClick, + hideTooltip, + tooltipAlignment, + bordered, +}) => { const [hover, setHover] = useState(false); const classes = classNames({ @@ -80,7 +90,7 @@ const E2EIcon: React.FC = ({ isUser, status, className, size, onClick, h let tip; if (hover && !hideTooltip) { - tip = ; + tip = ; } if (onClick) { diff --git a/src/components/views/rooms/LiveContentSummary.tsx b/src/components/views/rooms/LiveContentSummary.tsx index 95adf54f13..34ee825268 100644 --- a/src/components/views/rooms/LiveContentSummary.tsx +++ b/src/components/views/rooms/LiveContentSummary.tsx @@ -18,6 +18,8 @@ import React, { FC } from "react"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; +import { Call } from "../../../models/Call"; +import { useParticipants } from "../../../hooks/useCall"; export enum LiveContentType { Video, @@ -55,3 +57,18 @@ export const LiveContentSummary: FC = ({ type, text, active, participantC } ); + +interface LiveContentSummaryWithCallProps { + call: Call; +} + +export function LiveContentSummaryWithCall({ call }: LiveContentSummaryWithCallProps) { + const participants = useParticipants(call); + + return ; +} diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index 0d01e039c4..013a992698 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -24,7 +24,6 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call"; 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 { MatrixClientPeg } from '../../../MatrixClientPeg'; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { UserTab } from "../dialogs/UserTab"; @@ -32,7 +31,7 @@ import SettingsStore from "../../../settings/SettingsStore"; import RoomHeaderButtons from '../right_panel/RoomHeaderButtons'; import E2EIcon from './E2EIcon'; import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar"; -import { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import RoomTopic from "../elements/RoomTopic"; import RoomName from "../elements/RoomName"; @@ -53,18 +52,21 @@ 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 SdkConfig, { DEFAULTS } from "../../../SdkConfig"; import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import { useWidgets } from "../right_panel/RoomSummaryCard"; import { WidgetType } from "../../../widgets/WidgetType"; -import { useCall } from "../../../hooks/useCall"; +import { useCall, useLayout } from "../../../hooks/useCall"; import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; -import { ElementCall } from "../../../models/Call"; +import { Call, ElementCall, Layout } from "../../../models/Call"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList, + IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { CallDurationFromEvent } from "../voip/CallDuration"; +import { Alignment } from "../elements/Tooltip"; class DisabledWithReason { constructor(public readonly reason: string) { } @@ -107,6 +109,7 @@ const VoiceCallButton: FC = ({ room, busy, setBusy, behavi onClick={onClick} title={_t("Voice call")} tooltip={tooltip ?? _t("Voice call")} + alignment={Alignment.Bottom} disabled={disabled || busy} />; }; @@ -192,10 +195,11 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi let menu: JSX.Element | null = null; if (menuOpen) { const buttonRect = buttonRef.current!.getBoundingClientRect(); + const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; menu = - + ; } @@ -207,6 +211,7 @@ const VideoCallButton: FC = ({ room, busy, setBusy, behavi onClick={onClick} title={_t("Video call")} tooltip={tooltip ?? _t("Video call")} + alignment={Alignment.Bottom} disabled={disabled || busy} /> { menu } @@ -225,7 +230,9 @@ const CallButtons: FC = ({ room }) => { const groupCallsEnabled = useFeatureEnabled("feature_group_calls"); const videoRoomsEnabled = useFeatureEnabled("feature_video_rooms"); const isVideoRoom = useMemo(() => videoRoomsEnabled && calcIsVideoRoom(room), [videoRoomsEnabled, room]); - const useElementCallExclusively = useMemo(() => SdkConfig.get("element_call").use_exclusively, []); + const useElementCallExclusively = useMemo(() => { + return SdkConfig.get("element_call").use_exclusively ?? DEFAULTS.element_call.use_exclusively; + }, []); const hasLegacyCall = useEventEmitterState( LegacyCallHandler.instance, @@ -318,6 +325,72 @@ const CallButtons: FC = ({ room }) => { } }; +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 { searchTerm: string; searchScope: SearchScope; @@ -338,6 +411,8 @@ export interface IProps { excludedRightPanelPhaseButtons?: Array; showButtons?: boolean; enableRoomOptionsMenu?: boolean; + viewingCall: boolean; + activeCall: Call | null; } interface IState { @@ -356,6 +431,7 @@ export default class RoomHeader extends React.Component { static contextType = RoomContext; public context!: React.ContextType; + private readonly client = this.props.room.client; constructor(props: IProps, context: IState) { super(props, context); @@ -367,14 +443,12 @@ export default class RoomHeader extends React.Component { } public componentDidMount() { - const cli = MatrixClientPeg.get(); - cli.on(RoomStateEvent.Events, this.onRoomStateEvents); + this.client.on(RoomStateEvent.Events, this.onRoomStateEvents); RightPanelStore.instance.on(UPDATE_EVENT, this.onRightPanelStoreUpdate); } public componentWillUnmount() { - const cli = MatrixClientPeg.get(); - cli?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); + 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); @@ -401,7 +475,7 @@ export default class RoomHeader extends React.Component { this.forceUpdate(); }, 500, { leading: true, trailing: true }); - private onContextMenuOpenClick = (ev: React.MouseEvent) => { + private onContextMenuOpenClick = (ev: ButtonEvent) => { ev.preventDefault(); ev.stopPropagation(); const target = ev.target as HTMLButtonElement; @@ -412,56 +486,98 @@ export default class RoomHeader extends React.Component { this.setState({ contextMenuPosition: undefined }); }; - private renderButtons(): JSX.Element[] { - const buttons: JSX.Element[] = []; + private onHideCallClick = (ev: ButtonEvent) => { + ev.preventDefault(); + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: this.props.room.roomId, + view_call: false, + metricsTrigger: undefined, + }); + }; - if (this.props.inRoom && !this.context.tombstone) { - buttons.push(); + 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.onForgetClick) { - const forgetButton = ); + } + + if (!this.props.viewingCall && this.props.onForgetClick) { + startButtons.push(; - buttons.push(forgetButton); + />); } - if (this.props.onAppsClick) { - const appsButton = ; - buttons.push(appsButton); + />); } - if (this.props.onSearchClick && this.props.inRoom) { - const searchButton = ; - buttons.push(searchButton); + />); } - if (this.props.onInviteClick && this.props.inRoom) { - const inviteButton = ; - buttons.push(inviteButton); + />); } - return buttons; + 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) { @@ -480,7 +596,7 @@ export default class RoomHeader extends React.Component { let settingsHint = false; const members = this.props.room ? this.props.room.getJoinedMembers() : undefined; if (members) { - if (members.length === 1 && members[0].userId === MatrixClientPeg.get().credentials.userId) { + 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; @@ -505,6 +621,7 @@ export default class RoomHeader extends React.Component { onClick={this.onContextMenuOpenClick} isExpanded={!!this.state.contextMenuPosition} title={_t("Room options")} + alignment={Alignment.Bottom} > { roomName } { this.props.room &&
} @@ -519,6 +636,57 @@ export default class RoomHeader extends React.Component { } public render() { + 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; + + if (this.props.viewingCall && !isVideoRoom) { + return ( +
+
+
{ roomAvatar }
+ { icon } +
+ { _t("Video call") } +
+ { 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 @@ -543,29 +711,6 @@ export default class RoomHeader extends React.Component { className="mx_RoomHeader_topic" />; - let roomAvatar: JSX.Element | null = null; - if (this.props.room) { - roomAvatar = ; - } - - let buttons: JSX.Element | null = null; - if (this.props.showButtons) { - buttons = -
- { this.renderButtons() } -
- -
; - } - - const e2eIcon = this.props.e2eStatus ? : undefined; - - const isVideoRoom = SettingsStore.getValue("feature_video_rooms") && calcIsVideoRoom(this.props.room); const viewLabs = () => defaultDispatcher.dispatch({ action: Action.ViewUserSettings, initialTabId: UserTab.Labs, @@ -581,7 +726,7 @@ export default class RoomHeader extends React.Component { aria-owns={this.state.rightPanelOpen ? "mx_RightPanel" : undefined} >
{ roomAvatar }
-
{ e2eIcon }
+ { icon } { name } { searchStatus } { topicElement } diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index 4e80303aa0..219295d23d 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -89,7 +89,7 @@ export default class RoomTile extends React.PureComponent { selected: RoomViewStore.instance.getRoomId() === this.props.room.roomId, notificationsMenuPosition: null, generalMenuPosition: null, - call: CallStore.instance.get(this.props.room.roomId), + call: CallStore.instance.getCall(this.props.room.roomId), // generatePreview() will return nothing if the user has previews disabled messagePreview: "", }; @@ -159,7 +159,7 @@ export default class RoomTile extends React.PureComponent { // Recalculate the call for this room, since it could've changed between // construction and mounting - this.setState({ call: CallStore.instance.get(this.props.room.roomId) }); + this.setState({ call: CallStore.instance.getCall(this.props.room.roomId) }); } public componentWillUnmount() { diff --git a/src/components/views/settings/DevicesPanelEntry.tsx b/src/components/views/settings/DevicesPanelEntry.tsx index 0109c37b9b..aa152826bf 100644 --- a/src/components/views/settings/DevicesPanelEntry.tsx +++ b/src/components/views/settings/DevicesPanelEntry.tsx @@ -29,6 +29,7 @@ import VerificationRequestDialog from '../../views/dialogs/VerificationRequestDi import LogoutDialog from '../dialogs/LogoutDialog'; import DeviceTile from './devices/DeviceTile'; import SelectableDeviceTile from './devices/SelectableDeviceTile'; +import { DeviceType } from '../../../utils/device/parseUserAgent'; interface IProps { device: IMyDevice; @@ -153,9 +154,10 @@ export default class DevicesPanelEntry extends React.Component { ; - const deviceWithVerification = { + const extendedDevice = { ...this.props.device, isVerified: this.props.verified, + deviceType: DeviceType.Unknown, }; if (this.props.isOwnDevice) { @@ -163,7 +165,7 @@ export default class DevicesPanelEntry extends React.Component {
- + { buttons }
; @@ -171,7 +173,7 @@ export default class DevicesPanelEntry extends React.Component { return (
- + { buttons }
diff --git a/src/components/views/settings/devices/CurrentDeviceSection.tsx b/src/components/views/settings/devices/CurrentDeviceSection.tsx index 615c9c69f0..fc58617d31 100644 --- a/src/components/views/settings/devices/CurrentDeviceSection.tsx +++ b/src/components/views/settings/devices/CurrentDeviceSection.tsx @@ -24,10 +24,10 @@ import DeviceDetails from './DeviceDetails'; import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; import DeviceTile from './DeviceTile'; import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; -import { DeviceWithVerification } from './types'; +import { ExtendedDevice } from './types'; interface Props { - device?: DeviceWithVerification; + device?: ExtendedDevice; isLoading: boolean; isSigningOut: boolean; localNotificationSettings?: LocalNotificationSettings | undefined; diff --git a/src/components/views/settings/devices/DeviceDetailHeading.tsx b/src/components/views/settings/devices/DeviceDetailHeading.tsx index dea79d3b23..2673ef4e89 100644 --- a/src/components/views/settings/devices/DeviceDetailHeading.tsx +++ b/src/components/views/settings/devices/DeviceDetailHeading.tsx @@ -22,10 +22,10 @@ import Field from '../../elements/Field'; import Spinner from '../../elements/Spinner'; import { Caption } from '../../typography/Caption'; import Heading from '../../typography/Heading'; -import { DeviceWithVerification } from './types'; +import { ExtendedDevice } from './types'; interface Props { - device: DeviceWithVerification; + device: ExtendedDevice; saveDeviceName: (deviceName: string) => Promise; } diff --git a/src/components/views/settings/devices/DeviceDetails.tsx b/src/components/views/settings/devices/DeviceDetails.tsx index b87bfcef3c..4330798dca 100644 --- a/src/components/views/settings/devices/DeviceDetails.tsx +++ b/src/components/views/settings/devices/DeviceDetails.tsx @@ -62,6 +62,7 @@ const DeviceDetails: React.FC = ({ id: 'session', values: [ { label: _t('Session ID'), value: device.device_id }, + { label: _t('Client'), value: device.client }, { label: _t('Last activity'), value: device.last_seen_ts && formatDate(new Date(device.last_seen_ts)), @@ -72,8 +73,8 @@ const DeviceDetails: React.FC = ({ id: 'application', heading: _t('Application'), values: [ - { label: _t('Name'), value: device.clientName }, - { label: _t('Version'), value: device.clientVersion }, + { label: _t('Name'), value: device.appName }, + { label: _t('Version'), value: device.appVersion }, { label: _t('URL'), value: device.url }, ], }, @@ -81,6 +82,8 @@ const DeviceDetails: React.FC = ({ id: 'device', heading: _t('Device'), values: [ + { label: _t('Model'), value: device.deviceModel }, + { label: _t('Operating system'), value: device.deviceOperatingSystem }, { label: _t('IP address'), value: device.last_seen_ip }, ], }, @@ -150,7 +153,7 @@ const DeviceDetails: React.FC = ({ checked={isPushNotificationsEnabled(pusher, localNotificationSettings)} disabled={isCheckboxDisabled(pusher, localNotificationSettings)} onChange={checked => setPushNotifications?.(device.device_id, checked)} - aria-label={_t("Toggle push notifications on this session.")} + title={_t("Toggle push notifications on this session.")} data-testid='device-detail-push-notification-checkbox' />

diff --git a/src/components/views/settings/devices/DeviceTile.tsx b/src/components/views/settings/devices/DeviceTile.tsx index bfeabfabb3..4c8e264751 100644 --- a/src/components/views/settings/devices/DeviceTile.tsx +++ b/src/components/views/settings/devices/DeviceTile.tsx @@ -21,16 +21,16 @@ import { _t } from "../../../../languageHandler"; import { formatDate, formatRelativeTime } from "../../../../DateUtils"; import Heading from "../../typography/Heading"; import { INACTIVE_DEVICE_AGE_DAYS, isDeviceInactive } from "./filter"; -import { DeviceWithVerification } from "./types"; -import { DeviceType } from "./DeviceType"; +import { ExtendedDevice } from "./types"; +import { DeviceTypeIcon } from "./DeviceTypeIcon"; export interface DeviceTileProps { - device: DeviceWithVerification; + device: ExtendedDevice; isSelected?: boolean; children?: React.ReactNode; onClick?: () => void; } -const DeviceTileName: React.FC<{ device: DeviceWithVerification }> = ({ device }) => { +const DeviceTileName: React.FC<{ device: ExtendedDevice }> = ({ device }) => { return { device.display_name || device.device_id } ; @@ -48,7 +48,7 @@ const formatLastActivity = (timestamp: number, now = new Date().getTime()): stri return formatRelativeTime(new Date(timestamp)); }; -const getInactiveMetadata = (device: DeviceWithVerification): { id: string, value: React.ReactNode } | undefined => { +const getInactiveMetadata = (device: ExtendedDevice): { id: string, value: React.ReactNode } | undefined => { const isInactive = isDeviceInactive(device); if (!isInactive) { @@ -89,7 +89,11 @@ const DeviceTile: React.FC = ({ ]; return

- +
diff --git a/src/components/views/settings/devices/DeviceType.tsx b/src/components/views/settings/devices/DeviceType.tsx deleted file mode 100644 index a0fbe75c56..0000000000 --- a/src/components/views/settings/devices/DeviceType.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* -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 classNames from 'classnames'; - -import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/settings/unknown-device.svg'; -import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg'; -import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg'; -import { _t } from '../../../../languageHandler'; -import { DeviceWithVerification } from './types'; - -interface Props { - isVerified?: DeviceWithVerification['isVerified']; - isSelected?: boolean; -} - -export const DeviceType: React.FC = ({ isVerified, isSelected }) => ( -
- { /* TODO(kerrya) all devices have an unknown type until PSG-650 */ } - - { - isVerified - ? - : - } -
); - diff --git a/src/components/views/settings/devices/DeviceTypeIcon.tsx b/src/components/views/settings/devices/DeviceTypeIcon.tsx new file mode 100644 index 0000000000..5ae30485eb --- /dev/null +++ b/src/components/views/settings/devices/DeviceTypeIcon.tsx @@ -0,0 +1,83 @@ +/* +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 classNames from 'classnames'; + +import { Icon as UnknownDeviceIcon } from '../../../../../res/img/element-icons/settings/unknown-device.svg'; +import { Icon as DesktopIcon } from '../../../../../res/img/element-icons/settings/desktop.svg'; +import { Icon as WebIcon } from '../../../../../res/img/element-icons/settings/web.svg'; +import { Icon as MobileIcon } from '../../../../../res/img/element-icons/settings/mobile.svg'; +import { Icon as VerifiedIcon } from '../../../../../res/img/e2e/verified.svg'; +import { Icon as UnverifiedIcon } from '../../../../../res/img/e2e/warning.svg'; +import { _t } from '../../../../languageHandler'; +import { ExtendedDevice } from './types'; +import { DeviceType } from '../../../../utils/device/parseUserAgent'; + +interface Props { + isVerified?: ExtendedDevice['isVerified']; + isSelected?: boolean; + deviceType?: DeviceType; +} + +const deviceTypeIcon: Record>> = { + [DeviceType.Desktop]: DesktopIcon, + [DeviceType.Mobile]: MobileIcon, + [DeviceType.Web]: WebIcon, + [DeviceType.Unknown]: UnknownDeviceIcon, +}; +const deviceTypeLabel: Record = { + [DeviceType.Desktop]: _t('Desktop session'), + [DeviceType.Mobile]: _t('Mobile session'), + [DeviceType.Web]: _t('Web session'), + [DeviceType.Unknown]: _t('Unknown session type'), +}; + +export const DeviceTypeIcon: React.FC = ({ + isVerified, + isSelected, + deviceType, +}) => { + const Icon = deviceTypeIcon[deviceType] || deviceTypeIcon[DeviceType.Unknown]; + const label = deviceTypeLabel[deviceType] || deviceTypeLabel[DeviceType.Unknown]; + return ( +
+
+ +
+ { + isVerified + ? + : + } +
); +}; + diff --git a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx index 11e806e54e..127f5eedf6 100644 --- a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx +++ b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx @@ -21,11 +21,11 @@ import AccessibleButton from '../../elements/AccessibleButton'; import DeviceSecurityCard from './DeviceSecurityCard'; import { DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, } from './types'; interface Props { - device: DeviceWithVerification; + device: ExtendedDevice; onVerifyDevice?: () => void; } diff --git a/src/components/views/settings/devices/FilteredDeviceList.tsx b/src/components/views/settings/devices/FilteredDeviceList.tsx index 4cf7ac1a63..c2e8786052 100644 --- a/src/components/views/settings/devices/FilteredDeviceList.tsx +++ b/src/components/views/settings/devices/FilteredDeviceList.tsx @@ -33,7 +33,7 @@ import SelectableDeviceTile from './SelectableDeviceTile'; import { DevicesDictionary, DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, } from './types'; import { DevicesState } from './useOwnDevices'; import FilteredDeviceListHeader from './FilteredDeviceListHeader'; @@ -42,27 +42,27 @@ interface Props { devices: DevicesDictionary; pushers: IPusher[]; localNotificationSettings: Map; - expandedDeviceIds: DeviceWithVerification['device_id'][]; - signingOutDeviceIds: DeviceWithVerification['device_id'][]; - selectedDeviceIds: DeviceWithVerification['device_id'][]; + expandedDeviceIds: ExtendedDevice['device_id'][]; + signingOutDeviceIds: ExtendedDevice['device_id'][]; + selectedDeviceIds: ExtendedDevice['device_id'][]; filter?: DeviceSecurityVariation; onFilterChange: (filter: DeviceSecurityVariation | undefined) => void; - onDeviceExpandToggle: (deviceId: DeviceWithVerification['device_id']) => void; - onSignOutDevices: (deviceIds: DeviceWithVerification['device_id'][]) => void; + onDeviceExpandToggle: (deviceId: ExtendedDevice['device_id']) => void; + onSignOutDevices: (deviceIds: ExtendedDevice['device_id'][]) => void; saveDeviceName: DevicesState['saveDeviceName']; - onRequestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => void; + onRequestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => void; setPushNotifications: (deviceId: string, enabled: boolean) => Promise; - setSelectedDeviceIds: (deviceIds: DeviceWithVerification['device_id'][]) => void; + setSelectedDeviceIds: (deviceIds: ExtendedDevice['device_id'][]) => void; supportsMSC3881?: boolean | undefined; } const isDeviceSelected = ( - deviceId: DeviceWithVerification['device_id'], - selectedDeviceIds: DeviceWithVerification['device_id'][], + deviceId: ExtendedDevice['device_id'], + selectedDeviceIds: ExtendedDevice['device_id'][], ) => selectedDeviceIds.includes(deviceId); // devices without timestamp metadata should be sorted last -const sortDevicesByLatestActivity = (left: DeviceWithVerification, right: DeviceWithVerification) => +const sortDevicesByLatestActivity = (left: ExtendedDevice, right: ExtendedDevice) => (right.last_seen_ts || 0) - (left.last_seen_ts || 0); const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSecurityVariation) => @@ -149,7 +149,7 @@ const NoResults: React.FC = ({ filter, clearFilter }) =>
; const DeviceListItem: React.FC<{ - device: DeviceWithVerification; + device: ExtendedDevice; pusher?: IPusher | undefined; localNotificationSettings?: LocalNotificationSettings | undefined; isExpanded: boolean; @@ -227,11 +227,11 @@ export const FilteredDeviceList = }: Props, ref: ForwardedRef) => { const sortedDevices = getFilteredSortedDevices(devices, filter); - function getPusherForDevice(device: DeviceWithVerification): IPusher | undefined { + function getPusherForDevice(device: ExtendedDevice): IPusher | undefined { return pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === device.device_id); } - const toggleSelection = (deviceId: DeviceWithVerification['device_id']): void => { + const toggleSelection = (deviceId: ExtendedDevice['device_id']): void => { if (isDeviceSelected(deviceId, selectedDeviceIds)) { // remove from selection setSelectedDeviceIds(selectedDeviceIds.filter(id => id !== deviceId)); diff --git a/src/components/views/settings/devices/SecurityRecommendations.tsx b/src/components/views/settings/devices/SecurityRecommendations.tsx index 3132eba38a..ddeb2f2e2e 100644 --- a/src/components/views/settings/devices/SecurityRecommendations.tsx +++ b/src/components/views/settings/devices/SecurityRecommendations.tsx @@ -23,13 +23,13 @@ import DeviceSecurityCard from './DeviceSecurityCard'; import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter'; import { DeviceSecurityVariation, - DeviceWithVerification, + ExtendedDevice, DevicesDictionary, } from './types'; interface Props { devices: DevicesDictionary; - currentDeviceId: DeviceWithVerification['device_id']; + currentDeviceId: ExtendedDevice['device_id']; goToFilteredList: (filter: DeviceSecurityVariation) => void; } @@ -38,7 +38,7 @@ const SecurityRecommendations: React.FC = ({ currentDeviceId, goToFilteredList, }) => { - const devicesArray = Object.values(devices); + const devicesArray = Object.values(devices); const unverifiedDevicesCount = filterDevicesBySecurityRecommendation( devicesArray, diff --git a/src/components/views/settings/devices/filter.ts b/src/components/views/settings/devices/filter.ts index ad2bc92152..05ceb9c697 100644 --- a/src/components/views/settings/devices/filter.ts +++ b/src/components/views/settings/devices/filter.ts @@ -14,9 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { DeviceWithVerification, DeviceSecurityVariation } from "./types"; +import { ExtendedDevice, DeviceSecurityVariation } from "./types"; -type DeviceFilterCondition = (device: DeviceWithVerification) => boolean; +type DeviceFilterCondition = (device: ExtendedDevice) => boolean; const MS_DAY = 24 * 60 * 60 * 1000; export const INACTIVE_DEVICE_AGE_MS = 7.776e+9; // 90 days @@ -32,7 +32,7 @@ const filters: Record = { }; export const filterDevicesBySecurityRecommendation = ( - devices: DeviceWithVerification[], + devices: ExtendedDevice[], securityVariations: DeviceSecurityVariation[], ) => { const activeFilters = securityVariations.map(variation => filters[variation]); diff --git a/src/components/views/settings/devices/types.ts b/src/components/views/settings/devices/types.ts index 9543ac2b32..3fa125a09f 100644 --- a/src/components/views/settings/devices/types.ts +++ b/src/components/views/settings/devices/types.ts @@ -16,14 +16,17 @@ limitations under the License. import { IMyDevice } from "matrix-js-sdk/src/matrix"; +import { ExtendedDeviceInformation } from "../../../../utils/device/parseUserAgent"; + export type DeviceWithVerification = IMyDevice & { isVerified: boolean | null }; -export type ExtendedDeviceInfo = { - clientName?: string; - clientVersion?: string; +export type ExtendedDeviceAppInfo = { + // eg Element Web + appName?: string; + appVersion?: string; url?: string; }; -export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceInfo; -export type DevicesDictionary = Record; +export type ExtendedDevice = DeviceWithVerification & ExtendedDeviceAppInfo & ExtendedDeviceInformation; +export type DevicesDictionary = Record; export enum DeviceSecurityVariation { Verified = 'Verified', diff --git a/src/components/views/settings/devices/useOwnDevices.ts b/src/components/views/settings/devices/useOwnDevices.ts index 2441a63a2b..c3b8cb0212 100644 --- a/src/components/views/settings/devices/useOwnDevices.ts +++ b/src/components/views/settings/devices/useOwnDevices.ts @@ -24,6 +24,7 @@ import { MatrixEvent, PUSHER_DEVICE_ID, PUSHER_ENABLED, + UNSTABLE_MSC3852_LAST_SEEN_UA, } from "matrix-js-sdk/src/matrix"; import { CrossSigningInfo } from "matrix-js-sdk/src/crypto/CrossSigning"; import { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; @@ -34,8 +35,9 @@ import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifi import MatrixClientContext from "../../../../contexts/MatrixClientContext"; import { _t } from "../../../../languageHandler"; import { getDeviceClientInformation } from "../../../../utils/device/clientInformation"; -import { DevicesDictionary, DeviceWithVerification, ExtendedDeviceInfo } from "./types"; +import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types"; import { useEventEmitter } from "../../../../hooks/useEventEmitter"; +import { parseUserAgent } from "../../../../utils/device/parseUserAgent"; const isDeviceVerified = ( matrixClient: MatrixClient, @@ -63,12 +65,12 @@ const isDeviceVerified = ( } }; -const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceInfo => { +const parseDeviceExtendedInformation = (matrixClient: MatrixClient, device: IMyDevice): ExtendedDeviceAppInfo => { const { name, version, url } = getDeviceClientInformation(matrixClient, device.device_id); return { - clientName: name, - clientVersion: version, + appName: name, + appVersion: version, url, }; }; @@ -87,6 +89,7 @@ const fetchDevicesWithVerification = async ( ...device, isVerified: isDeviceVerified(matrixClient, crossSigningInfo, device), ...parseDeviceExtendedInformation(matrixClient, device), + ...parseUserAgent(device[UNSTABLE_MSC3852_LAST_SEEN_UA.name]), }, }), {}); @@ -104,10 +107,10 @@ export type DevicesState = { currentDeviceId: string; isLoadingDeviceList: boolean; // not provided when current session cannot request verification - requestDeviceVerification?: (deviceId: DeviceWithVerification['device_id']) => Promise; + requestDeviceVerification?: (deviceId: ExtendedDevice['device_id']) => Promise; refreshDevices: () => Promise; - saveDeviceName: (deviceId: DeviceWithVerification['device_id'], deviceName: string) => Promise; - setPushNotifications: (deviceId: DeviceWithVerification['device_id'], enabled: boolean) => Promise; + saveDeviceName: (deviceId: ExtendedDevice['device_id'], deviceName: string) => Promise; + setPushNotifications: (deviceId: ExtendedDevice['device_id'], enabled: boolean) => Promise; error?: OwnDevicesError; supportsMSC3881?: boolean | undefined; }; @@ -189,7 +192,7 @@ export const useOwnDevices = (): DevicesState => { const isCurrentDeviceVerified = !!devices[currentDeviceId]?.isVerified; const requestDeviceVerification = isCurrentDeviceVerified && userId - ? async (deviceId: DeviceWithVerification['device_id']) => { + ? async (deviceId: ExtendedDevice['device_id']) => { return await matrixClient.requestVerification( userId, [deviceId], @@ -198,7 +201,7 @@ export const useOwnDevices = (): DevicesState => { : undefined; const saveDeviceName = useCallback( - async (deviceId: DeviceWithVerification['device_id'], deviceName: string): Promise => { + async (deviceId: ExtendedDevice['device_id'], deviceName: string): Promise => { const device = devices[deviceId]; // no change @@ -219,7 +222,7 @@ export const useOwnDevices = (): DevicesState => { }, [matrixClient, devices, refreshDevices]); const setPushNotifications = useCallback( - async (deviceId: DeviceWithVerification['device_id'], enabled: boolean): Promise => { + async (deviceId: ExtendedDevice['device_id'], enabled: boolean): Promise => { try { const pusher = pushers.find(pusher => pusher[PUSHER_DEVICE_ID.name] === deviceId); if (pusher) { diff --git a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx index bab6904243..5da3996154 100644 --- a/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx +++ b/src/components/views/settings/tabs/room/RolesRoomSettingsTab.tsx @@ -31,6 +31,8 @@ import PowerSelector from "../../../elements/PowerSelector"; import SettingsFieldset from '../../SettingsFieldset'; import SettingsStore from "../../../../../settings/SettingsStore"; import { VoiceBroadcastInfoEventType } from '../../../../../voice-broadcast'; +import { ElementCall } from "../../../../../models/Call"; +import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; interface IEventShowOpts { isState?: boolean; @@ -60,6 +62,10 @@ const plEventsToShow: Record = { [EventType.Reaction]: { isState: false, hideForSpace: true }, [EventType.RoomRedaction]: { isState: false, hideForSpace: true }, + // MSC3401: Native Group VoIP signaling + [ElementCall.CALL_EVENT_TYPE.name]: { isState: true, hideForSpace: true }, + [ElementCall.MEMBER_EVENT_TYPE.name]: { isState: true, hideForSpace: true }, + // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) "im.vector.modular.widgets": { isState: true, hideForSpace: true }, [VoiceBroadcastInfoEventType]: { isState: true, hideForSpace: true }, @@ -252,6 +258,11 @@ export default class RolesRoomSettingsTab extends React.Component { if (SettingsStore.getValue("feature_pinning")) { plEventsToLabels[EventType.RoomPinnedEvents] = _td("Manage pinned events"); } + // MSC3401: Native Group VoIP signaling + if (SettingsStore.getValue("feature_group_calls")) { + plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("Start %(brand)s calls"); + plEventsToLabels[ElementCall.MEMBER_EVENT_TYPE.name] = _td("Join %(brand)s calls"); + } const powerLevelDescriptors: Record = { "users_default": { @@ -435,7 +446,8 @@ export default class RolesRoomSettingsTab extends React.Component { let label = plEventsToLabels[eventType]; if (label) { - label = _t(label); + const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; + label = _t(label, { brand }); } else { label = _t("Send %(eventType)s events", { eventType }); } diff --git a/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx new file mode 100644 index 0000000000..b8dca6205c --- /dev/null +++ b/src/components/views/settings/tabs/room/VoipRoomSettingsTab.tsx @@ -0,0 +1,99 @@ +/* +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, { useCallback, useMemo, useState } from 'react'; +import { JoinRule } from "matrix-js-sdk/src/@types/partials"; +import { EventType } from "matrix-js-sdk/src/@types/event"; + +import { _t } from "../../../../../languageHandler"; +import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; +import LabelledToggleSwitch from "../../../elements/LabelledToggleSwitch"; +import SettingsSubsection from "../../shared/SettingsSubsection"; +import SettingsTab from "../SettingsTab"; +import { ElementCall } from "../../../../../models/Call"; +import { useRoomState } from "../../../../../hooks/useRoomState"; +import SdkConfig, { DEFAULTS } from "../../../../../SdkConfig"; + +interface ElementCallSwitchProps { + roomId: string; +} + +const ElementCallSwitch: React.FC = ({ roomId }) => { + const room = useMemo(() => MatrixClientPeg.get().getRoom(roomId), [roomId]); + const isPublic = useMemo(() => room.getJoinRule() === JoinRule.Public, [room]); + const [content, events, maySend] = useRoomState(room, useCallback((state) => { + const content = state?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent(); + return [ + content ?? {}, + content?.["events"] ?? {}, + state?.maySendStateEvent(EventType.RoomPowerLevels, MatrixClientPeg.get().getUserId()), + ]; + }, [])); + + const [elementCallEnabled, setElementCallEnabled] = useState(() => { + return events[ElementCall.MEMBER_EVENT_TYPE.name] === 0; + }); + + const onChange = useCallback((enabled: boolean): void => { + setElementCallEnabled(enabled); + + if (enabled) { + const userLevel = events[EventType.RoomMessage] ?? content.users_default ?? 0; + const moderatorLevel = content.kick ?? 50; + + events[ElementCall.CALL_EVENT_TYPE.name] = isPublic ? moderatorLevel : userLevel; + events[ElementCall.MEMBER_EVENT_TYPE.name] = userLevel; + } else { + const adminLevel = events[EventType.RoomPowerLevels] ?? content.state_default ?? 100; + + events[ElementCall.CALL_EVENT_TYPE.name] = adminLevel; + events[ElementCall.MEMBER_EVENT_TYPE.name] = adminLevel; + } + + MatrixClientPeg.get().sendStateEvent(roomId, EventType.RoomPowerLevels, { + "events": events, + ...content, + }); + }, [roomId, content, events, isPublic]); + + const brand = SdkConfig.get("element_call").brand ?? DEFAULTS.element_call.brand; + + return ; +}; + +interface Props { + roomId: string; +} + +export const VoipRoomSettingsTab: React.FC = ({ roomId }) => { + return + + + + ; +}; diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 1519e39a0d..4e19924857 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -214,7 +214,10 @@ export default class PreferencesUserSettingsTab extends React.Component Promise, ): { onSignOutCurrentDevice: () => void; - onSignOutOtherDevices: (deviceIds: DeviceWithVerification['device_id'][]) => Promise; - signingOutDeviceIds: DeviceWithVerification['device_id'][]; + onSignOutOtherDevices: (deviceIds: ExtendedDevice['device_id'][]) => Promise; + signingOutDeviceIds: ExtendedDevice['device_id'][]; } => { - const [signingOutDeviceIds, setSigningOutDeviceIds] = useState([]); + const [signingOutDeviceIds, setSigningOutDeviceIds] = useState([]); const onSignOutCurrentDevice = () => { Modal.createDialog( @@ -53,7 +53,7 @@ const useSignOut = ( ); }; - const onSignOutOtherDevices = async (deviceIds: DeviceWithVerification['device_id'][]) => { + const onSignOutOtherDevices = async (deviceIds: ExtendedDevice['device_id'][]) => { if (!deviceIds.length) { return; } @@ -96,8 +96,8 @@ const SessionManagerTab: React.FC = () => { supportsMSC3881, } = useOwnDevices(); const [filter, setFilter] = useState(); - const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); - const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); + const [expandedDeviceIds, setExpandedDeviceIds] = useState([]); + const [selectedDeviceIds, setSelectedDeviceIds] = useState([]); const filteredDeviceListRef = useRef(null); const scrollIntoViewTimeoutRef = useRef>(); @@ -105,7 +105,7 @@ const SessionManagerTab: React.FC = () => { const userId = matrixClient.getUserId(); const currentUserMember = userId && matrixClient.getUser(userId) || undefined; - const onDeviceExpandToggle = (deviceId: DeviceWithVerification['device_id']): void => { + const onDeviceExpandToggle = (deviceId: ExtendedDevice['device_id']): void => { if (expandedDeviceIds.includes(deviceId)) { setExpandedDeviceIds(expandedDeviceIds.filter(id => id !== deviceId)); } else { @@ -136,7 +136,7 @@ const SessionManagerTab: React.FC = () => { ); }; - const onTriggerDeviceVerification = useCallback((deviceId: DeviceWithVerification['device_id']) => { + const onTriggerDeviceVerification = useCallback((deviceId: ExtendedDevice['device_id']) => { if (!requestDeviceVerification) { return; } diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx index fbab581e0f..65439e56c9 100644 --- a/src/components/views/voip/CallView.tsx +++ b/src/components/views/voip/CallView.tsx @@ -22,7 +22,7 @@ import { defer, IDeferred } from "matrix-js-sdk/src/utils"; import type { Room } from "matrix-js-sdk/src/models/room"; import type { ConnectionState } from "../../../models/Call"; import { Call, CallEvent, ElementCall, isConnected } from "../../../models/Call"; -import { useCall, useConnectionState, useParticipants } from "../../../hooks/useCall"; +import { useCall, useConnectionState, useJoinCallButtonDisabledTooltip, useParticipants } from "../../../hooks/useCall"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; @@ -35,7 +35,7 @@ import IconizedContextMenu, { } from "../context_menus/IconizedContextMenu"; import { aboveLeftOf, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import { Alignment } from "../elements/Tooltip"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import FacePile from "../elements/FacePile"; import MemberAvatar from "../avatars/MemberAvatar"; @@ -110,10 +110,11 @@ const MAX_FACES = 8; interface LobbyProps { room: Room; connect: () => Promise; + joinCallButtonDisabledTooltip?: string; children?: ReactNode; } -export const Lobby: FC = ({ room, connect, children }) => { +export const Lobby: FC = ({ room, joinCallButtonDisabledTooltip, connect, children }) => { const [connecting, setConnecting] = useState(false); const me = useMemo(() => room.getMember(room.myUserId)!, [room]); const videoRef = useRef(null); @@ -233,14 +234,15 @@ export const Lobby: FC = ({ room, connect, children }) => { />
- - { _t("Join") } - + title={_t("Join")} + label={_t("Join")} + tooltip={connecting ? _t("Connecting") : joinCallButtonDisabledTooltip} + />
; }; @@ -321,6 +323,7 @@ const JoinCallView: FC = ({ room, resizing, call }) => { const cli = useContext(MatrixClientContext); const connected = isConnected(useConnectionState(call)); const participants = useParticipants(call); + const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call); const connect = useCallback(async () => { // Disconnect from any other active calls first, since we don't yet support holding @@ -344,7 +347,13 @@ const JoinCallView: FC = ({ room, resizing, call }) => {
; } - lobby = { facePile }; + lobby = + { facePile } + ; } return
diff --git a/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx b/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx index 6881be9cb6..e7bc1c4739 100644 --- a/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx +++ b/src/components/views/voip/LegacyCallView/LegacyCallViewHeader.tsx @@ -32,7 +32,7 @@ const LegacyCallViewHeaderControls: React.FC = ({ onExp { onMaximize && } { onPin && { }; return ( -
{ onStartMoving: this.onStartMoving, onResize: this.onResize, }) } -
+ ); } } diff --git a/src/components/views/voip/PipView.tsx b/src/components/views/voip/PipView.tsx index 691f422e5b..c6ce0da159 100644 --- a/src/components/views/voip/PipView.tsx +++ b/src/components/views/voip/PipView.tsx @@ -24,7 +24,6 @@ import LegacyCallView from "./LegacyCallView"; import { RoomViewStore } from '../../../stores/RoomViewStore'; import LegacyCallHandler, { LegacyCallHandlerEvent } from '../../../LegacyCallHandler'; import PersistentApp from "../elements/PersistentApp"; -import SettingsStore from "../../../settings/SettingsStore"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import PictureInPictureDragger from './PictureInPictureDragger'; import dis from '../../../dispatcher/dispatcher'; @@ -35,6 +34,7 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from '../../../stores/Activ import WidgetStore, { IApp } from "../../../stores/WidgetStore"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { UPDATE_EVENT } from '../../../stores/AsyncStore'; +import { CallStore } from "../../../stores/CallStore"; const SHOW_CALL_IN_STATES = [ CallState.Connected, @@ -116,7 +116,6 @@ function getPrimarySecondaryCallsForPip(roomId: string): [MatrixCall, MatrixCall */ export default class PipView extends React.Component { - private settingsWatcherRef: string; private movePersistedElement = createRef<() => void>(); constructor(props: IProps) { @@ -157,7 +156,6 @@ export default class PipView extends React.Component { LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls); MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); - SettingsStore.unwatchSetting(this.settingsWatcherRef); const room = MatrixClientPeg.get().getRoom(this.state.viewedRoomId); if (room) { WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); @@ -278,6 +276,14 @@ export default class PipView extends React.Component { }); }; + private onViewCall = (): void => + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.state.persistentRoomId, + view_call: true, + metricsTrigger: undefined, + }); + // Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId public updateShowWidgetInPip( persistentWidgetId = this.state.persistentWidgetId, @@ -323,18 +329,19 @@ export default class PipView extends React.Component { mx_LegacyCallView_large: !pipMode, }); const roomId = this.state.persistentRoomId; - const roomForWidget = MatrixClientPeg.get().getRoom(roomId); + const roomForWidget = MatrixClientPeg.get().getRoom(roomId)!; const viewingCallRoom = this.state.viewedRoomId === roomId; + const isCall = CallStore.instance.getActiveCall(roomId) !== null; - pipContent = ({ onStartMoving, _onResize }) => + pipContent = ({ onStartMoving }) =>
{ onStartMoving(event); this.onStartMoving.bind(this)(); }} pipMode={pipMode} callRooms={[roomForWidget]} - onExpand={!viewingCallRoom && this.onExpand} - onPin={viewingCallRoom && this.onPin} - onMaximize={viewingCallRoom && this.onMaximize} + onExpand={!isCall && !viewingCallRoom ? this.onExpand : undefined} + onPin={!isCall && viewingCallRoom ? this.onPin : undefined} + onMaximize={isCall ? this.onViewCall : viewingCallRoom ? this.onMaximize : undefined} /> ({ threadId: undefined, liveTimeline: undefined, narrow: false, + activeCall: null, }); RoomContext.displayName = "RoomContext"; export default RoomContext; diff --git a/src/createRoom.ts b/src/createRoom.ts index 19d92164a0..88e3f8ef9f 100644 --- a/src/createRoom.ts +++ b/src/createRoom.ts @@ -46,6 +46,7 @@ import { findDMForUser } from "./utils/dm/findDMForUser"; import { privateShouldBeEncrypted } from "./utils/rooms"; import { waitForMember } from "./utils/membership"; import { PreferredRoomVersions } from "./utils/PreferredRoomVersions"; +import SettingsStore from "./settings/SettingsStore"; // we define a number of interfaces which take their names from the js-sdk /* eslint-disable camelcase */ @@ -168,6 +169,16 @@ export default async function createRoom(opts: IOpts): Promise { }, }; } + } else if (SettingsStore.getValue("feature_group_calls")) { + createOpts.power_level_content_override = { + events: { + ...DEFAULT_EVENT_POWER_LEVELS, + // Element Call should be disabled by default + [ElementCall.MEMBER_EVENT_TYPE.name]: 100, + // Make sure only admins can enable it + [ElementCall.CALL_EVENT_TYPE.name]: 100, + }, + }; } // By default, view the room after creating it diff --git a/src/group_helpers.tsx b/src/group_helpers.tsx deleted file mode 100644 index 9e5a96aa19..0000000000 --- a/src/group_helpers.tsx +++ /dev/null @@ -1,45 +0,0 @@ -/* -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 * as React from "react"; - -import Modal from "./Modal"; -import QuestionDialog from "./components/views/dialogs/QuestionDialog"; -import { _t } from "./languageHandler"; -import SdkConfig, { DEFAULTS } from "./SdkConfig"; - -export function showGroupReplacedWithSpacesDialog(groupId: string) { - const learnMoreUrl = SdkConfig.get().spaces_learn_more_url ?? DEFAULTS.spaces_learn_more_url; - Modal.createDialog(QuestionDialog, { - title: _t("That link is no longer supported"), - description: <> -

- { _t( - "You're trying to access a community link (%(groupId)s).
" + - "Communities are no longer supported and have been replaced by spaces." + - "Learn more about spaces here.", - { groupId }, - { - br: () =>
, - br2: () =>
, - a: (sub) => { sub }, - }, - ) } -

- , - hasCancelButton: false, - }); -} diff --git a/src/hooks/useCall.ts b/src/hooks/useCall.ts index 6a32ee1894..178514f262 100644 --- a/src/hooks/useCall.ts +++ b/src/hooks/useCall.ts @@ -17,14 +17,16 @@ limitations under the License. import { useState, useCallback } from "react"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; -import type { Call, ConnectionState } from "../models/Call"; +import { Call, ConnectionState, ElementCall, Layout } from "../models/Call"; import { useTypedEventEmitterState } from "./useEventEmitter"; import { CallEvent } from "../models/Call"; import { CallStore, CallStoreEvent } from "../stores/CallStore"; import { useEventEmitter } from "./useEventEmitter"; +import SdkConfig, { DEFAULTS } from "../SdkConfig"; +import { _t } from "../languageHandler"; export const useCall = (roomId: string): Call | null => { - const [call, setCall] = useState(() => CallStore.instance.get(roomId)); + const [call, setCall] = useState(() => CallStore.instance.getCall(roomId)); useEventEmitter(CallStore.instance, CallStoreEvent.Call, (call: Call | null, forRoomId: string) => { if (forRoomId === roomId) setCall(call); }); @@ -44,3 +46,28 @@ export const useParticipants = (call: Call): Set => CallEvent.Participants, useCallback(state => state ?? call.participants, [call]), ); + +export const useFull = (call: Call): boolean => { + const participants = useParticipants(call); + + return ( + participants.size + >= (SdkConfig.get("element_call").participant_limit ?? DEFAULTS.element_call.participant_limit) + ); +}; + +export const useJoinCallButtonDisabledTooltip = (call: Call): string | null => { + const isFull = useFull(call); + const state = useConnectionState(call); + + if (state === ConnectionState.Connecting) return _t("Connecting"); + if (isFull) return _t("Sorry — this call is currently full"); + return null; +}; + +export const useLayout = (call: ElementCall): Layout => + useTypedEventEmitterState( + call, + CallEvent.Layout, + useCallback(state => state ?? call.layout, [call]), + ); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 34be752afc..737f3e8879 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -52,8 +52,6 @@ "%(value)sh": "%(value)sh", "%(value)sm": "%(value)sm", "%(value)ss": "%(value)ss", - "That link is no longer supported": "That link is no longer supported", - "You're trying to access a community link (%(groupId)s).
Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.": "You're trying to access a community link (%(groupId)s).
Communities are no longer supported and have been replaced by spaces.Learn more about spaces here.", "Identity server has no terms of service": "Identity server has no terms of service", "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.": "This action requires accessing the default identity server to validate an email address or phone number, but the server does not have any terms of service.", "Only continue if you trust the owner of the server.": "Only continue if you trust the owner of the server.", @@ -472,6 +470,8 @@ "Converts the DM to a room": "Converts the DM to a room", "Displays action": "Displays action", "Someone": "Someone", + "Video call started in %(roomName)s.": "Video call started in %(roomName)s.", + "Video call started in %(roomName)s. (not supported by this browser)": "Video call started in %(roomName)s. (not supported by this browser)", "%(senderName)s placed a voice call.": "%(senderName)s placed a voice call.", "%(senderName)s placed a voice call. (not supported by this browser)": "%(senderName)s placed a voice call. (not supported by this browser)", "%(senderName)s placed a video call.": "%(senderName)s placed a video call.", @@ -797,6 +797,11 @@ "Don't miss a reply": "Don't miss a reply", "Notifications": "Notifications", "Enable desktop notifications": "Enable desktop notifications", + "Join": "Join", + "Unknown room": "Unknown room", + "Video call started": "Video call started", + "Video": "Video", + "Close": "Close", "Unknown caller": "Unknown caller", "Voice call": "Voice call", "Video call": "Video call", @@ -1010,6 +1015,8 @@ "When rooms are upgraded": "When rooms are upgraded", "My Ban List": "My Ban List", "This is your list of users/servers you have blocked - don't leave the room!": "This is your list of users/servers you have blocked - don't leave the room!", + "Connecting": "Connecting", + "Sorry — this call is currently full": "Sorry — this call is currently full", "Create account": "Create account", "You made it!": "You made it!", "Find and invite your friends": "Find and invite your friends", @@ -1054,7 +1061,6 @@ "Video devices": "Video devices", "Turn off camera": "Turn off camera", "Turn on camera": "Turn on camera", - "Join": "Join", "%(count)s people joined|other": "%(count)s people joined", "%(count)s people joined|one": "%(count)s person joined", "Dial": "Dial", @@ -1067,7 +1073,6 @@ "You held the call Switch": "You held the call Switch", "You held the call Resume": "You held the call Resume", "%(peerName)s held the call": "%(peerName)s held the call", - "Connecting": "Connecting", "Dialpad": "Dialpad", "Mute the microphone": "Mute the microphone", "Unmute the microphone": "Unmute the microphone", @@ -1079,7 +1084,7 @@ "Show sidebar": "Show sidebar", "More": "More", "Hangup": "Hangup", - "Fill Screen": "Fill Screen", + "Fill screen": "Fill screen", "Pin": "Pin", "Return to call": "Return to call", "%(name)s on hold": "%(name)s on hold", @@ -1522,7 +1527,6 @@ "Ban list rules - %(roomName)s": "Ban list rules - %(roomName)s", "Server rules": "Server rules", "User rules": "User rules", - "Close": "Close", "You have not ignored anyone.": "You have not ignored anyone.", "You are currently ignoring:": "You are currently ignoring:", "You are not subscribed to any lists": "You are not subscribed to any lists", @@ -1648,6 +1652,8 @@ "Modify widgets": "Modify widgets", "Voice broadcasts": "Voice broadcasts", "Manage pinned events": "Manage pinned events", + "Start %(brand)s calls": "Start %(brand)s calls", + "Join %(brand)s calls": "Join %(brand)s calls", "Default role": "Default role", "Send messages": "Send messages", "Invite users": "Invite users", @@ -1687,6 +1693,10 @@ "Security & Privacy": "Security & Privacy", "Once enabled, encryption cannot be disabled.": "Once enabled, encryption cannot be disabled.", "Encrypted": "Encrypted", + "Enable %(brand)s as an additional calling option in this room": "Enable %(brand)s as an additional calling option in this room", + "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.": "%(brand)s is end-to-end encrypted, but is currently limited to smaller numbers of users.", + "You do not have sufficient permissions to change this.": "You do not have sufficient permissions to change this.", + "Call type": "Call type", "Unable to revoke sharing for email address": "Unable to revoke sharing for email address", "Unable to share email address": "Unable to share email address", "Your email address hasn't been verified yet": "Your email address hasn't been verified yet", @@ -1717,11 +1727,14 @@ "Rename session": "Rename session", "Please be aware that session names are also visible to people you communicate with": "Please be aware that session names are also visible to people you communicate with", "Session ID": "Session ID", + "Client": "Client", "Last activity": "Last activity", "Application": "Application", "Version": "Version", "URL": "URL", "Device": "Device", + "Model": "Model", + "Operating system": "Operating system", "IP address": "IP address", "Session details": "Session details", "Toggle push notifications on this session.": "Toggle push notifications on this session.", @@ -1732,7 +1745,10 @@ "Inactive for %(inactiveAgeDays)s+ days": "Inactive for %(inactiveAgeDays)s+ days", "Verified": "Verified", "Unverified": "Unverified", - "Unknown device type": "Unknown device type", + "Desktop session": "Desktop session", + "Mobile session": "Mobile session", + "Web session": "Web session", + "Unknown session type": "Unknown session type", "Verified session": "Verified session", "This session is ready for secure messaging.": "This session is ready for secure messaging.", "Unverified session": "Unverified session", @@ -1884,16 +1900,21 @@ "Recently visited rooms": "Recently visited rooms", "No recently visited rooms": "No recently visited rooms", "Video call (Jitsi)": "Video call (Jitsi)", - "Video call (Element Call)": "Video call (Element Call)", + "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", + "Layout type": "Layout type", "Forget room": "Forget room", "Hide Widgets": "Hide Widgets", "Show Widgets": "Show Widgets", "Search": "Search", "Invite": "Invite", + "Close call": "Close call", + "View chat timeline": "View chat timeline", "Room options": "Room options", "(~%(count)s results)|other": "(~%(count)s results)", "(~%(count)s results)|one": "(~%(count)s result)", @@ -2005,7 +2026,6 @@ "%(count)s unread messages.|other": "%(count)s unread messages.", "%(count)s unread messages.|one": "1 unread message.", "Unread messages.": "Unread messages.", - "Video": "Video", "Joining…": "Joining…", "Joined": "Joined", "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.": "Upgrading this room will shut down the current instance of the room and create an upgraded room with the same name.", @@ -2090,7 +2110,7 @@ "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", + "Room info": "Room info", "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/linkify-matrix.ts b/src/linkify-matrix.ts index 896784cb45..f2df222eec 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -30,13 +30,11 @@ import dis from './dispatcher/dispatcher'; import { Action } from './dispatcher/actions'; import { ViewUserPayload } from './dispatcher/payloads/ViewUserPayload'; import { ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload"; -import { showGroupReplacedWithSpacesDialog } from "./group_helpers"; export enum Type { URL = "url", UserId = "userid", RoomAlias = "roomalias", - GroupId = "groupid", } // Linkify stuff doesn't type scanner/parser/utils properly :/ @@ -115,11 +113,6 @@ function onUserClick(event: MouseEvent, userId: string) { }); } -function onGroupClick(event: MouseEvent, groupId: string) { - event.preventDefault(); - showGroupReplacedWithSpacesDialog(groupId); -} - function onAliasClick(event: MouseEvent, roomAlias: string) { event.preventDefault(); dis.dispatch({ @@ -192,15 +185,6 @@ export const options = { onAliasClick(e, alias); }, }; - - case Type.GroupId: - return { - // @ts-ignore see https://linkify.js.org/docs/options.html - click: function(e: MouseEvent) { - const groupId = parsePermalink(href).groupId; - onGroupClick(e, groupId); - }, - }; } }, @@ -208,7 +192,6 @@ export const options = { switch (type) { case Type.RoomAlias: case Type.UserId: - case Type.GroupId: default: { return tryTransformEntityToPermalink(href); } @@ -255,17 +238,6 @@ registerPlugin(Type.RoomAlias, ({ scanner, parser, utils }) => { }); }); -registerPlugin(Type.GroupId, ({ scanner, parser, utils }) => { - const token = scanner.tokens.PLUS as '+'; - matrixOpaqueIdLinkifyParser({ - scanner, - parser, - utils, - token, - name: Type.GroupId, - }); -}); - registerPlugin(Type.UserId, ({ scanner, parser, utils }) => { const token = scanner.tokens.AT as '@'; matrixOpaqueIdLinkifyParser({ diff --git a/src/models/Call.ts b/src/models/Call.ts index 417cf16291..455b08e6a5 100644 --- a/src/models/Call.ts +++ b/src/models/Call.ts @@ -31,7 +31,7 @@ import type { Room } from "matrix-js-sdk/src/models/room"; import type { RoomMember } from "matrix-js-sdk/src/models/room-member"; import type { ClientWidgetApi } from "matrix-widget-api"; import type { IApp } from "../stores/WidgetStore"; -import SdkConfig from "../SdkConfig"; +import SdkConfig, { DEFAULTS } from "../SdkConfig"; import SettingsStore from "../settings/SettingsStore"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../MediaDeviceHandler"; import { timeout } from "../utils/promise"; @@ -71,15 +71,22 @@ export enum ConnectionState { export const isConnected = (state: ConnectionState): boolean => state === ConnectionState.Connected || state === ConnectionState.Disconnecting; +export enum Layout { + Tile = "tile", + Spotlight = "spotlight", +} + export enum CallEvent { ConnectionState = "connection_state", Participants = "participants", + Layout = "layout", Destroy = "destroy", } interface CallEventHandlerMap { [CallEvent.ConnectionState]: (state: ConnectionState, prevState: ConnectionState) => void; [CallEvent.Participants]: (participants: Set, prevParticipants: Set) => void; + [CallEvent.Layout]: (layout: Layout) => void; [CallEvent.Destroy]: () => void; } @@ -110,7 +117,7 @@ export abstract class Call extends TypedEventEmitter { @@ -791,6 +809,8 @@ export class ElementCall extends Call { public setDisconnected() { this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup); + this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout); + this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout); super.setDisconnected(); } @@ -812,6 +832,18 @@ export class ElementCall extends Call { super.destroy(); } + /** + * Sets the call's layout. + * @param layout The layout to switch to. + */ + public async setLayout(layout: Layout): Promise { + const action = layout === Layout.Tile + ? ElementWidgetActions.TileLayout + : ElementWidgetActions.SpotlightLayout; + + await this.messaging!.transport.send(action, {}); + } + private get mayTerminate(): boolean { return this.groupCall.getContent()["m.intent"] !== "m.room" && this.room.currentState.mayClientSendStateEvent(ElementCall.CALL_EVENT_TYPE.name, this.client); @@ -869,4 +901,16 @@ export class ElementCall extends Call { await this.messaging!.transport.reply(ev.detail, {}); // ack this.setDisconnected(); }; + + private onTileLayout = async (ev: CustomEvent) => { + ev.preventDefault(); + this.layout = Layout.Tile; + await this.messaging!.transport.reply(ev.detail, {}); // ack + }; + + private onSpotlightLayout = async (ev: CustomEvent) => { + ev.preventDefault(); + this.layout = Layout.Spotlight; + await this.messaging!.transport.reply(ev.detail, {}); // ack + }; } diff --git a/src/stores/CallStore.ts b/src/stores/CallStore.ts index e8d2a49199..9d8eeb8ff0 100644 --- a/src/stores/CallStore.ts +++ b/src/stores/CallStore.ts @@ -73,7 +73,7 @@ export class CallStore extends AsyncStoreWithClient<{}> { await Promise.all([ ...uncleanlyDisconnectedRoomIds.map(async uncleanlyDisconnectedRoomId => { logger.log(`Cleaning up call state for room ${uncleanlyDisconnectedRoomId}`); - await this.get(uncleanlyDisconnectedRoomId)?.clean(); + await this.getCall(uncleanlyDisconnectedRoomId)?.clean(); }), SettingsStore.setValue("activeCallRoomIds", null, SettingLevel.DEVICE, []), ]); @@ -152,18 +152,18 @@ export class CallStore extends AsyncStoreWithClient<{}> { * @param {string} roomId The room's ID. * @returns {Call | null} The call. */ - public get(roomId: string): Call | null { + public getCall(roomId: string): Call | null { return this.calls.get(roomId) ?? null; } /** - * Determines whether the given room has an active call. + * Gets the active call associated with the given room, if any. * @param roomId The room's ID. - * @returns Whether the given room has an active call. + * @returns The active call. */ - public hasActiveCall(roomId: string): boolean { - const call = this.get(roomId); - return call !== null && this.activeCalls.has(call); + public getActiveCall(roomId: string): Call | null { + const call = this.getCall(roomId); + return call !== null && this.activeCalls.has(call) ? call : null; } private onRoom = (room: Room) => this.updateRoom(room); diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx index 42443a295d..0a15ce1860 100644 --- a/src/stores/RoomViewStore.tsx +++ b/src/stores/RoomViewStore.tsx @@ -365,7 +365,7 @@ export class RoomViewStore extends EventEmitter { viewingCall: payload.view_call ?? ( payload.room_id === this.state.roomId ? this.state.viewingCall - : CallStore.instance.hasActiveCall(payload.room_id) + : CallStore.instance.getActiveCall(payload.room_id) !== null ), }; diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index f0b496f0b7..dd806dd6dd 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -40,7 +40,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { Direction } from "matrix-js-sdk/src/matrix"; -import SdkConfig from "../../SdkConfig"; +import SdkConfig, { DEFAULTS } from "../../SdkConfig"; import { iterableDiff, iterableIntersection } from "../../utils/iterables"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import Modal from "../../Modal"; @@ -104,7 +104,10 @@ export class StopGapWidgetDriver extends WidgetDriver { // Auto-approve the legacy visibility capability. We send it regardless of capability. // Widgets don't technically need to request this capability, but Scalar still does. this.allowedCapabilities.add("visibility"); - } else if (virtual && new URL(SdkConfig.get("element_call").url).origin === this.forWidget.origin) { + } else if ( + virtual + && new URL(SdkConfig.get("element_call").url ?? DEFAULTS.element_call.url).origin === this.forWidget.origin + ) { // This is a trusted Element Call widget that we control this.allowedCapabilities.add(MatrixCapabilities.AlwaysOnScreen); this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers); @@ -434,7 +437,6 @@ export class StopGapWidgetDriver extends WidgetDriver { } const { - originalEvent, events, nextBatch, prevBatch, @@ -451,7 +453,6 @@ export class StopGapWidgetDriver extends WidgetDriver { }); return { - originalEvent: originalEvent?.getEffectiveEvent(), chunk: events.map(e => e.getEffectiveEvent()), nextBatch, prevBatch, diff --git a/src/theme.ts b/src/theme.ts index 54bc42f807..9d2f836fb4 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -237,13 +237,13 @@ export async function setTheme(theme?: string): Promise { // look for the stylesheet elements. // styleElements is a map from style name to HTMLLinkElement. - const styleElements = Object.create(null); - const themes = Array.from(document.querySelectorAll('[data-mx-theme]')); + const styleElements = new Map(); + const themes = Array.from(document.querySelectorAll('[data-mx-theme]')); themes.forEach(theme => { - styleElements[theme.attributes['data-mx-theme'].value.toLowerCase()] = theme; + styleElements.set(theme.attributes['data-mx-theme'].value.toLowerCase(), theme); }); - if (!(stylesheetName in styleElements)) { + if (!styleElements.has(stylesheetName)) { throw new Error("Unknown theme " + stylesheetName); } @@ -258,17 +258,18 @@ export async function setTheme(theme?: string): Promise { // having them interact badly... but this causes a flash of unstyled app // which is even uglier. So we don't. - styleElements[stylesheetName].disabled = false; + const styleSheet = styleElements.get(stylesheetName); + styleSheet.disabled = false; - return new Promise((resolve) => { + return new Promise(((resolve, reject) => { const switchTheme = function() { // we re-enable our theme here just in case we raced with another // theme set request as per https://github.com/vector-im/element-web/issues/5601. // We could alternatively lock or similar to stop the race, but // this is probably good enough for now. - styleElements[stylesheetName].disabled = false; - Object.values(styleElements).forEach((a: HTMLStyleElement) => { - if (a == styleElements[stylesheetName]) return; + styleSheet.disabled = false; + styleElements.forEach(a => { + if (a == styleSheet) return; a.disabled = true; }); const bodyStyles = global.getComputedStyle(document.body); @@ -279,26 +280,50 @@ export async function setTheme(theme?: string): Promise { resolve(); }; - // turns out that Firefox preloads the CSS for link elements with - // the disabled attribute, but Chrome doesn't. + const isStyleSheetLoaded = () => Boolean( + [...document.styleSheets] + .find(_styleSheet => _styleSheet?.href === styleSheet.href), + ); - let cssLoaded = false; - - styleElements[stylesheetName].onload = () => { - switchTheme(); - }; - - for (let i = 0; i < document.styleSheets.length; i++) { - const ss = document.styleSheets[i]; - if (ss && ss.href === styleElements[stylesheetName].href) { - cssLoaded = true; - break; + function waitForStyleSheetLoading() { + // turns out that Firefox preloads the CSS for link elements with + // the disabled attribute, but Chrome doesn't. + if (isStyleSheetLoaded()) { + switchTheme(); + return; } + + let counter = 0; + + // In case of theme toggling (white => black => white) + // Chrome doesn't fire the `load` event when the white theme is selected the second times + const intervalId = setInterval(() => { + if (isStyleSheetLoaded()) { + clearInterval(intervalId); + styleSheet.onload = undefined; + styleSheet.onerror = undefined; + switchTheme(); + } + + // Avoid to be stuck in an endless loop if there is an issue in the stylesheet loading + counter++; + if (counter === 10) { + clearInterval(intervalId); + reject(); + } + }, 200); + + styleSheet.onload = () => { + clearInterval(intervalId); + switchTheme(); + }; + + styleSheet.onerror = (e) => { + clearInterval(intervalId); + reject(e); + }; } - if (cssLoaded) { - styleElements[stylesheetName].onload = undefined; - switchTheme(); - } - }); + waitForStyleSheetLoading(); + })); } diff --git a/src/toasts/IncomingCallToast.tsx b/src/toasts/IncomingCallToast.tsx new file mode 100644 index 0000000000..c8eff9dc82 --- /dev/null +++ b/src/toasts/IncomingCallToast.tsx @@ -0,0 +1,153 @@ +/* +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, { useCallback, useEffect } from 'react'; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import { _t } from '../languageHandler'; +import RoomAvatar from '../components/views/avatars/RoomAvatar'; +import { MatrixClientPeg } from "../MatrixClientPeg"; +import defaultDispatcher from "../dispatcher/dispatcher"; +import { ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../dispatcher/actions"; +import ToastStore from "../stores/ToastStore"; +import AccessibleTooltipButton from "../components/views/elements/AccessibleTooltipButton"; +import { + LiveContentSummary, + LiveContentSummaryWithCall, + LiveContentType, +} from "../components/views/rooms/LiveContentSummary"; +import { useCall, useJoinCallButtonDisabledTooltip } from "../hooks/useCall"; +import { useRoomState } from "../hooks/useRoomState"; +import { ButtonEvent } from "../components/views/elements/AccessibleButton"; +import { useDispatcher } from "../hooks/useDispatcher"; +import { ActionPayload } from "../dispatcher/payloads"; +import { Call } from "../models/Call"; + +export const getIncomingCallToastKey = (stateKey: string) => `call_${stateKey}`; + +interface JoinCallButtonWithCallProps { + onClick: (e: ButtonEvent) => void; + call: Call; +} + +function JoinCallButtonWithCall({ onClick, call }: JoinCallButtonWithCallProps) { + const tooltip = useJoinCallButtonDisabledTooltip(call); + + return + { _t("Join") } + ; +} + +interface Props { + callEvent: MatrixEvent; +} + +export function IncomingCallToast({ callEvent }: Props) { + const roomId = callEvent.getRoomId()!; + const room = MatrixClientPeg.get().getRoom(roomId); + const call = useCall(roomId); + + const dismissToast = useCallback((): void => { + ToastStore.sharedInstance().dismissToast(getIncomingCallToastKey(callEvent.getStateKey()!)); + }, [callEvent]); + + const latestEvent = useRoomState(room, useCallback((state) => { + return state.getStateEvents(callEvent.getType(), callEvent.getStateKey()!); + }, [callEvent])); + + useEffect(() => { + if ("m.terminated" in latestEvent.getContent()) { + dismissToast(); + } + }, [latestEvent, dismissToast]); + + useDispatcher(defaultDispatcher, useCallback((payload: ActionPayload) => { + if ( + payload.action === Action.ViewRoom + && payload.room_id === roomId + && payload.view_call + ) { + dismissToast(); + } + }, [roomId, dismissToast])); + + const onJoinClick = useCallback((e: ButtonEvent): void => { + e.stopPropagation(); + + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + view_call: true, + metricsTrigger: undefined, + }); + dismissToast(); + }, [room, dismissToast]); + + const onCloseClick = useCallback((e: ButtonEvent): void => { + e.stopPropagation(); + + dismissToast(); + }, [dismissToast]); + + return + +
+
+ + { room ? room.name : _t("Unknown room") } + +
+ { _t("Video call started") } +
+ { call + ? + : + } +
+ { call + ? + : + { _t("Join") } + + } +
+ +
; +} diff --git a/src/utils/WidgetUtils.ts b/src/utils/WidgetUtils.ts index 728a41f687..964d795576 100644 --- a/src/utils/WidgetUtils.ts +++ b/src/utils/WidgetUtils.ts @@ -512,6 +512,7 @@ export default class WidgetUtils { 'theme=$theme', 'roomName=$roomName', `supportsScreensharing=${PlatformPeg.get().supportsJitsiScreensharing()}`, + 'language=$org.matrix.msc2873.client_language', ]; if (opts.auth) { queryStringParts.push(`auth=${opts.auth}`); diff --git a/src/utils/device/parseUserAgent.ts b/src/utils/device/parseUserAgent.ts new file mode 100644 index 0000000000..9d9d71e6f7 --- /dev/null +++ b/src/utils/device/parseUserAgent.ts @@ -0,0 +1,113 @@ +/* +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 UAParser from 'ua-parser-js'; + +export enum DeviceType { + Desktop = 'Desktop', + Mobile = 'Mobile', + Web = 'Web', + Unknown = 'Unknown', +} +export type ExtendedDeviceInformation = { + deviceType: DeviceType; + // eg Google Pixel 6 + deviceModel?: string; + // eg Android 11 + deviceOperatingSystem?: string; + // eg Firefox 1.1.0 + client?: string; +}; + +// Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00) +const IOS_KEYWORD = "; iOS "; +const BROWSER_KEYWORD = "Mozilla/"; + +const getDeviceType = ( + userAgent: string, + device: UAParser.IDevice, + browser: UAParser.IBrowser, + operatingSystem: UAParser.IOS, +): DeviceType => { + if (browser.name === 'Electron') { + return DeviceType.Desktop; + } + if (!!browser.name) { + return DeviceType.Web; + } + if ( + device.type === 'mobile' || + operatingSystem.name?.includes('Android') || + userAgent.indexOf(IOS_KEYWORD) > -1 + ) { + return DeviceType.Mobile; + } + return DeviceType.Unknown; +}; + +/** + * Some mobile model and OS strings are not recognised + * by the UA parsing library + * check they exist by hand + */ +const checkForCustomValues = (userAgent: string): { + customDeviceModel?: string; + customDeviceOS?: string; +} => { + if (userAgent.includes(BROWSER_KEYWORD)) { + return {}; + } + + const mightHaveDevice = userAgent.includes('('); + if (!mightHaveDevice) { + return {}; + } + const deviceInfoSegments = userAgent.substring(userAgent.indexOf('(') + 1).split('; '); + const customDeviceModel = deviceInfoSegments[0] || undefined; + const customDeviceOS = deviceInfoSegments[1] || undefined; + return { customDeviceModel, customDeviceOS }; +}; + +const concatenateNameAndVersion = (name?: string, version?: string): string | undefined => + name && [name, version].filter(Boolean).join(' '); + +export const parseUserAgent = (userAgent?: string): ExtendedDeviceInformation => { + if (!userAgent) { + return { + deviceType: DeviceType.Unknown, + }; + } + + const parser = new UAParser(userAgent); + + const browser = parser.getBrowser(); + const device = parser.getDevice(); + const operatingSystem = parser.getOS(); + + const deviceOperatingSystem = concatenateNameAndVersion(operatingSystem.name, operatingSystem.version); + const deviceModel = concatenateNameAndVersion(device.vendor, device.model); + const client = concatenateNameAndVersion(browser.name, browser.major || browser.version); + + const { customDeviceModel, customDeviceOS } = checkForCustomValues(userAgent); + const deviceType = getDeviceType(userAgent, device, browser, operatingSystem); + + return { + deviceType, + deviceModel: deviceModel || customDeviceModel, + deviceOperatingSystem: deviceOperatingSystem || customDeviceOS, + client, + }; +}; diff --git a/src/utils/notifications.ts b/src/utils/notifications.ts index f41edd24bb..7e8aff6d0b 100644 --- a/src/utils/notifications.ts +++ b/src/utils/notifications.ts @@ -14,14 +14,40 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { MatrixClient } from "matrix-js-sdk/src/client"; import { LOCAL_NOTIFICATION_SETTINGS_PREFIX } from "matrix-js-sdk/src/@types/event"; import { LocalNotificationSettings } from "matrix-js-sdk/src/@types/local_notifications"; -import { MatrixClient } from "matrix-js-sdk/src/client"; + +import SettingsStore from "../settings/SettingsStore"; + +export const deviceNotificationSettingsKeys = [ + "notificationsEnabled", + "notificationBodyEnabled", + "audioNotificationsEnabled", +]; export function getLocalNotificationAccountDataEventType(deviceId: string): string { return `${LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${deviceId}`; } +export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient): Promise { + const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); + const event = cli.getAccountData(eventType); + // New sessions will create an account data event to signify they support + // remote toggling of push notifications on this device. Default `is_silenced=true` + // For backwards compat purposes, older sessions will need to check settings value + // to determine what the state of `is_silenced` + if (!event) { + // If any of the above is true, we fall in the "backwards compat" case, + // and `is_silenced` will be set to `false` + const isSilenced = !deviceNotificationSettingsKeys.some(key => SettingsStore.getValue(key)); + + await cli.setAccountData(eventType, { + is_silenced: isSilenced, + }); + } +} + export function localNotificationsAreSilenced(cli: MatrixClient): boolean { const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); const event = cli.getAccountData(eventType); diff --git a/src/utils/permalinks/ElementPermalinkConstructor.ts b/src/utils/permalinks/ElementPermalinkConstructor.ts index 01525081a6..d66c3ae031 100644 --- a/src/utils/permalinks/ElementPermalinkConstructor.ts +++ b/src/utils/permalinks/ElementPermalinkConstructor.ts @@ -43,17 +43,11 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor { return `${this.elementUrl}/#/user/${userId}`; } - forGroup(groupId: string): string { - return `${this.elementUrl}/#/group/${groupId}`; - } - forEntity(entityId: string): string { if (entityId[0] === '!' || entityId[0] === '#') { return this.forRoom(entityId); } else if (entityId[0] === '@') { return this.forUser(entityId); - } else if (entityId[0] === '+') { - return this.forGroup(entityId); } else throw new Error("Unrecognized entity"); } @@ -107,8 +101,6 @@ export default class ElementPermalinkConstructor extends PermalinkConstructor { const eventId = parts.length > 2 ? parts.slice(2).join('/') : ""; const via = query.split(/&?via=/).filter(p => !!p); return PermalinkParts.forEvent(entity, eventId, via); - } else if (entityType === 'group') { - return PermalinkParts.forGroup(entity); } else { throw new Error("Unknown entity type in permalink"); } diff --git a/src/utils/permalinks/MatrixSchemePermalinkConstructor.ts b/src/utils/permalinks/MatrixSchemePermalinkConstructor.ts index 70622af7c2..904fbb8939 100644 --- a/src/utils/permalinks/MatrixSchemePermalinkConstructor.ts +++ b/src/utils/permalinks/MatrixSchemePermalinkConstructor.ts @@ -51,10 +51,6 @@ export default class MatrixSchemePermalinkConstructor extends PermalinkConstruct return `matrix:${this.encodeEntity(userId)}`; } - forGroup(groupId: string): string { - throw new Error("Deliberately not implemented"); - } - forEntity(entityId: string): string { return `matrix:${this.encodeEntity(entityId)}`; } diff --git a/src/utils/permalinks/MatrixToPermalinkConstructor.ts b/src/utils/permalinks/MatrixToPermalinkConstructor.ts index ab2de5959a..3a57fc443f 100644 --- a/src/utils/permalinks/MatrixToPermalinkConstructor.ts +++ b/src/utils/permalinks/MatrixToPermalinkConstructor.ts @@ -39,10 +39,6 @@ export default class MatrixToPermalinkConstructor extends PermalinkConstructor { return `${baseUrl}/#/${userId}`; } - forGroup(groupId: string): string { - return `${baseUrl}/#/${groupId}`; - } - forEntity(entityId: string): string { return `${baseUrl}/#/${entityId}`; } @@ -82,8 +78,6 @@ export default class MatrixToPermalinkConstructor extends PermalinkConstructor { const via = query.split(/&?via=/g).filter(p => !!p); return PermalinkParts.forEvent(entity, eventId, via); - } else if (entity[0] === '+') { - return PermalinkParts.forGroup(entity); } else { throw new Error("Unknown entity type in permalink"); } diff --git a/src/utils/permalinks/PermalinkConstructor.ts b/src/utils/permalinks/PermalinkConstructor.ts index 158df895e2..f259094095 100644 --- a/src/utils/permalinks/PermalinkConstructor.ts +++ b/src/utils/permalinks/PermalinkConstructor.ts @@ -27,10 +27,6 @@ export default class PermalinkConstructor { throw new Error("Not implemented"); } - forGroup(groupId: string): string { - throw new Error("Not implemented"); - } - forUser(userId: string): string { throw new Error("Not implemented"); } @@ -55,30 +51,24 @@ export class PermalinkParts { eventId: string; userId: string; viaServers: string[]; - groupId: string; - constructor(roomIdOrAlias: string, eventId: string, userId: string, groupId: string, viaServers: string[]) { + constructor(roomIdOrAlias: string, eventId: string, userId: string, viaServers: string[]) { this.roomIdOrAlias = roomIdOrAlias; this.eventId = eventId; this.userId = userId; - this.groupId = groupId; this.viaServers = viaServers; } static forUser(userId: string): PermalinkParts { - return new PermalinkParts(null, null, userId, null, null); - } - - static forGroup(groupId: string): PermalinkParts { - return new PermalinkParts(null, null, null, groupId, null); + return new PermalinkParts(null, null, userId, null); } static forRoom(roomIdOrAlias: string, viaServers: string[] = []): PermalinkParts { - return new PermalinkParts(roomIdOrAlias, null, null, null, viaServers); + return new PermalinkParts(roomIdOrAlias, null, null, viaServers); } static forEvent(roomId: string, eventId: string, viaServers: string[] = []): PermalinkParts { - return new PermalinkParts(roomId, eventId, null, null, viaServers); + return new PermalinkParts(roomId, eventId, null, viaServers); } get primaryEntityId(): string { diff --git a/src/utils/permalinks/Permalinks.ts b/src/utils/permalinks/Permalinks.ts index c7c7de9c53..ce2f8aeb1d 100644 --- a/src/utils/permalinks/Permalinks.ts +++ b/src/utils/permalinks/Permalinks.ts @@ -295,10 +295,6 @@ export function makeRoomPermalink(roomId: string): string { return permalinkCreator.forShareableRoom(); } -export function makeGroupPermalink(groupId: string): string { - return getPermalinkConstructor().forGroup(groupId); -} - export function isPermalinkHost(host: string): boolean { // Always check if the permalink is a spec permalink (callers are likely to call // parsePermalink after this function). @@ -319,7 +315,6 @@ export function tryTransformEntityToPermalink(entity: string): string { // Check to see if it is a bare entity for starters if (entity[0] === '#' || entity[0] === '!') return makeRoomPermalink(entity); if (entity[0] === '@') return makeUserPermalink(entity); - if (entity[0] === '+') return makeGroupPermalink(entity); if (entity.slice(0, 7) === "matrix:") { try { @@ -332,8 +327,6 @@ export function tryTransformEntityToPermalink(entity: string): string { pl += new MatrixToPermalinkConstructor().encodeServerCandidates(permalinkParts.viaServers); } return pl; - } else if (permalinkParts.groupId) { - return matrixtoBaseUrl + `/#/${permalinkParts.groupId}`; } else if (permalinkParts.userId) { return matrixtoBaseUrl + `/#/${permalinkParts.userId}`; } @@ -381,8 +374,6 @@ export function tryTransformPermalinkToLocalHref(permalink: string): string { } } else if (permalinkParts.userId) { permalink = `#/user/${permalinkParts.userId}`; - } else if (permalinkParts.groupId) { - permalink = `#/group/${permalinkParts.groupId}`; } // else not a valid permalink for our purposes - do not handle } } catch (e) { @@ -410,7 +401,6 @@ export function getPrimaryPermalinkEntity(permalink: string): string { if (!permalinkParts) return null; // not processable if (permalinkParts.userId) return permalinkParts.userId; if (permalinkParts.roomIdOrAlias) return permalinkParts.roomIdOrAlias; - if (permalinkParts.groupId) return permalinkParts.groupId; } catch (e) { // no entity - not a permalink } diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts index 1178d35bec..eb5d5c7fce 100644 --- a/test/Notifier-test.ts +++ b/test/Notifier-test.ts @@ -14,28 +14,31 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { mocked, MockedObject } from "jest-mock"; +import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; +import { Room } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { SyncState } from "matrix-js-sdk/src/sync"; +import BasePlatform from "../src/BasePlatform"; +import { ElementCall } from "../src/models/Call"; import Notifier from "../src/Notifier"; -import { getLocalNotificationAccountDataEventType } from "../src/utils/notifications"; +import SettingsStore from "../src/settings/SettingsStore"; +import ToastStore from "../src/stores/ToastStore"; +import { + createLocalNotificationSettingsIfNeeded, + getLocalNotificationAccountDataEventType, +} from "../src/utils/notifications"; import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils"; +import { IncomingCallToast } from "../src/toasts/IncomingCallToast"; + +jest.mock("../src/utils/notifications", () => ({ + // @ts-ignore + ...jest.requireActual("../src/utils/notifications"), + createLocalNotificationSettingsIfNeeded: jest.fn(), +})); describe("Notifier", () => { - let MockPlatform; - let accountDataStore = {}; - - const mockClient = getMockClientWithEventEmitter({ - getUserId: jest.fn().mockReturnValue("@bob:example.org"), - isGuest: jest.fn().mockReturnValue(false), - getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), - setAccountData: jest.fn().mockImplementation((eventType, content) => { - accountDataStore[eventType] = new MatrixEvent({ - type: eventType, - content, - }); - }), - }); - const accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); const roomId = "!room1:server"; const testEvent = mkEvent({ event: true, @@ -44,10 +47,33 @@ describe("Notifier", () => { room: roomId, content: {}, }); - const testRoom = mkRoom(mockClient, roomId); + + let MockPlatform: MockedObject; + let mockClient: MockedObject; + let testRoom: MockedObject; + let accountDataEventKey: string; + let accountDataStore = {}; beforeEach(() => { accountDataStore = {}; + mockClient = getMockClientWithEventEmitter({ + getUserId: jest.fn().mockReturnValue("@bob:example.org"), + isGuest: jest.fn().mockReturnValue(false), + getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), + setAccountData: jest.fn().mockImplementation((eventType, content) => { + accountDataStore[eventType] = new MatrixEvent({ + type: eventType, + content, + }); + }), + decryptEventIfNeeded: jest.fn(), + getRoom: jest.fn(), + getPushActionsForEvent: jest.fn(), + }); + accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); + + testRoom = mkRoom(mockClient, roomId); + MockPlatform = mockPlatformPeg({ supportsNotifications: jest.fn().mockReturnValue(true), maySendNotifications: jest.fn().mockReturnValue(true), @@ -55,6 +81,8 @@ describe("Notifier", () => { }); Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); + + mockClient.getRoom.mockReturnValue(testRoom); }); describe("_displayPopupNotification", () => { @@ -82,4 +110,115 @@ describe("Notifier", () => { expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); }); }); + + describe("group call notifications", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + jest.spyOn(ToastStore.sharedInstance(), "addOrReplaceToast"); + + mockClient.getPushActionsForEvent.mockReturnValue({ + notify: true, + tweaks: {}, + }); + + Notifier.onSyncStateChange(SyncState.Syncing); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + const callOnEvent = (type?: string) => { + const callEvent = { + getContent: () => { }, + getRoomId: () => roomId, + isBeingDecrypted: () => false, + isDecryptionFailure: () => false, + getSender: () => "@alice:foo", + getType: () => type ?? ElementCall.CALL_EVENT_TYPE.name, + getStateKey: () => "state_key", + } as unknown as MatrixEvent; + + Notifier.onEvent(callEvent); + return callEvent; + }; + + const setGroupCallsEnabled = (val: boolean) => { + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "feature_group_calls") return val; + }); + }; + + it("should show toast when group calls are supported", () => { + setGroupCallsEnabled(true); + + const callEvent = callOnEvent(); + + expect(ToastStore.sharedInstance().addOrReplaceToast).toHaveBeenCalledWith(expect.objectContaining({ + key: `call_${callEvent.getStateKey()}`, + priority: 100, + component: IncomingCallToast, + bodyClassName: "mx_IncomingCallToast", + props: { callEvent }, + })); + }); + + it("should not show toast when group calls are not supported", () => { + setGroupCallsEnabled(false); + + callOnEvent(); + + expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); + }); + + it("should not show toast when calling with non-group call event", () => { + setGroupCallsEnabled(true); + + callOnEvent("event_type"); + + expect(ToastStore.sharedInstance().addOrReplaceToast).not.toHaveBeenCalled(); + }); + }); + + describe('local notification settings', () => { + const createLocalNotificationSettingsIfNeededMock = mocked(createLocalNotificationSettingsIfNeeded); + let hasStartedNotiferBefore = false; + beforeEach(() => { + // notifier defines some listener functions in start + // and references them in stop + // so blows up if stopped before it was started + if (hasStartedNotiferBefore) { + Notifier.stop(); + } + Notifier.start(); + hasStartedNotiferBefore = true; + createLocalNotificationSettingsIfNeededMock.mockClear(); + }); + + afterAll(() => { + Notifier.stop(); + }); + + it('does not create local notifications event after a sync error', () => { + mockClient.emit(ClientEvent.Sync, SyncState.Error, SyncState.Syncing); + expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled(); + }); + + it('does not create local notifications event after sync stops', () => { + mockClient.emit(ClientEvent.Sync, SyncState.Stopped, SyncState.Syncing); + expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled(); + }); + + it('does not create local notifications event after a cached sync', () => { + mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing, { + fromCache: true, + }); + expect(createLocalNotificationSettingsIfNeededMock).not.toHaveBeenCalled(); + }); + + it('creates local notifications event after a non-cached sync', () => { + mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing, {}); + expect(createLocalNotificationSettingsIfNeededMock).toHaveBeenCalled(); + }); + }); }); diff --git a/test/TextForEvent-test.ts b/test/TextForEvent-test.ts index c99fb56571..27f3090d3e 100644 --- a/test/TextForEvent-test.ts +++ b/test/TextForEvent-test.ts @@ -14,15 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import TestRenderer from 'react-test-renderer'; import { ReactElement } from "react"; +import { mocked } from "jest-mock"; import { getSenderName, textForEvent } from "../src/TextForEvent"; import SettingsStore from "../src/settings/SettingsStore"; -import { createTestClient } from './test-utils'; +import { createTestClient, stubClient } from './test-utils'; import { MatrixClientPeg } from '../src/MatrixClientPeg'; import UserIdentifierCustomisations from '../src/customisations/UserIdentifier'; +import { ElementCall } from "../src/models/Call"; jest.mock("../src/settings/SettingsStore"); jest.mock('../src/customisations/UserIdentifier', () => ({ @@ -444,4 +446,42 @@ describe('TextForEvent', () => { expect(textForEvent(messageEvent)).toEqual('@a: test message'); }); }); + + describe("textForCallEvent()", () => { + let mockClient: MatrixClient; + let callEvent: MatrixEvent; + + beforeEach(() => { + stubClient(); + mockClient = MatrixClientPeg.get(); + + mocked(mockClient.getRoom).mockReturnValue({ + name: "Test room", + } as unknown as Room); + + callEvent = { + getRoomId: jest.fn(), + getType: jest.fn(), + isState: jest.fn().mockReturnValue(true), + } as unknown as MatrixEvent; + }); + + describe.each(ElementCall.CALL_EVENT_TYPE.names)("eventType=%s", (eventType: string) => { + beforeEach(() => { + mocked(callEvent).getType.mockReturnValue(eventType); + }); + + it("returns correct message for call event when supported", () => { + expect(textForEvent(callEvent)).toEqual('Video call started in Test room.'); + }); + + it("returns correct message for call event when supported", () => { + mocked(mockClient).supportsVoip.mockReturnValue(false); + + expect(textForEvent(callEvent)).toEqual( + 'Video call started in Test room. (not supported by this browser)', + ); + }); + }); + }); }); diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index a0c3a277c9..bf62c38ace 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
We're creating a room with @user:example.com
"`; +exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
We're creating a room with @user:example.com
"`; -exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; -exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; -exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; diff --git a/test/components/views/context_menus/MessageContextMenu-test.tsx b/test/components/views/context_menus/MessageContextMenu-test.tsx index 38c646cfe8..10017376bb 100644 --- a/test/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/components/views/context_menus/MessageContextMenu-test.tsx @@ -27,7 +27,7 @@ import { EventType, } from 'matrix-js-sdk/src/matrix'; import { ExtensibleEvent, MessageEvent, M_POLL_KIND_DISCLOSED, PollStartEvent } from 'matrix-events-sdk'; -import { Thread } from "matrix-js-sdk/src/models/thread"; +import { FeatureSupport, Thread } from "matrix-js-sdk/src/models/thread"; import { mocked } from "jest-mock"; import { act } from '@testing-library/react'; @@ -469,7 +469,7 @@ describe('MessageContextMenu', () => { const eventContent = MessageEvent.from("hello"); const mxEvent = new MatrixEvent(eventContent.serialize()); - Thread.hasServerSideSupport = true; + Thread.hasServerSideSupport = FeatureSupport.Stable; const context = { canSendMessages: true, }; diff --git a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap index d77fb7ff49..8221ef4b55 100644 --- a/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/TooltipTarget-test.tsx.snap @@ -4,7 +4,7 @@ exports[` displays Bottom aligned tooltip on mouseover 1`] = `