diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index b5db3871b845..eb0e7628290e 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -14,17 +14,17 @@ 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 { ISendEventResponse, 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 { CypressBot } from "../../support/bot"; import { SynapseInstance } from "../../plugins/synapsedocker"; import Chainable = Cypress.Chainable; type EmojiMapping = [emoji: string, name: string]; interface CryptoTestContext extends Mocha.Context { synapse: SynapseInstance; - bob: MatrixClient; + bob: CypressBot; } const waitForVerificationRequest = (cli: MatrixClient): Promise => { @@ -197,7 +197,7 @@ describe("Cryptography", function () { cy.bootstrapCrossSigning(); autoJoin(this.bob); - /* we need to have a room with the other user present, so we can open the verification panel */ + // we need to have a room with the other user present, so we can open the verification panel let roomId: string; cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }).then((_room1Id) => { roomId = _room1Id; @@ -210,4 +210,85 @@ describe("Cryptography", function () { verify.call(this); }); + + it("should show the correct shield on edited e2e events", function (this: CryptoTestContext) { + cy.bootstrapCrossSigning(); + + // bob has a second, not cross-signed, device + cy.loginBot(this.synapse, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); + + autoJoin(this.bob); + + // first create the room, so that we can open the verification panel + cy.createRoom({ name: "TestRoom", invite: [this.bob.getUserId()] }) + .as("testRoomId") + .then((roomId) => { + cy.log(`Created test room ${roomId}`); + cy.visit(`/#/room/${roomId}`); + + // enable encryption + cy.getClient().then((cli) => { + cli.sendStateEvent(roomId, "m.room.encryption", { algorithm: "m.megolm.v1.aes-sha2" }); + }); + + // wait for Bob to join the room, otherwise our attempt to open his user details may race + // with his join. + cy.contains(".mx_TextualEvent", "Bob joined the room").should("exist"); + }); + + verify.call(this); + + cy.get("@testRoomId").then((roomId) => { + // bob sends a valid event + cy.wrap(this.bob.sendTextMessage(roomId, "Hoo!")).as("testEvent"); + + // the message should appear, decrypted, with no warning + cy.contains(".mx_EventTile_body", "Hoo!") + .closest(".mx_EventTile") + .should("have.class", "mx_EventTile_verified") + .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); + + // bob sends an edit to the first message with his unverified device + cy.get("@bobSecondDevice").then((bobSecondDevice) => { + cy.get("@testEvent").then((testEvent) => { + bobSecondDevice.sendMessage(roomId, { + "m.new_content": { + msgtype: "m.text", + body: "Haa!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + }); + }); + + // the edit should have a warning + cy.contains(".mx_EventTile_body", "Haa!") + .closest(".mx_EventTile") + .within(() => { + cy.get(".mx_EventTile_e2eIcon_warning").should("exist"); + }); + + // a second edit from the verified device should be ok + cy.get("@testEvent").then((testEvent) => { + this.bob.sendMessage(roomId, { + "m.new_content": { + msgtype: "m.text", + body: "Hee!", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: testEvent.event_id, + }, + }); + }); + + cy.contains(".mx_EventTile_body", "Hee!") + .closest(".mx_EventTile") + .should("have.class", "mx_EventTile_verified") + .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning"); + }); + }); }); diff --git a/cypress/e2e/integration-manager/read_events.spec.ts b/cypress/e2e/integration-manager/read_events.spec.ts new file mode 100644 index 000000000000..662df2281370 --- /dev/null +++ b/cypress/e2e/integration-manager/read_events.spec.ts @@ -0,0 +1,276 @@ +/* +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 { SynapseInstance } from "../../plugins/synapsedocker"; +import { UserCredentials } from "../../support/login"; + +const ROOM_NAME = "Integration Manager Test"; +const USER_DISPLAY_NAME = "Alice"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + +

No response

+ + + +`; + +function openIntegrationManager() { + cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { + cy.contains("Add widgets, bridges & bots").click(); + }); +} + +function sendActionFromIntegrationManager( + integrationManagerUrl: string, + targetRoomId: string, + eventType: string, + stateKey: string | boolean, +) { + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#target-room-id").should("exist").type(targetRoomId); + cy.get("#event-type").should("exist").type(eventType); + cy.get("#state-key").should("exist").type(JSON.stringify(stateKey)); + cy.get("#send-action").should("exist").click(); + }); +} + +describe("Integration Manager: Read Events", () => { + let testUser: UserCredentials; + let synapse: SynapseInstance; + let integrationManagerUrl: string; + + beforeEach(() => { + cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { + integrationManagerUrl = url; + }); + cy.startSynapse("default").then((data) => { + synapse = data; + + cy.initTestUser(synapse, USER_DISPLAY_NAME, () => { + cy.window().then((win) => { + win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); + win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); + }); + }).then((user) => { + testUser = user; + }); + + cy.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }).as("integrationManager"); + + // Succeed when checking the token is valid + cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { + req.continue((res) => { + return res.send(200, { + user_id: testUser.userId, + }); + }); + }); + + cy.createRoom({ + name: ROOM_NAME, + }).as("roomId"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + cy.stopWebServers(); + }); + + it("should read a state event by state key", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = "state-key-123"; + + // Send a state event + cy.getClient() + .then(async (client) => { + return await client.sendStateEvent(roomId, eventType, eventContent, stateKey); + }) + .then((event) => { + openIntegrationManager(); + + // Read state events + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response") + .should("include.text", event.event_id) + .should("include.text", `"content":${JSON.stringify(eventContent)}`); + }); + }); + }); + }); + + it("should read a state event with empty state key", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send a state event + cy.getClient() + .then(async (client) => { + return await client.sendStateEvent(roomId, eventType, eventContent, stateKey); + }) + .then((event) => { + openIntegrationManager(); + + // Read state events + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response") + .should("include.text", event.event_id) + .should("include.text", `"content":${JSON.stringify(eventContent)}`); + }); + }); + }); + }); + + it("should read state events with any state key", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + const eventType = "io.element.integrations.installations"; + + const stateKey1 = "state-key-123"; + const eventContent1 = { + foo1: "bar1", + }; + const stateKey2 = "state-key-456"; + const eventContent2 = { + foo2: "bar2", + }; + const stateKey3 = "state-key-789"; + const eventContent3 = { + foo3: "bar3", + }; + + // Send state events + cy.getClient() + .then(async (client) => { + return Promise.all([ + client.sendStateEvent(roomId, eventType, eventContent1, stateKey1), + client.sendStateEvent(roomId, eventType, eventContent2, stateKey2), + client.sendStateEvent(roomId, eventType, eventContent3, stateKey3), + ]); + }) + .then((events) => { + openIntegrationManager(); + + // Read state events + sendActionFromIntegrationManager( + integrationManagerUrl, + roomId, + eventType, + true, // Any state key + ); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response") + .should("include.text", events[0].event_id) + .should("include.text", `"content":${JSON.stringify(eventContent1)}`) + .should("include.text", events[1].event_id) + .should("include.text", `"content":${JSON.stringify(eventContent2)}`) + .should("include.text", events[2].event_id) + .should("include.text", `"content":${JSON.stringify(eventContent3)}`); + }); + }); + }); + }); + + it("should fail to read an event type which is not allowed", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + const eventType = "com.example.event"; + const stateKey = ""; + + openIntegrationManager(); + + // Read state events + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "Failed to read events"); + }); + }); + }); +}); diff --git a/cypress/e2e/integration-manager/send_event.spec.ts b/cypress/e2e/integration-manager/send_event.spec.ts new file mode 100644 index 000000000000..7b706b047d87 --- /dev/null +++ b/cypress/e2e/integration-manager/send_event.spec.ts @@ -0,0 +1,261 @@ +/* +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 { SynapseInstance } from "../../plugins/synapsedocker"; +import { UserCredentials } from "../../support/login"; + +const ROOM_NAME = "Integration Manager Test"; +const USER_DISPLAY_NAME = "Alice"; + +const INTEGRATION_MANAGER_TOKEN = "DefinitelySecret_DoNotUseThisForReal"; +const INTEGRATION_MANAGER_HTML = ` + + + Fake Integration Manager + + + + + + + + +

No response

+ + + +`; + +function openIntegrationManager() { + cy.get(".mx_RightPanel_roomSummaryButton").click(); + cy.get(".mx_RoomSummaryCard_appsGroup").within(() => { + cy.contains("Add widgets, bridges & bots").click(); + }); +} + +function sendActionFromIntegrationManager( + integrationManagerUrl: string, + targetRoomId: string, + eventType: string, + stateKey: string, + content: Record, +) { + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#target-room-id").should("exist").type(targetRoomId); + cy.get("#event-type").should("exist").type(eventType); + if (stateKey) { + cy.get("#state-key").should("exist").type(stateKey); + } + cy.get("#event-content").should("exist").type(JSON.stringify(content), { parseSpecialCharSequences: false }); + cy.get("#send-action").should("exist").click(); + }); +} + +describe("Integration Manager: Send Event", () => { + let testUser: UserCredentials; + let synapse: SynapseInstance; + let integrationManagerUrl: string; + + beforeEach(() => { + cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { + integrationManagerUrl = url; + }); + cy.startSynapse("default").then((data) => { + synapse = data; + + cy.initTestUser(synapse, USER_DISPLAY_NAME, () => { + cy.window().then((win) => { + win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); + win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); + }); + }).then((user) => { + testUser = user; + }); + + cy.setAccountData("m.widgets", { + "m.integration_manager": { + content: { + type: "m.integration_manager", + name: "Integration Manager", + url: integrationManagerUrl, + data: { + api_url: integrationManagerUrl, + }, + }, + id: "integration-manager", + }, + }).as("integrationManager"); + + // Succeed when checking the token is valid + cy.intercept(`${integrationManagerUrl}/account?scalar_token=${INTEGRATION_MANAGER_TOKEN}*`, (req) => { + req.continue((res) => { + return res.send(200, { + user_id: testUser.userId, + }); + }); + }); + + cy.createRoom({ + name: ROOM_NAME, + }).as("roomId"); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + cy.stopWebServers(); + }); + + it("should send a state event", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + openIntegrationManager(); + + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = "state-key-123"; + + // Send the event + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "event_id"); + }); + + // Check the event + cy.getClient() + .then(async (client) => { + return await client.getStateEvent(roomId, eventType, stateKey); + }) + .then((event) => { + expect(event).to.deep.equal(eventContent); + }); + }); + }); + + it("should send a state event with empty content", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + openIntegrationManager(); + + const eventType = "io.element.integrations.installations"; + const eventContent = {}; + const stateKey = "state-key-123"; + + // Send the event + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "event_id"); + }); + + // Check the event + cy.getClient() + .then(async (client) => { + return await client.getStateEvent(roomId, eventType, stateKey); + }) + .then((event) => { + expect(event).to.be.empty; + }); + }); + }); + + it("should send a state event with empty state key", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + openIntegrationManager(); + + const eventType = "io.element.integrations.installations"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send the event + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "event_id"); + }); + + // Check the event + cy.getClient() + .then(async (client) => { + return await client.getStateEvent(roomId, eventType, stateKey); + }) + .then((event) => { + expect(event).to.deep.equal(eventContent); + }); + }); + }); + + it("should fail to send an event type which is not allowed", () => { + cy.all([cy.get("@roomId"), cy.get<{}>("@integrationManager")]).then(([roomId]) => { + cy.viewRoomByName(ROOM_NAME); + + openIntegrationManager(); + + const eventType = "com.example.event"; + const eventContent = { + foo: "bar", + }; + const stateKey = ""; + + // Send the event + sendActionFromIntegrationManager(integrationManagerUrl, roomId, eventType, stateKey, eventContent); + + // Check the response + cy.accessIframe(`iframe[src*="${integrationManagerUrl}"]`).within(() => { + cy.get("#message-response").should("include.text", "Failed to send event"); + }); + }); + }); +}); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index 78c83c5c8e0e..750cd566bb7f 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -51,6 +51,10 @@ const defaultCreateBotOptions = { bootstrapCrossSigning: true, } as CreateBotOpts; +export interface CypressBot extends MatrixClient { + __cypress_password: string; +} + declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { @@ -60,7 +64,7 @@ declare global { * @param synapse the instance on which to register the bot user * @param opts create bot options */ - getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable; + getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable; /** * Returns a new Bot instance logged in as an existing user * @param synapse the instance on which to register the bot user @@ -156,14 +160,20 @@ function setupBotClient( }); } -Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable => { +Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable => { opts = Object.assign({}, defaultCreateBotOptions, opts); const username = Cypress._.uniqueId(opts.userIdPrefix); const password = Cypress._.uniqueId("password_"); - return cy.registerUser(synapse, username, password, opts.displayName).then((credentials) => { - cy.log(`Registered bot user ${username} with displayname ${opts.displayName}`); - return setupBotClient(synapse, credentials, opts); - }); + return cy + .registerUser(synapse, username, password, opts.displayName) + .then((credentials) => { + cy.log(`Registered bot user ${username} with displayname ${opts.displayName}`); + return setupBotClient(synapse, credentials, opts); + }) + .then((client): Chainable => { + Object.assign(client, { __cypress_password: password }); + return cy.wrap(client as CypressBot); + }); }); Cypress.Commands.add( diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 1abf50ee4117..bbc6a3eadc10 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -62,7 +62,6 @@ @import "./structures/_MainSplit.pcss"; @import "./structures/_MatrixChat.pcss"; @import "./structures/_NonUrgentToastContainer.pcss"; -@import "./structures/_NotificationPanel.pcss"; @import "./structures/_QuickSettingsButton.pcss"; @import "./structures/_RightPanel.pcss"; @import "./structures/_RoomSearch.pcss"; diff --git a/res/css/structures/_NotificationPanel.pcss b/res/css/structures/_NotificationPanel.pcss deleted file mode 100644 index 2ff3335280b8..000000000000 --- a/res/css/structures/_NotificationPanel.pcss +++ /dev/null @@ -1,113 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd - -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_NotificationPanel { - order: 2; - flex: 1 1 0; - overflow-y: auto; - display: flex; - - .mx_RoomView_messageListWrapper { - flex-direction: row; - align-items: center; - justify-content: center; - } - - .mx_RoomView_MessageList { - width: 100%; - - h2 { - margin-left: 0; - } - } - - /* FIXME: rather than having EventTile's default CSS be for MessagePanel, - we should make EventTile a base CSS class and customise it specifically - for usage in {Message,File,Notification}Panel. */ - - .mx_EventTile_avatar { - display: none; - } - - .mx_EventTile { - word-break: break-word; - position: relative; - padding-block: 18px; - - .mx_EventTile_senderDetails, - .mx_EventTile_line { - padding-left: 36px; /* align with the room name */ - } - - .mx_EventTile_senderDetails { - position: relative; - - a { - display: flex; - column-gap: 5px; /* TODO: Use a spacing variable */ - } - } - - .mx_DisambiguatedProfile, - .mx_MessageTimestamp { - color: $primary-content; - font-size: $font-12px; - display: inline; - } - - &:hover .mx_EventTile_line { - background-color: $background; - } - - &:not(.mx_EventTile_last):not(.mx_EventTile_lastInSection)::after { - position: absolute; - bottom: 0; - left: 0; - right: 0; - background-color: $tertiary-content; - height: 1px; - opacity: 0.4; - content: ""; - } - } - - .mx_EventTile_roomName { - display: flex; - align-items: center; - column-gap: $spacing-8; - font-weight: bold; - font-size: $font-14px; - - a { - color: $primary-content; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - } - - .mx_EventTile_selected .mx_EventTile_line { - padding-left: 0; - } - - .mx_EventTile_content { - margin-right: 0; - } -} - -.mx_NotificationPanel_empty::before { - mask-image: url("$(res)/img/element-icons/notifications.svg"); -} diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss index d21bbdcdc065..22720a99e034 100644 --- a/res/css/views/right_panel/_BaseCard.pcss +++ b/res/css/views/right_panel/_BaseCard.pcss @@ -210,7 +210,6 @@ limitations under the License. .mx_FilePanel, .mx_UserInfo, -.mx_NotificationPanel, .mx_MemberList { &.mx_BaseCard { padding: $spacing-32 0 0; diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 9932fcfd9f32..1169f5138871 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -836,7 +836,8 @@ $left-gutter: 64px; } } -.mx_EventTile[data-shape="ThreadsList"] { +.mx_EventTile[data-shape="ThreadsList"], +.mx_EventTile[data-shape="Notification"] { --topOffset: $spacing-12; --leftOffset: 48px; $borderRadius: 8px; @@ -916,9 +917,7 @@ $left-gutter: 64px; } .mx_DisambiguatedProfile { - margin-inline: 0 $spacing-12; display: inline-flex; - flex: 1; .mx_DisambiguatedProfile_displayName, .mx_DisambiguatedProfile_mxid { @@ -941,6 +940,7 @@ $left-gutter: 64px; width: 100%; box-sizing: border-box; padding-bottom: 0; + padding-inline-start: var(--leftOffset); .mx_ThreadPanel_replies { margin-top: $spacing-8; @@ -966,11 +966,6 @@ $left-gutter: 64px; } } - .mx_DisambiguatedProfile, - .mx_EventTile_line { - padding-inline-start: var(--leftOffset); - } - .mx_MessageTimestamp { font-size: $font-12px; max-width: var(--MessageTimestamp-max-width); @@ -1300,6 +1295,21 @@ $left-gutter: 64px; } } +.mx_EventTile_details { + display: flex; + width: -webkit-fill-available; + align-items: center; + justify-content: space-between; + gap: $spacing-8; + margin-left: var(--leftOffset); + .mx_EventTile_truncated { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + /* Media query for mobile UI */ @media only screen and (max-width: 480px) { .mx_EventTile_content { diff --git a/src/ScalarMessaging.ts b/src/ScalarMessaging.ts index 14c56bef41aa..b1912c484af3 100644 --- a/src/ScalarMessaging.ts +++ b/src/ScalarMessaging.ts @@ -264,10 +264,36 @@ Get an openID token for the current user session. Request: No parameters Response: - The openId token object as described in https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3useruseridopenidrequest_token + +send_event +---------- +Sends an event in a room. + +Request: + - type is the event type to send. + - state_key is the state key to send. Omitted if not a state event. + - content is the event content to send. + +Response: + - room_id is the room ID where the event was sent. + - event_id is the event ID of the event which was sent. + +read_events +----------- +Read events from a room. + +Request: + - type is the event type to read. + - state_key is the state key to read, or `true` to read all events of the type. Omitted if not a state event. + +Response: + - events: Array of events. If none found, this will be an empty array. + */ -import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event"; import { logger } from "matrix-js-sdk/src/logger"; +import { IEvent } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "./MatrixClientPeg"; import dis from "./dispatcher/dispatcher"; @@ -295,6 +321,8 @@ enum Action { SetBotOptions = "set_bot_options", SetBotPower = "set_bot_power", GetOpenIdToken = "get_open_id_token", + SendEvent = "send_event", + ReadEvents = "read_events", } function sendResponse(event: MessageEvent, res: any): void { @@ -468,13 +496,13 @@ function setWidget(event: MessageEvent, roomId: string | null): void { } } -function getWidgets(event: MessageEvent, roomId: string): void { +function getWidgets(event: MessageEvent, roomId: string | null): void { const client = MatrixClientPeg.get(); if (!client) { sendError(event, _t("You need to be logged in.")); return; } - let widgetStateEvents = []; + let widgetStateEvents: Partial[] = []; if (roomId) { const room = client.getRoom(roomId); @@ -693,6 +721,141 @@ async function getOpenIdToken(event: MessageEvent) { } } +async function sendEvent( + event: MessageEvent<{ + type: string; + state_key?: string; + content?: IContent; + }>, + roomId: string, +) { + const eventType = event.data.type; + const stateKey = event.data.state_key; + const content = event.data.content; + + if (typeof eventType !== "string") { + sendError(event, _t("Failed to send event"), new Error("Invalid 'type' in request")); + return; + } + const allowedEventTypes = ["m.widgets", "im.vector.modular.widgets", "io.element.integrations.installations"]; + if (!allowedEventTypes.includes(eventType)) { + sendError(event, _t("Failed to send event"), new Error("Disallowed 'type' in request")); + return; + } + + if (!content || typeof content !== "object") { + sendError(event, _t("Failed to send event"), new Error("Invalid 'content' in request")); + return; + } + + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t("You need to be logged in.")); + return; + } + + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t("This room is not recognised.")); + return; + } + + if (stateKey !== undefined) { + // state event + try { + const res = await client.sendStateEvent(roomId, eventType, content, stateKey); + sendResponse(event, { + room_id: roomId, + event_id: res.event_id, + }); + } catch (e) { + sendError(event, _t("Failed to send event"), e as Error); + return; + } + } else { + // message event + sendError(event, _t("Failed to send event"), new Error("Sending message events is not implemented")); + return; + } +} + +async function readEvents( + event: MessageEvent<{ + type: string; + state_key?: string | boolean; + limit?: number; + }>, + roomId: string, +) { + const eventType = event.data.type; + const stateKey = event.data.state_key; + const limit = event.data.limit; + + if (typeof eventType !== "string") { + sendError(event, _t("Failed to read events"), new Error("Invalid 'type' in request")); + return; + } + const allowedEventTypes = [ + "m.room.power_levels", + "m.room.encryption", + "m.room.member", + "m.room.name", + "m.widgets", + "im.vector.modular.widgets", + "io.element.integrations.installations", + ]; + if (!allowedEventTypes.includes(eventType)) { + sendError(event, _t("Failed to read events"), new Error("Disallowed 'type' in request")); + return; + } + + let effectiveLimit: number; + if (limit !== undefined) { + if (typeof limit !== "number" || limit < 0) { + sendError(event, _t("Failed to read events"), new Error("Invalid 'limit' in request")); + return; + } + effectiveLimit = Math.min(limit, Number.MAX_SAFE_INTEGER); + } else { + effectiveLimit = Number.MAX_SAFE_INTEGER; + } + + const client = MatrixClientPeg.get(); + if (!client) { + sendError(event, _t("You need to be logged in.")); + return; + } + + const room = client.getRoom(roomId); + if (!room) { + sendError(event, _t("This room is not recognised.")); + return; + } + + if (stateKey !== undefined) { + // state events + if (typeof stateKey !== "string" && stateKey !== true) { + sendError(event, _t("Failed to read events"), new Error("Invalid 'state_key' in request")); + return; + } + // When `true` is passed for state key, get events with any state key. + const effectiveStateKey = stateKey === true ? undefined : stateKey; + + let events: MatrixEvent[] = []; + events = events.concat(room.currentState.getStateEvents(eventType, effectiveStateKey as string) || []); + events = events.slice(0, effectiveLimit); + + sendResponse(event, { + events: events.map((e) => e.getEffectiveEvent()), + }); + return; + } else { + // message events + sendError(event, _t("Failed to read events"), new Error("Reading message events is not implemented")); + return; + } +} + const onMessage = function (event: MessageEvent): void { if (!event.origin) { // stupid chrome @@ -786,6 +949,12 @@ const onMessage = function (event: MessageEvent): void { } else if (event.data.action === Action.CanSendEvent) { canSendEvent(event, roomId); return; + } else if (event.data.action === Action.SendEvent) { + sendEvent(event, roomId); + return; + } else if (event.data.action === Action.ReadEvents) { + readEvents(event, roomId); + return; } if (!userId) { diff --git a/src/components/structures/NotificationPanel.tsx b/src/components/structures/NotificationPanel.tsx index cf3f3e0fbaf0..9e4365880ea6 100644 --- a/src/components/structures/NotificationPanel.tsx +++ b/src/components/structures/NotificationPanel.tsx @@ -25,6 +25,7 @@ import Spinner from "../views/elements/Spinner"; import { Layout } from "../../settings/enums/Layout"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import Measured from "../views/elements/Measured"; +import Heading from "../views/typography/Heading"; interface IProps { onClose(): void; @@ -90,8 +91,21 @@ export default class NotificationPanel extends React.PureComponent - - + + {_t("Notifications")} + + } + /** + * Need to rename this CSS class to something more generic + * Will be done once all the panels are using a similar layout + */ + className="mx_ThreadPanel" + onClose={this.props.onClose} + withoutScrollContainer={true} + > + {this.card.current && } {content} diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index e34bf8716f6c..32136018a88b 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -29,8 +29,6 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; -import { Icon as LinkIcon } from "../../../../res/img/element-icons/link.svg"; -import { Icon as ViewInRoomIcon } from "../../../../res/img/element-icons/view-in-room.svg"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; @@ -63,8 +61,6 @@ import SettingsStore from "../../../settings/SettingsStore"; import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; -import Toolbar from "../../../accessibility/Toolbar"; -import { RovingAccessibleTooltipButton } from "../../../accessibility/roving/RovingAccessibleTooltipButton"; import { ThreadNotificationState } from "../../../stores/notifications/ThreadNotificationState"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; @@ -85,6 +81,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa import { isLocalRoom } from "../../../utils/localRoom/isLocalRoom"; import { ElementCall } from "../../../models/Call"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; +import { EventTileThreadToolbar } from "./EventTile/EventTileThreadToolbar"; export type GetRelationsForEvent = ( eventId: string, @@ -232,7 +229,7 @@ interface IState { // Whether the action bar is focused. actionBarFocused: boolean; // Whether the event's sender has been verified. - verified: string; + verified: string | null; // The Relations model from the JS SDK for reactions to `mxEvent` reactions?: Relations | null | undefined; @@ -278,7 +275,8 @@ export class UnwrappedEventTile extends React.Component this.state = { // Whether the action bar is focused. actionBarFocused: false, - // Whether the event's sender has been verified. + // Whether the event's sender has been verified. `null` if no attempt has yet been made to verify + // (including if the event is not encrypted). verified: null, // The Relations model from the JS SDK for reactions to `mxEvent` reactions: this.getReactions(), @@ -371,6 +369,7 @@ export class UnwrappedEventTile extends React.Component client.on(CryptoEvent.DeviceVerificationChanged, this.onDeviceVerificationChanged); client.on(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged); this.props.mxEvent.on(MatrixEventEvent.Decrypted, this.onDecrypted); + this.props.mxEvent.on(MatrixEventEvent.Replaced, this.onReplaced); DecryptionFailureTracker.instance.addVisibleEvent(this.props.mxEvent); if (this.props.showReactions) { this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); @@ -395,7 +394,7 @@ export class UnwrappedEventTile extends React.Component const room = client.getRoom(this.props.mxEvent.getRoomId()); room?.on(ThreadEvent.New, this.onNewThread); - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); } private get supportsThreadNotifications(): boolean { @@ -461,6 +460,7 @@ export class UnwrappedEventTile extends React.Component } this.isListeningForReceipts = false; this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); + this.props.mxEvent.removeListener(MatrixEventEvent.Replaced, this.onReplaced); if (this.props.showReactions) { this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); } @@ -470,7 +470,11 @@ export class UnwrappedEventTile extends React.Component this.threadState?.off(NotificationStateEvents.Update, this.onThreadStateUpdate); } - public componentDidUpdate(prevProps: Readonly) { + public componentDidUpdate(prevProps: Readonly, prevState: Readonly) { + // If the verification state changed, the height might have changed + if (prevState.verified !== this.state.verified && this.props.onHeightChanged) { + this.props.onHeightChanged(); + } // If we're not listening for receipts and expect to be, register a listener. if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt); @@ -478,7 +482,7 @@ export class UnwrappedEventTile extends React.Component } // re-check the sender verification as outgoing events progress through the send process. if (prevProps.eventSendStatus !== this.props.eventSendStatus) { - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); } } @@ -586,26 +590,36 @@ export class UnwrappedEventTile extends React.Component */ private onDecrypted = () => { // we need to re-verify the sending device. - // (we call onHeightChanged in verifyEvent to handle the case where decryption - // has caused a change in size of the event tile) - this.verifyEvent(this.props.mxEvent); - this.forceUpdate(); + this.verifyEvent(); + // decryption might, of course, trigger a height change, so call onHeightChanged after the re-render + this.forceUpdate(this.props.onHeightChanged); }; private onDeviceVerificationChanged = (userId: string, device: string): void => { if (userId === this.props.mxEvent.getSender()) { - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); } }; private onUserVerificationChanged = (userId: string, _trustStatus: UserTrustLevel): void => { if (userId === this.props.mxEvent.getSender()) { - this.verifyEvent(this.props.mxEvent); + this.verifyEvent(); } }; - private async verifyEvent(mxEvent: MatrixEvent): Promise { + /** called when the event is edited after we show it. */ + private onReplaced = () => { + // re-verify the event if it is replaced (the edit may not be verified) + this.verifyEvent(); + }; + + private verifyEvent(): void { + // if the event was edited, show the verification info for the edit, not + // the original + const mxEvent = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; + if (!mxEvent.isEncrypted() || mxEvent.isRedacted()) { + this.setState({ verified: null }); return; } @@ -615,12 +629,7 @@ export class UnwrappedEventTile extends React.Component if (encryptionInfo.mismatchedSender) { // something definitely wrong is going on here - this.setState( - { - verified: E2EState.Warning, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Warning }); return; } @@ -628,53 +637,28 @@ export class UnwrappedEventTile extends React.Component // If the message is unauthenticated, then display a grey // shield, otherwise if the user isn't cross-signed then // nothing's needed - this.setState( - { - verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: encryptionInfo.authenticated ? E2EState.Normal : E2EState.Unauthenticated }); return; } const eventSenderTrust = encryptionInfo.sender && MatrixClientPeg.get().checkDeviceTrust(senderId, encryptionInfo.sender.deviceId); if (!eventSenderTrust) { - this.setState( - { - verified: E2EState.Unknown, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Unknown }); return; } if (!eventSenderTrust.isVerified()) { - this.setState( - { - verified: E2EState.Warning, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Warning }); return; } if (!encryptionInfo.authenticated) { - this.setState( - { - verified: E2EState.Unauthenticated, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Unauthenticated }); return; } - this.setState( - { - verified: E2EState.Verified, - }, - this.props.onHeightChanged, - ); // Decryption may have caused a change in size + this.setState({ verified: E2EState.Verified }); } private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { @@ -768,7 +752,9 @@ export class UnwrappedEventTile extends React.Component }; private renderE2EPadlock() { - const ev = this.props.mxEvent; + // if the event was edited, show the verification info for the edit, not + // the original + const ev = this.props.mxEvent.replacingEvent() ?? this.props.mxEvent; // no icon for local rooms if (isLocalRoom(ev.getRoomId()!)) return; @@ -983,6 +969,8 @@ export class UnwrappedEventTile extends React.Component isContinuation = false; } + const isRenderingNotification = this.context.timelineRenderingType === TimelineRenderingType.Notification; + const isEditing = !!this.props.editState; const classes = classNames({ mx_EventTile_bubbleContainer: isBubbleMessage, @@ -1007,7 +995,8 @@ export class UnwrappedEventTile extends React.Component mx_EventTile_bad: isEncryptionFailure, mx_EventTile_emote: msgtype === MsgType.Emote, mx_EventTile_noSender: this.props.hideSender, - mx_EventTile_clamp: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList, + mx_EventTile_clamp: + this.context.timelineRenderingType === TimelineRenderingType.ThreadsList || isRenderingNotification, mx_EventTile_noBubble: noBubbleEvent, }); @@ -1023,12 +1012,12 @@ export class UnwrappedEventTile extends React.Component // Local echos have a send "status". const scrollToken = this.props.mxEvent.status ? undefined : this.props.mxEvent.getId(); - let avatar: JSX.Element; - let sender: JSX.Element; + let avatar: JSX.Element | null = null; + let sender: JSX.Element | null = null; let avatarSize: number; let needsSenderProfile: boolean; - if (this.context.timelineRenderingType === TimelineRenderingType.Notification) { + if (isRenderingNotification) { avatarSize = 24; needsSenderProfile = true; } else if (isInfoMessage) { @@ -1072,7 +1061,9 @@ export class UnwrappedEventTile extends React.Component member = this.props.mxEvent.sender; } // In the ThreadsList view we use the entire EventTile as a click target to open the thread instead - const viewUserOnClick = this.context.timelineRenderingType !== TimelineRenderingType.ThreadsList; + const viewUserOnClick = ![TimelineRenderingType.ThreadsList, TimelineRenderingType.Notification].includes( + this.context.timelineRenderingType, + ); avatar = (
const isOwnEvent = this.props.mxEvent?.getSender() === MatrixClientPeg.get().getUserId(); switch (this.context.timelineRenderingType) { - case TimelineRenderingType.Notification: { - const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); - return React.createElement( - this.props.as || "li", - { - "className": classes, - "aria-live": ariaLive, - "aria-atomic": true, - "data-scroll-tokens": scrollToken, - }, - [ - , - , -
- {this.renderContextMenu()} - {renderTile( - TimelineRenderingType.Notification, - { - ...this.props, - - // overrides - ref: this.tile, - isSeeingThroughMessageHiddenForModeration, - - // appease TS - highlights: this.props.highlights, - highlightLink: this.props.highlightLink, - onHeightChanged: this.props.onHeightChanged, - permalinkCreator: this.props.permalinkCreator, - }, - this.context.showHiddenEvents, - )} -
, - ], - ); - } case TimelineRenderingType.Thread: { return React.createElement( this.props.as || "li", @@ -1300,8 +1240,8 @@ export class UnwrappedEventTile extends React.Component // appease TS highlights: this.props.highlights, highlightLink: this.props.highlightLink, - onHeightChanged: this.props.onHeightChanged, - permalinkCreator: this.props.permalinkCreator, + onHeightChanged: () => this.props.onHeightChanged, + permalinkCreator: this.props.permalinkCreator!, }, this.context.showHiddenEvents, )} @@ -1315,6 +1255,7 @@ export class UnwrappedEventTile extends React.Component ], ); } + case TimelineRenderingType.Notification: case TimelineRenderingType.ThreadsList: { const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers @@ -1337,20 +1278,48 @@ export class UnwrappedEventTile extends React.Component "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), "onClick": (ev: MouseEvent) => { - dis.dispatch({ - action: Action.ShowThread, - rootEvent: this.props.mxEvent, - push: true, - }); const target = ev.currentTarget as HTMLElement; - const index = Array.from(target.parentElement.children).indexOf(target); - PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index); + let index = -1; + if (target.parentElement) index = Array.from(target.parentElement.children).indexOf(target); + switch (this.context.timelineRenderingType) { + case TimelineRenderingType.Notification: + this.viewInRoom(ev); + break; + case TimelineRenderingType.ThreadsList: + dis.dispatch({ + action: Action.ShowThread, + rootEvent: this.props.mxEvent, + push: true, + }); + PosthogTrackers.trackInteraction("WebThreadsPanelThreadItem", ev, index ?? -1); + break; + } }, }, <> - {sender} - {avatar} - {timestamp} +
+ {sender} + {isRenderingNotification && room ? ( + + {" "} + {_t( + " in %(room)s", + { room: room.name }, + { strong: (sub) => {sub} }, + )} + + ) : ( + "" + )} + {timestamp} +
+ {isRenderingNotification && room ? ( +
+ +
+ ) : ( + avatar + )}
{this.props.mxEvent.isRedacted() ? ( @@ -1361,24 +1330,13 @@ export class UnwrappedEventTile extends React.Component
{this.renderThreadPanelSummary()}
- - - - - - - - + {this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( + + )} + {msgOption} , diff --git a/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx b/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx new file mode 100644 index 000000000000..0c4dcda8ac7b --- /dev/null +++ b/src/components/views/rooms/EventTile/EventTileThreadToolbar.tsx @@ -0,0 +1,53 @@ +/* +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 { RovingAccessibleTooltipButton } from "../../../../accessibility/RovingTabIndex"; +import Toolbar from "../../../../accessibility/Toolbar"; +import { _t } from "../../../../languageHandler"; +import { Icon as LinkIcon } from "../../../../../res/img/element-icons/link.svg"; +import { Icon as ViewInRoomIcon } from "../../../../../res/img/element-icons/view-in-room.svg"; +import { ButtonEvent } from "../../elements/AccessibleButton"; + +export function EventTileThreadToolbar({ + viewInRoom, + copyLinkToThread, +}: { + viewInRoom: (evt: ButtonEvent) => void; + copyLinkToThread: (evt: ButtonEvent) => void; +}) { + return ( + + + + + + + + + ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/DynamicImportWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/DynamicImportWysiwygComposer.tsx new file mode 100644 index 000000000000..65a365b06dab --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/DynamicImportWysiwygComposer.tsx @@ -0,0 +1,36 @@ +/* +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, { ComponentProps, lazy, Suspense } from "react"; + +const SendComposer = lazy(() => import("./SendWysiwygComposer")); +const EditComposer = lazy(() => import("./EditWysiwygComposer")); + +export function DynamicImportSendWysiwygComposer(props: ComponentProps) { + return ( + }> + + + ); +} + +export function DynamicImportEditWysiwygComposer(props: ComponentProps) { + return ( + }> + + + ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 275d30bb1f43..36264b77639f 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -43,32 +43,35 @@ interface EditWysiwygComposerProps { className?: string; } -export function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) { +// Default needed for React.lazy +export default function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) { const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(editorStateTransfer, initialContent); + if (!isReady) { + return null; + } + return ( - isReady && ( - - {(ref) => ( - <> - - - - )} - - ) + + {(ref) => ( + <> + + + + )} + ); } diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 4d7267216749..78b24bb50727 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -49,7 +49,8 @@ interface SendWysiwygComposerProps { menuPosition: AboveLeftOf; } -export function SendWysiwygComposer({ +// Default needed for React.lazy +export default function SendWysiwygComposer({ isRichTextEnabled, e2eStatus, menuPosition, diff --git a/src/components/views/rooms/wysiwyg_composer/index.ts b/src/components/views/rooms/wysiwyg_composer/index.ts index 55a3e79a19d1..c82f59ca896b 100644 --- a/src/components/views/rooms/wysiwyg_composer/index.ts +++ b/src/components/views/rooms/wysiwyg_composer/index.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ -export { SendWysiwygComposer } from "./SendWysiwygComposer"; -export { EditWysiwygComposer } from "./EditWysiwygComposer"; +export { + DynamicImportSendWysiwygComposer as SendWysiwygComposer, + DynamicImportEditWysiwygComposer as EditWysiwygComposer, +} from "./DynamicImportWysiwygComposer"; export { sendMessage } from "./utils/message"; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 306e04835558..43247b0bd1d2 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -390,6 +390,8 @@ "Power level must be positive integer.": "Power level must be positive integer.", "You are not in this room.": "You are not in this room.", "You do not have permission to do that in this room.": "You do not have permission to do that in this room.", + "Failed to send event": "Failed to send event", + "Failed to read events": "Failed to read events", "Missing room_id in request": "Missing room_id in request", "Room %(roomId)s not visible": "Room %(roomId)s not visible", "Missing user_id in request": "Missing user_id in request", @@ -1877,9 +1879,7 @@ "Mod": "Mod", "From a thread": "From a thread", "This event could not be displayed": "This event could not be displayed", - "Message Actions": "Message Actions", - "View in room": "View in room", - "Copy link to thread": "Copy link to thread", + " in %(room)s": " in %(room)s", "Encrypted by an unverified session": "Encrypted by an unverified session", "Unencrypted": "Unencrypted", "Encrypted by a deleted session": "Encrypted by a deleted session", @@ -2128,6 +2128,9 @@ "Italic": "Italic", "Underline": "Underline", "Code": "Code", + "Message Actions": "Message Actions", + "View in room": "View in room", + "Copy link to thread": "Copy link to thread", "Error updating main address": "Error updating main address", "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's main address. It may not be allowed by the server or a temporary failure occurred.", "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.": "There was an error updating the room's alternative addresses. It may not be allowed by the server or a temporary failure occurred.", diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index bebec2efc845..f425bc5aa55e 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -14,26 +14,33 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; -import { act, render, screen, waitFor } from "@testing-library/react"; +import * as React from "react"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { DeviceTrustLevel, UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; +import { DeviceInfo } from "matrix-js-sdk/src/crypto/deviceinfo"; +import { IEncryptedEventInfo } from "matrix-js-sdk/src/crypto/api"; +import { render, waitFor, screen, act, fireEvent } from "@testing-library/react"; import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import { getRoomContext, mkEvent, mkMessage, stubClient } from "../../../test-utils"; +import { getRoomContext, mkEncryptedEvent, mkEvent, mkMessage, stubClient } from "../../../test-utils"; import { mkThread } from "../../../test-utils/threads"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import dis from "../../../../src/dispatcher/dispatcher"; +import { Action } from "../../../../src/dispatcher/actions"; describe("EventTile", () => { const ROOM_ID = "!roomId:example.org"; let mxEvent: MatrixEvent; let room: Room; let client: MatrixClient; + // let changeEvent: (event: MatrixEvent) => void; function TestEventTile(props: Partial) { @@ -67,7 +74,7 @@ describe("EventTile", () => { stubClient(); client = MatrixClientPeg.get(); - room = new Room(ROOM_ID, client, client.getUserId(), { + room = new Room(ROOM_ID, client, client.getUserId()!, { pendingEventOrdering: PendingEventOrdering.Detached, }); @@ -140,18 +147,231 @@ describe("EventTile", () => { expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0); act(() => { - room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Total, 3); + room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Total, 3); }); expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(0); act(() => { - room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Highlight, 1); + room.setThreadUnreadNotificationCount(mxEvent.getId()!, NotificationCountType.Highlight, 1); }); expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(1); }); }); + + describe("EventTile in the right panel", () => { + beforeAll(() => { + const dmRoomMap: DMRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + DMRoomMap.setShared(dmRoomMap); + }); + + it("renders the room name for notifications", () => { + const { container } = getComponent({}, TimelineRenderingType.Notification); + expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent( + "@alice:example.org in !roomId:example.org", + ); + }); + + it("renders the sender for the thread list", () => { + const { container } = getComponent({}, TimelineRenderingType.ThreadsList); + expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent("@alice:example.org"); + }); + + it.each([ + [TimelineRenderingType.Notification, Action.ViewRoom], + [TimelineRenderingType.ThreadsList, Action.ShowThread], + ])("type %s dispatches %s", (renderingType, action) => { + jest.spyOn(dis, "dispatch"); + + const { container } = getComponent({}, renderingType); + + fireEvent.click(container.querySelector("li")!); + + expect(dis.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + action, + }), + ); + }); + }); + describe("Event verification", () => { + // data for our stubbed getEventEncryptionInfo: a map from event id to result + const eventToEncryptionInfoMap = new Map(); + + const TRUSTED_DEVICE = DeviceInfo.fromStorage({}, "TRUSTED_DEVICE"); + const UNTRUSTED_DEVICE = DeviceInfo.fromStorage({}, "UNTRUSTED_DEVICE"); + + beforeEach(() => { + eventToEncryptionInfoMap.clear(); + + // a mocked version of getEventEncryptionInfo which will pick its result from `eventToEncryptionInfoMap` + client.getEventEncryptionInfo = (event) => eventToEncryptionInfoMap.get(event.getId()!)!; + + // a mocked version of checkUserTrust which always says the user is trusted (we do our testing via + // unverified devices). + const trustedUserTrustLevel = new UserTrustLevel(true, true, true); + client.checkUserTrust = (_userId) => trustedUserTrustLevel; + + // a version of checkDeviceTrust which says that TRUSTED_DEVICE is trusted, and others are not. + const trustedDeviceTrustLevel = DeviceTrustLevel.fromUserTrustLevel(trustedUserTrustLevel, true, false); + const untrustedDeviceTrustLevel = DeviceTrustLevel.fromUserTrustLevel(trustedUserTrustLevel, false, false); + client.checkDeviceTrust = (userId, deviceId) => { + if (deviceId === TRUSTED_DEVICE.deviceId) { + return trustedDeviceTrustLevel; + } else { + return untrustedDeviceTrustLevel; + } + }; + }); + + it("shows a warning for an event from an unverified device", async () => { + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + authenticated: true, + sender: UNTRUSTED_DEVICE, + } as IEncryptedEventInfo); + + const { container } = getComponent(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + const eventTile = eventTiles[0]; + + expect(eventTile.classList).toContain("mx_EventTile_unverified"); + + // there should be a warning shield + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain( + "mx_EventTile_e2eIcon_warning", + ); + }); + + it("shows no shield for a verified event", async () => { + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + authenticated: true, + sender: TRUSTED_DEVICE, + } as IEncryptedEventInfo); + + const { container } = getComponent(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + const eventTile = eventTiles[0]; + + expect(eventTile.classList).toContain("mx_EventTile_verified"); + + // there should be no warning + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + }); + + it("should update the warning when the event is edited", async () => { + // we start out with an event from the trusted device + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + authenticated: true, + sender: TRUSTED_DEVICE, + } as IEncryptedEventInfo); + + const { container } = getComponent(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + const eventTile = eventTiles[0]; + + expect(eventTile.classList).toContain("mx_EventTile_verified"); + + // there should be no warning + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + + // then we replace the event with one from the unverified device + const replacementEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(replacementEvent.getId()!, { + authenticated: true, + sender: UNTRUSTED_DEVICE, + } as IEncryptedEventInfo); + + act(() => { + mxEvent.makeReplaced(replacementEvent); + }); + + // check it was updated + expect(eventTile.classList).toContain("mx_EventTile_unverified"); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain( + "mx_EventTile_e2eIcon_warning", + ); + }); + + it("should update the warning when the event is replaced with an unencrypted one", async () => { + jest.spyOn(client, "isRoomEncrypted").mockReturnValue(true); + + // we start out with an event from the trusted device + mxEvent = await mkEncryptedEvent({ + plainContent: { msgtype: "m.text", body: "msg1" }, + plainType: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + }); + eventToEncryptionInfoMap.set(mxEvent.getId()!, { + authenticated: true, + sender: TRUSTED_DEVICE, + } as IEncryptedEventInfo); + + const { container } = getComponent(); + + const eventTiles = container.getElementsByClassName("mx_EventTile"); + expect(eventTiles).toHaveLength(1); + const eventTile = eventTiles[0]; + + expect(eventTile.classList).toContain("mx_EventTile_verified"); + + // there should be no warning + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0); + + // then we replace the event with an unencrypted one + const replacementEvent = await mkMessage({ + msg: "msg2", + user: "@alice:example.org", + room: room.roomId, + event: true, + }); + + act(() => { + mxEvent.makeReplaced(replacementEvent); + }); + + // check it was updated + expect(eventTile.classList).not.toContain("mx_EventTile_verified"); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1); + expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0].classList).toContain( + "mx_EventTile_e2eIcon_warning", + ); + }); + }); }); diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx index cc53c88dc034..c0849145bf4c 100644 --- a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx @@ -64,13 +64,35 @@ describe("EditWysiwygComposer", () => { ); }; + it("Should not render the component when not ready", async () => { + // When + const { rerender } = customRender(false); + await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"), { + timeout: 2000, + }); + + rerender( + + + + + , + ); + + // Then + await waitFor(() => expect(screen.queryByRole("textbox")).toBeNull()); + }); + describe("Initialize with content", () => { it("Should initialize useWysiwyg with html content", async () => { // When customRender(false, editorStateTransfer); - await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true")); // Then + await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"), { + timeout: 2000, + }); + await waitFor(() => expect(screen.getByRole("textbox")).toContainHTML(mockEvent.getContent()["formatted_body"]), ); diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx index 669c611f8ce1..cdaf76d499cc 100644 --- a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx @@ -24,7 +24,7 @@ import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils"; -import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer"; +import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/"; import { aboveLeftOf } from "../../../../../src/components/structures/ContextMenu"; import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload"; import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection"; @@ -101,12 +101,12 @@ describe("SendWysiwygComposer", () => { ); }; - it("Should render WysiwygComposer when isRichTextEnabled is at true", () => { + it("Should render WysiwygComposer when isRichTextEnabled is at true", async () => { // When customRender(jest.fn(), jest.fn(), false, true); // Then - expect(screen.getByTestId("WysiwygComposer")).toBeTruthy(); + await waitFor(() => expect(screen.getByTestId("WysiwygComposer")).toBeTruthy()); }); it("Should render PlainTextComposer when isRichTextEnabled is at false", () => { diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index a842e8101c07..04ef0b358496 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -38,6 +38,8 @@ import { normalize } from "matrix-js-sdk/src/utils"; import { ReEmitter } from "matrix-js-sdk/src/ReEmitter"; import { MediaHandler } from "matrix-js-sdk/src/webrtc/mediaHandler"; import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; +import { CryptoBackend } from "matrix-js-sdk/src/common-crypto/CryptoBackend"; +import { IEventDecryptionResult } from "matrix-js-sdk/src/@types/crypto"; import type { GroupCall } from "matrix-js-sdk/src/webrtc/groupCall"; import { MatrixClientPeg as peg } from "../../src/MatrixClientPeg"; @@ -317,26 +319,48 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent { } /** - * Create an m.presence event. - * @param {Object} opts Values for the presence. - * @return {Object|MatrixEvent} The event + * Create an m.room.encrypted event + * + * @param opts - Values for the event + * @param opts.room - The ID of the room for the event + * @param opts.user - The sender of the event + * @param opts.plainType - The type the event will have, once it has been decrypted + * @param opts.plainContent - The content the event will have, once it has been decrypted */ -export function mkPresence(opts) { - if (!opts.user) { - throw new Error("Missing user"); - } - const event = { - event_id: "$" + Math.random() + "-" + Math.random(), - type: "m.presence", - sender: opts.user, - content: { - avatar_url: opts.url, - displayname: opts.name, - last_active_ago: opts.ago, - presence: opts.presence || "offline", +export async function mkEncryptedEvent(opts: { + room: Room["roomId"]; + user: User["userId"]; + plainType: string; + plainContent: IContent; +}): Promise { + // we construct an event which has been decrypted by stubbing out CryptoBackend.decryptEvent and then + // calling MatrixEvent.attemptDecryption. + + const mxEvent = mkEvent({ + type: "m.room.encrypted", + room: opts.room, + user: opts.user, + event: true, + content: {}, + }); + + const decryptionResult: IEventDecryptionResult = { + claimedEd25519Key: "", + clearEvent: { + type: opts.plainType, + content: opts.plainContent, }, + forwardingCurve25519KeyChain: [], + senderCurve25519Key: "", + untrusted: false, }; - return opts.event ? new MatrixEvent(event) : event; + + const mockCrypto = { + decryptEvent: async (_ev): Promise => decryptionResult, + } as CryptoBackend; + + await mxEvent.attemptDecryption(mockCrypto); + return mxEvent; } /**