diff --git a/.eslintrc.js b/.eslintrc.js index a65f20893b5..7c2ebb96df5 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,6 +1,9 @@ module.exports = { plugins: ["matrix-org"], extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], + parserOptions: { + project: ["./tsconfig.json"], + }, env: { browser: true, node: true, @@ -168,6 +171,12 @@ module.exports = { "@typescript-eslint/explicit-member-accessibility": "off", }, }, + { + files: ["cypress/**/*.ts"], + parserOptions: { + project: ["./cypress/tsconfig.json"], + }, + }, ], settings: { react: { diff --git a/CHANGELOG.md b/CHANGELOG.md index 5dbf6ebc47d..bfd402f79ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,65 @@ +Changes in [3.66.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.66.0) (2023-02-14) +===================================================================================================== + +## ✨ Features + * Add option to find own location in map views ([\#10083](https://github.com/matrix-org/matrix-react-sdk/pull/10083)). + * Render poll end events in timeline ([\#10027](https://github.com/matrix-org/matrix-react-sdk/pull/10027)). Contributed by @kerryarchibald. + * Indicate unread messages in tab title ([\#10096](https://github.com/matrix-org/matrix-react-sdk/pull/10096)). Contributed by @tnt7864. + * Open message in editing mode when keyboard up is pressed (RTE) ([\#10079](https://github.com/matrix-org/matrix-react-sdk/pull/10079)). Contributed by @florianduros. + * Hide superseded rooms from the room list using dynamic room predecessors ([\#10068](https://github.com/matrix-org/matrix-react-sdk/pull/10068)). Contributed by @andybalaam. + * Support MSC3946 in RoomListStore ([\#10054](https://github.com/matrix-org/matrix-react-sdk/pull/10054)). Fixes vector-im/element-web#24325. Contributed by @andybalaam. + * Auto focus security key field ([\#10048](https://github.com/matrix-org/matrix-react-sdk/pull/10048)). + * use Poll model with relations API in poll rendering ([\#9877](https://github.com/matrix-org/matrix-react-sdk/pull/9877)). Contributed by @kerryarchibald. + * Support MSC3946 in the RoomCreate tile ([\#10041](https://github.com/matrix-org/matrix-react-sdk/pull/10041)). Fixes vector-im/element-web#24323. Contributed by @andybalaam. + * Update labs flag description for RTE ([\#10058](https://github.com/matrix-org/matrix-react-sdk/pull/10058)). Contributed by @florianduros. + * Change ul list style to disc when editing message ([\#10043](https://github.com/matrix-org/matrix-react-sdk/pull/10043)). Contributed by @alunturner. + * Improved click detection within PiP windows ([\#10040](https://github.com/matrix-org/matrix-react-sdk/pull/10040)). Fixes vector-im/element-web#24371. + * Add RTE keyboard navigation in editing ([\#9980](https://github.com/matrix-org/matrix-react-sdk/pull/9980)). Fixes vector-im/element-web#23621. Contributed by @florianduros. + * Paragraph integration for rich text editor ([\#10008](https://github.com/matrix-org/matrix-react-sdk/pull/10008)). Contributed by @alunturner. + * Add indentation increasing/decreasing to RTE ([\#10034](https://github.com/matrix-org/matrix-react-sdk/pull/10034)). Contributed by @florianduros. + * Add ignore user confirmation dialog ([\#6116](https://github.com/matrix-org/matrix-react-sdk/pull/6116)). Fixes vector-im/element-web#14746. + * Use monospace font for room, message IDs in View Source modal ([\#9956](https://github.com/matrix-org/matrix-react-sdk/pull/9956)). Fixes vector-im/element-web#21937. Contributed by @paragpoddar. + * Implement MSC3946 for AdvancedRoomSettingsTab ([\#9995](https://github.com/matrix-org/matrix-react-sdk/pull/9995)). Fixes vector-im/element-web#24322. Contributed by @andybalaam. + * Implementation of MSC3824 to make the client OIDC-aware ([\#8681](https://github.com/matrix-org/matrix-react-sdk/pull/8681)). Contributed by @hughns. + * Improves a11y for avatar uploads ([\#9985](https://github.com/matrix-org/matrix-react-sdk/pull/9985)). Contributed by @GoodGuyMarco. + * Add support for [token authenticated registration](https ([\#7275](https://github.com/matrix-org/matrix-react-sdk/pull/7275)). Fixes vector-im/element-web#18931. Contributed by @govynnus. + +## 🐛 Bug Fixes + * Remove duplicate white space characters from translation keys ([\#10152](https://github.com/matrix-org/matrix-react-sdk/pull/10152)). Contributed by @luixxiul. + * Fix the caption of new sessions manager on Labs settings page for localization ([\#10143](https://github.com/matrix-org/matrix-react-sdk/pull/10143)). Contributed by @luixxiul. + * Prevent start another DM with a user if one already exists ([\#10127](https://github.com/matrix-org/matrix-react-sdk/pull/10127)). Fixes vector-im/element-web#23138. + * Remove white space characters before the horizontal ellipsis ([\#10130](https://github.com/matrix-org/matrix-react-sdk/pull/10130)). Contributed by @luixxiul. + * Fix Selectable Text on 'Delete All' and 'Retry All' Buttons ([\#10128](https://github.com/matrix-org/matrix-react-sdk/pull/10128)). Fixes vector-im/element-web#23232. Contributed by @akshattchhabra. + * Correctly Identify emoticons ([\#10108](https://github.com/matrix-org/matrix-react-sdk/pull/10108)). Fixes vector-im/element-web#19472. Contributed by @adarsh-sgh. + * Should open new 1:1 chat room after leaving the old one ([\#9880](https://github.com/matrix-org/matrix-react-sdk/pull/9880)). Contributed by @ahmadkadri. + * Remove a redundant white space ([\#10129](https://github.com/matrix-org/matrix-react-sdk/pull/10129)). Contributed by @luixxiul. + * Fix a crash when removing persistent widgets (updated) ([\#10099](https://github.com/matrix-org/matrix-react-sdk/pull/10099)). Fixes vector-im/element-web#24412. Contributed by @andybalaam. + * Fix wrongly grouping 3pid invites into a single repeated transition ([\#10087](https://github.com/matrix-org/matrix-react-sdk/pull/10087)). Fixes vector-im/element-web#24432. + * Fix scrollbar colliding with checkbox in add to space section ([\#10093](https://github.com/matrix-org/matrix-react-sdk/pull/10093)). Fixes vector-im/element-web#23189. Contributed by @Arnabdaz. + * Add a whitespace character after 'broadcast?' ([\#10097](https://github.com/matrix-org/matrix-react-sdk/pull/10097)). Contributed by @luixxiul. + * Seekbar in broadcast PiP view is now updated when switching between different broadcasts ([\#10072](https://github.com/matrix-org/matrix-react-sdk/pull/10072)). Fixes vector-im/element-web#24415. + * Add border to "reject" button on room preview card for clickable area indication. It fixes vector-im/element-web#22623 ([\#9205](https://github.com/matrix-org/matrix-react-sdk/pull/9205)). Contributed by @gefgu. + * Element-R: fix rageshages ([\#10081](https://github.com/matrix-org/matrix-react-sdk/pull/10081)). Fixes vector-im/element-web#24430. + * Fix markdown paragraph display in timeline ([\#10071](https://github.com/matrix-org/matrix-react-sdk/pull/10071)). Fixes vector-im/element-web#24419. Contributed by @alunturner. + * Prevent the remaining broadcast time from being exceeded ([\#10070](https://github.com/matrix-org/matrix-react-sdk/pull/10070)). + * Fix cursor position when new line is created by pressing enter (RTE) ([\#10064](https://github.com/matrix-org/matrix-react-sdk/pull/10064)). Contributed by @florianduros. + * Ensure room is actually in space hierarchy when resolving its latest version ([\#10010](https://github.com/matrix-org/matrix-react-sdk/pull/10010)). + * Fix new line for inline code ([\#10062](https://github.com/matrix-org/matrix-react-sdk/pull/10062)). Contributed by @florianduros. + * Member avatars without canvas ([\#9990](https://github.com/matrix-org/matrix-react-sdk/pull/9990)). Contributed by @clarkf. + * Apply more general fix for base avatar regressions ([\#10045](https://github.com/matrix-org/matrix-react-sdk/pull/10045)). Fixes vector-im/element-web#24382 and vector-im/element-web#24370. + * Replace list, code block and quote icons by new icons ([\#10035](https://github.com/matrix-org/matrix-react-sdk/pull/10035)). Contributed by @florianduros. + * fix regional emojis converted to flags ([\#9294](https://github.com/matrix-org/matrix-react-sdk/pull/9294)). Fixes vector-im/element-web#19000. Contributed by @grimhilt. + * resolved emoji description text overflowing issue ([\#10028](https://github.com/matrix-org/matrix-react-sdk/pull/10028)). Contributed by @fahadNoufal. + * Fix MessageEditHistoryDialog crashing on complex input ([\#10018](https://github.com/matrix-org/matrix-react-sdk/pull/10018)). Fixes vector-im/element-web#23665. Contributed by @clarkf. + * Unify unread notification state determination ([\#9941](https://github.com/matrix-org/matrix-react-sdk/pull/9941)). Contributed by @clarkf. + * Fix layout and visual regressions around default avatars ([\#10031](https://github.com/matrix-org/matrix-react-sdk/pull/10031)). Fixes vector-im/element-web#24375 and vector-im/element-web#24369. + * Fix useUnreadNotifications exploding with falsey room, like in notif panel ([\#10030](https://github.com/matrix-org/matrix-react-sdk/pull/10030)). Fixes matrix-org/element-web-rageshakes#19334. + * Fix "[object Promise]" appearing in HTML exports ([\#9975](https://github.com/matrix-org/matrix-react-sdk/pull/9975)). Fixes vector-im/element-web#24272. Contributed by @clarkf. + * changing the color of message time stamp ([\#10016](https://github.com/matrix-org/matrix-react-sdk/pull/10016)). Contributed by @nawarajshah. + * Fix link creation with backward selection ([\#9986](https://github.com/matrix-org/matrix-react-sdk/pull/9986)). Fixes vector-im/element-web#24315. Contributed by @florianduros. + * Misaligned reply preview in thread composer #23396 ([\#9977](https://github.com/matrix-org/matrix-react-sdk/pull/9977)). Fixes vector-im/element-web#23396. Contributed by @mustafa-kapadia1483. + + Changes in [3.65.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.65.0) (2023-01-31) ===================================================================================================== diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index 289d865ba64..48467ac6564 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -98,7 +98,7 @@ describe("Composer", () => { }); }); - describe("WYSIWYG", () => { + describe("Rich text editor", () => { beforeEach(() => { cy.enableLabsFeature("feature_wysiwyg_composer"); cy.initTestUser(homeserver, "Janet").then(() => { @@ -165,5 +165,25 @@ describe("Composer", () => { cy.contains(".mx_EventTile_body", "my message 3"); }); }); + + describe("links", () => { + it("create link with a forward selection", () => { + // Type a message + cy.get("div[contenteditable=true]").type("my message 0{selectAll}"); + + // Open link modal + cy.get('button[aria-label="Link"]').click(); + // Fill the link field + cy.get('input[label="Link"]').type("https://matrix.org/"); + // Click on save + cy.get('button[type="submit"]').click(); + // Send the message + cy.get('div[aria-label="Send message"]').click(); + + // It was sent + cy.contains(".mx_EventTile_body a", "my message 0"); + cy.get(".mx_EventTile_body a").should("have.attr", "href").and("include", "https://matrix.org/"); + }); + }); }); }); diff --git a/cypress/e2e/crypto/decryption-failure.spec.ts b/cypress/e2e/crypto/decryption-failure.spec.ts index 5f0a9056ad6..b9e3265b767 100644 --- a/cypress/e2e/crypto/decryption-failure.spec.ts +++ b/cypress/e2e/crypto/decryption-failure.spec.ts @@ -52,6 +52,8 @@ const handleVerificationRequest = (request: VerificationRequest): Chainable { }) .then(() => { cy.botSendMessage(bot, roomId, "test"); - cy.wait(5000); - cy.get(".mx_DecryptionFailureBar .mx_DecryptionFailureBar_message_headline").should( - "have.text", + cy.contains( + ".mx_DecryptionFailureBar .mx_DecryptionFailureBar_message_headline", "Verify this device to access all messages", ); @@ -124,6 +125,7 @@ describe("Decryption Failure Bar", () => { const verificationRequestPromise = waitForVerificationRequest(otherDevice); cy.get(".mx_CompleteSecurity_actionRow .mx_AccessibleButton").click(); + cy.contains("To proceed, please accept the verification request on your other device."); cy.wrap(verificationRequestPromise).then((verificationRequest: VerificationRequest) => { cy.wrap(verificationRequest.accept()); handleVerificationRequest(verificationRequest).then((emojis) => { @@ -170,9 +172,8 @@ describe("Decryption Failure Bar", () => { ); cy.botSendMessage(bot, roomId, "test"); - cy.wait(5000); - cy.get(".mx_DecryptionFailureBar .mx_DecryptionFailureBar_message_headline").should( - "have.text", + cy.contains( + ".mx_DecryptionFailureBar .mx_DecryptionFailureBar_message_headline", "Reset your keys to prevent future decryption errors", ); diff --git a/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts b/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts new file mode 100644 index 00000000000..897b916105e --- /dev/null +++ b/cypress/e2e/one-to-one-chat/one-to-one-chat.spec.ts @@ -0,0 +1,61 @@ +/* +Copyright 2023 Ahmad Kadri +Copyright 2023 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 { HomeserverInstance } from "../../plugins/utils/homeserver"; +import { Credentials } from "../../support/homeserver"; + +describe("1:1 chat room", () => { + let homeserver: HomeserverInstance; + let user2: Credentials; + + const username = "user1234"; + const password = "p4s5W0rD"; + + beforeEach(() => { + cy.startHomeserver("default").then((data) => { + homeserver = data; + + cy.initTestUser(homeserver, "Jeff"); + cy.registerUser(homeserver, username, password).then((credential) => { + user2 = credential; + cy.visit(`/#/user/${user2.userId}?action=chat`); + }); + }); + }); + + afterEach(() => { + cy.stopHomeserver(homeserver); + }); + + it("should open new 1:1 chat room after leaving the old one", () => { + // leave 1:1 chat room + cy.contains(".mx_RoomHeader_nametext", username).click(); + cy.contains('[role="menuitem"]', "Leave").click(); + cy.get('[data-testid="dialog-primary-button"]').click(); + + // wait till the room was left + cy.get('[role="group"][aria-label="Historical"]').within(() => { + cy.contains(".mx_RoomTile", username); + }); + + // open new 1:1 chat room + cy.visit(`/#/user/${user2.userId}?action=chat`); + cy.contains(".mx_RoomHeader_nametext", username); + }); +}); diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index f07746c0f56..f89fa297d01 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -17,6 +17,7 @@ limitations under the License. /// import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { Preset } from "matrix-js-sdk/src/@types/partials"; import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; @@ -32,7 +33,7 @@ function openSpaceContextMenu(spaceName: string): Chainable { return cy.get(".mx_SpacePanel_contextMenu"); } -function spaceCreateOptions(spaceName: string): ICreateRoomOpts { +function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateRoomOpts { return { creation_content: { type: "m.space", @@ -44,6 +45,7 @@ function spaceCreateOptions(spaceName: string): ICreateRoomOpts { name: spaceName, }, }, + ...roomIds.map(spaceChildInitialState), ], }; } @@ -77,7 +79,7 @@ describe("Spaces", () => { cy.stopHomeserver(homeserver); }); - it.only("should allow user to create public space", () => { + it("should allow user to create public space", () => { openSpaceCreateMenu(); cy.get("#mx_ContextualMenu_Container").percySnapshotElement("Space create menu"); cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu").within(() => { @@ -283,4 +285,29 @@ describe("Spaces", () => { cy.checkA11y(undefined, axeOptions); cy.get(".mx_SpacePanel").percySnapshotElement("Space panel expanded", { widths: [258] }); }); + + it("should not soft crash when joining a room from space hierarchy which has a link in its topic", () => { + cy.getBot(homeserver, { displayName: "BotBob" }).then({ timeout: 10000 }, async (bot) => { + const { room_id: roomId } = await bot.createRoom({ + preset: "public_chat" as Preset, + name: "Test Room", + topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link", + }); + const { room_id: spaceId } = await bot.createRoom(spaceCreateOptions("Test Space", [roomId])); + await bot.invite(spaceId, user.userId); + }); + + cy.getSpacePanelButton("Test Space").should("exist"); + cy.wait(500); // without this we can end up clicking too quickly and it ends up having no effect + cy.viewSpaceByName("Test Space"); + cy.contains(".mx_AccessibleButton", "Accept").click(); + + cy.contains(".mx_SpaceHierarchy_roomTile.mx_AccessibleButton", "Test Room").within(() => { + cy.contains("Join").should("exist").realHover().click(); + cy.contains("View", { timeout: 5000 }).should("exist").click(); + }); + + // Assert we get shown the new room intro, and thus not the soft crash screen + cy.get(".mx_NewRoomIntro").should("exist"); + }); }); diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 17b763a089f..bef1cd0393c 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -250,7 +250,7 @@ describe("Timeline", () => { cy.contains(".mx_RoomView_body .mx_EventTile[data-scroll-tokens]", "MessageEdit").should("exist"); // Click top left of the event toggle, which should not be covered by MessageActionBar's safe area - cy.get(".mx_EventTile .mx_ViewSourceEvent") + cy.get(".mx_EventTile:not(:first-child) .mx_ViewSourceEvent") .should("exist") .realHover() .within(() => { @@ -384,5 +384,24 @@ describe("Timeline", () => { 1, ); }); + + it("should not be possible to send flag with regional emojis", () => { + cy.visit("/#/room/" + roomId); + + // Send a message + cy.getComposer().type(":regional_indicator_a"); + cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_a:").click(); + cy.getComposer().type(":regional_indicator_r"); + cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_r:").click(); + cy.getComposer().type(" :regional_indicator_z"); + cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_z:").click(); + cy.getComposer().type(":regional_indicator_a"); + cy.contains(".mx_Autocomplete_Completion_title", ":regional_indicator_a:").click(); + cy.getComposer().type("{enter}"); + + cy.get(".mx_RoomView_body .mx_EventTile .mx_EventTile_line .mx_MTextBody .mx_EventTile_bigEmoji") + .children() + .should("have.length", 4); + }); }); }); diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index 9cb5e472deb..2799927916e 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -163,6 +163,8 @@ function setupBotClient( } }) .then(() => cli), + // extra timeout, as this sometimes takes a while + { timeout: 30_000 }, ); }); } diff --git a/package.json b/package.json index 8c0f58addf1..2c717e99f1d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.65.0", + "version": "3.66.0", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -57,7 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.4.0", - "@matrix-org/matrix-wysiwyg": "^0.20.0", + "@matrix-org/matrix-wysiwyg": "^0.23.0", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^7.0.0", "@sentry/tracing": "^7.0.0", @@ -86,13 +86,14 @@ "jszip": "^3.7.0", "katex": "^0.16.0", "linkify-element": "4.0.0-beta.4", + "linkify-react": "4.0.0-beta.4", "linkify-string": "4.0.0-beta.4", "linkifyjs": "4.0.0-beta.4", "lodash": "^4.17.20", "maplibre-gl": "^2.0.0", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "0.0.1", - "matrix-js-sdk": "23.2.0", + "matrix-js-sdk": "23.3.0", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", @@ -190,7 +191,7 @@ "eslint-plugin-deprecate": "^0.7.0", "eslint-plugin-import": "^2.25.4", "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-matrix-org": "0.9.0", + "eslint-plugin-matrix-org": "0.10.0", "eslint-plugin-react": "^7.28.0", "eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-unicorn": "^45.0.0", diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 4dad08d084e..671de3ed868 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -17,6 +17,7 @@ @import "./components/views/beacon/_ShareLatestLocation.pcss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss"; @import "./components/views/context_menus/_KebabContextMenu.pcss"; +@import "./components/views/dialogs/polls/_PollListItem.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; @import "./components/views/elements/_LearnMore.pcss"; @import "./components/views/location/_EnableLiveShare.pcss"; @@ -161,6 +162,8 @@ @import "./views/dialogs/_UserSettingsDialog.pcss"; @import "./views/dialogs/_VerifyEMailDialog.pcss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss"; +@import "./views/dialogs/polls/_PollHistoryDialog.pcss"; +@import "./views/dialogs/polls/_PollHistoryList.pcss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.pcss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.pcss"; @import "./views/dialogs/security/_CreateKeyBackupDialog.pcss"; diff --git a/res/css/components/views/dialogs/polls/_PollListItem.pcss b/res/css/components/views/dialogs/polls/_PollListItem.pcss new file mode 100644 index 00000000000..7b19e675943 --- /dev/null +++ b/res/css/components/views/dialogs/polls/_PollListItem.pcss @@ -0,0 +1,40 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_PollListItem { + width: 100%; + display: grid; + justify-content: left; + align-items: center; + grid-gap: $spacing-8; + grid-template-columns: auto auto auto; + grid-template-rows: auto; + + color: $primary-content; +} + +.mx_PollListItem_icon { + height: 14px; + width: 14px; + color: $quaternary-content; + padding-left: $spacing-8; +} + +.mx_PollListItem_question { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/res/css/structures/_ViewSource.pcss b/res/css/structures/_ViewSource.pcss index c063eeb49cd..52d3afecc4f 100644 --- a/res/css/structures/_ViewSource.pcss +++ b/res/css/structures/_ViewSource.pcss @@ -27,6 +27,7 @@ limitations under the License. border-bottom: 1px solid $quinary-content; padding-bottom: $spacing-12; margin-bottom: $spacing-12; + font-family: monospace; .mx_CopyableText { word-break: break-all; diff --git a/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss b/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss index cda642f610f..9a6372a5adb 100644 --- a/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss +++ b/res/css/views/dialogs/_AddExistingToSpaceDialog.pcss @@ -38,6 +38,8 @@ limitations under the License. } .mx_AddExistingToSpace_section { + margin-right: 12px; // provides space for scrollbar so that checkbox and scrollbar do not collide + &:not(:first-child) { margin-top: 24px; } diff --git a/res/css/views/dialogs/polls/_PollHistoryDialog.pcss b/res/css/views/dialogs/polls/_PollHistoryDialog.pcss new file mode 100644 index 00000000000..39a53344ede --- /dev/null +++ b/res/css/views/dialogs/polls/_PollHistoryDialog.pcss @@ -0,0 +1,23 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_PollHistoryDialog_content { + height: 600px; + width: 100%; + + display: flex; + flex-direction: column; +} diff --git a/res/css/views/dialogs/polls/_PollHistoryList.pcss b/res/css/views/dialogs/polls/_PollHistoryList.pcss new file mode 100644 index 00000000000..6a0a003ce1e --- /dev/null +++ b/res/css/views/dialogs/polls/_PollHistoryList.pcss @@ -0,0 +1,44 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_PollHistoryList { + display: flex; + flex-direction: column; + flex: 1 1 auto; + max-height: 100%; +} + +.mx_PollHistoryList_list { + overflow: auto; + list-style: none; + margin-block: 0; + padding-inline: 0; + flex: 1 1 0; + align-content: flex-start; + display: grid; + grid-gap: $spacing-20; + padding-right: $spacing-64; + margin: $spacing-32 0; +} + +.mx_PollHistoryList_noResults { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + color: $secondary-content; +} diff --git a/res/css/views/emojipicker/_EmojiPicker.pcss b/res/css/views/emojipicker/_EmojiPicker.pcss index e613b81eee3..22b26d2867b 100644 --- a/res/css/views/emojipicker/_EmojiPicker.pcss +++ b/res/css/views/emojipicker/_EmojiPicker.pcss @@ -228,6 +228,10 @@ limitations under the License. .mx_EmojiPicker_preview_text { display: flex; + flex: 1; + overflow: hidden; + padding-top: 1rem; + padding-bottom: 1rem; flex-direction: column; } @@ -237,6 +241,7 @@ limitations under the License. .mx_EmojiPicker_shortcode { color: $light-fg-color; + overflow-wrap: break-word; font-size: $font-14px; &::before, diff --git a/res/css/views/messages/_MPollBody.pcss b/res/css/views/messages/_MPollBody.pcss index 64c6ff3bf5b..ed355be103c 100644 --- a/res/css/views/messages/_MPollBody.pcss +++ b/res/css/views/messages/_MPollBody.pcss @@ -151,8 +151,16 @@ limitations under the License. } .mx_MPollBody_totalVotes { + display: flex; + flex-direction: inline; + justify-content: start; color: $secondary-content; font-size: $font-12px; + + .mx_Spinner { + flex: 0; + margin-left: $spacing-8; + } } } diff --git a/res/css/views/right_panel/_RoomSummaryCard.pcss b/res/css/views/right_panel/_RoomSummaryCard.pcss index 1da98e2026e..ed7e3887518 100644 --- a/res/css/views/right_panel/_RoomSummaryCard.pcss +++ b/res/css/views/right_panel/_RoomSummaryCard.pcss @@ -275,3 +275,7 @@ limitations under the License. .mx_RoomSummaryCard_icon_export::before { mask-image: url("$(res)/img/element-icons/export.svg"); } + +.mx_RoomSummaryCard_icon_poll::before { + mask-image: url("$(res)/img/element-icons/room/composer/poll.svg"); +} diff --git a/res/css/views/right_panel/_ThreadPanel.pcss b/res/css/views/right_panel/_ThreadPanel.pcss index aa3282cf420..683b576d2a6 100644 --- a/res/css/views/right_panel/_ThreadPanel.pcss +++ b/res/css/views/right_panel/_ThreadPanel.pcss @@ -130,7 +130,7 @@ limitations under the License. } .mx_MessageTimestamp { - color: $secondary-content; + color: $event-timestamp-color; } .mx_BaseCard_footer { diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index bfe84947174..f577f4836dc 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -637,12 +637,12 @@ $left-gutter: 64px; } /* Make list type disc to match rich text editor */ - > ul { + ul { list-style-type: disc; } - /* Remove top and bottom margin for better consecutive list display */ - > :is(ol, ul) { + /* Remove top and bottom margin for better display in rich text editor output */ + :is(blockquote > p, ol, ul) { margin-top: 0; margin-bottom: 0; } diff --git a/res/css/views/rooms/_ReplyPreview.pcss b/res/css/views/rooms/_ReplyPreview.pcss index 75b3f8a672e..524b1693908 100644 --- a/res/css/views/rooms/_ReplyPreview.pcss +++ b/res/css/views/rooms/_ReplyPreview.pcss @@ -26,7 +26,7 @@ limitations under the License. display: flex; flex-flow: column; row-gap: $spacing-8; - padding: $spacing-8 $spacing-8 0 $spacing-8; + padding: $spacing-8 $spacing-8 0 0; .mx_ReplyPreview_header { display: flex; diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss index cc805e1ac1e..51a213192ca 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -37,6 +37,20 @@ limitations under the License. user-select: all; } + // we always have a
tag at the end of the html, we need it to be present at first then hide it as soon as + // we have any other elements + br:not(:only-child) { + display: none; + } + + p { + margin-top: 0; + margin-bottom: 0; + // this may seem redundant, but we need to handle zero content formatting tags, which occur when we split a + // formatting tag into paragraphs + min-height: $font-22px; + } + ul, ol { margin-top: 0; @@ -44,6 +58,11 @@ limitations under the License. padding-inline-start: $spacing-28; } + /* Make list type disc to match rich text editor */ + ul { + list-style-type: disc; + } + blockquote { color: #777; border-left: 2px solid $blockquote-bar-color; @@ -56,12 +75,6 @@ limitations under the License. margin-inline-end: 0; } - // model output always includes a linebreak but we do not want the user - // to see it when writing input in lists - :is(ol, ul, pre, blockquote) + br:last-of-type { - display: none; - } - > pre { font-size: $font-15px; line-height: $font-24px; @@ -82,6 +95,11 @@ limitations under the License. border-radius: 4px; padding: $spacing-2; } + + code:empty { + border: unset; + padding: unset; + } } .mx_WysiwygComposer_Editor_content_placeholder::before { diff --git a/res/img/element-icons/room/composer/bulleted_list.svg b/res/img/element-icons/room/composer/bulleted_list.svg index 828bb8ab038..df076045674 100644 --- a/res/img/element-icons/room/composer/bulleted_list.svg +++ b/res/img/element-icons/room/composer/bulleted_list.svg @@ -1,3 +1,10 @@ - - + + + + + + + + + diff --git a/res/img/element-icons/room/composer/code_block.svg b/res/img/element-icons/room/composer/code_block.svg index dd0be2aefc6..e2949ec8c75 100644 --- a/res/img/element-icons/room/composer/code_block.svg +++ b/res/img/element-icons/room/composer/code_block.svg @@ -1,3 +1,3 @@ - - + + diff --git a/res/img/element-icons/room/composer/indent_decrease.svg b/res/img/element-icons/room/composer/indent_decrease.svg new file mode 100644 index 00000000000..660c3e55ca0 --- /dev/null +++ b/res/img/element-icons/room/composer/indent_decrease.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/img/element-icons/room/composer/indent_increase.svg b/res/img/element-icons/room/composer/indent_increase.svg new file mode 100644 index 00000000000..f40162e05de --- /dev/null +++ b/res/img/element-icons/room/composer/indent_increase.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/img/element-icons/room/composer/numbered_list.svg b/res/img/element-icons/room/composer/numbered_list.svg index 46a5438f3f6..5748c977664 100644 --- a/res/img/element-icons/room/composer/numbered_list.svg +++ b/res/img/element-icons/room/composer/numbered_list.svg @@ -1,3 +1,10 @@ - - + + + + + + + + + diff --git a/res/img/element-icons/room/composer/quote.svg b/res/img/element-icons/room/composer/quote.svg index 82cc2d28752..e83480a6ee8 100644 --- a/res/img/element-icons/room/composer/quote.svg +++ b/res/img/element-icons/room/composer/quote.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/sonar-project.properties b/sonar-project.properties index a48c03603fc..a8d8f0cf860 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,5 +10,5 @@ sonar.exclusions=__mocks__,docs sonar.typescript.tsconfigPath=./tsconfig.json sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.coverage.exclusions=test/**/*,cypress/**/* +sonar.coverage.exclusions=test/**/*,cypress/**/*,src/components/views/dialogs/devtools/**/* sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml diff --git a/src/Avatar.ts b/src/Avatar.ts index 0f0c0b09274..8ea0e7c2d84 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -31,7 +31,7 @@ export function avatarUrlForMember( height: number, resizeMethod: ResizeMethod, ): string { - let url: string; + let url: string | null | undefined; if (member?.getMxcAvatarUrl()) { url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); } @@ -118,7 +118,7 @@ export function defaultAvatarUrlForString(s: string): string { * @param {string} name * @return {string} the first letter */ -export function getInitialLetter(name: string): string { +export function getInitialLetter(name: string): string | undefined { if (!name) { // XXX: We should find out what causes the name to sometimes be falsy. console.trace("`name` argument to `getInitialLetter` not supplied"); @@ -146,7 +146,7 @@ export function avatarUrlForRoom( if (!room) return null; // null-guard if (room.getMxcAvatarUrl()) { - return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod); + return mediaFromMxc(room.getMxcAvatarUrl() || undefined).getThumbnailOfSourceHttp(width, height, resizeMethod); } // space rooms cannot be DMs so skip the rest diff --git a/src/BasePlatform.ts b/src/BasePlatform.ts index 22d274ffb1e..46f964995af 100644 --- a/src/BasePlatform.ts +++ b/src/BasePlatform.ts @@ -22,6 +22,7 @@ import { encodeUnpaddedBase64 } from "matrix-js-sdk/src/crypto/olmlib"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; +import { SSOAction } from "matrix-js-sdk/src/@types/auth"; import dis from "./dispatcher/dispatcher"; import BaseEventIndexManager from "./indexing/BaseEventIndexManager"; @@ -129,7 +130,7 @@ export default abstract class BasePlatform { if (MatrixClientPeg.userRegisteredWithinLastHours(24)) return false; try { - const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY)); + const [version, deferUntil] = JSON.parse(localStorage.getItem(UPDATE_DEFER_KEY)!); return newVersion !== version || Date.now() > deferUntil; } catch (e) { return true; @@ -210,7 +211,7 @@ export default abstract class BasePlatform { metricsTrigger: "Notification", }; - if (ev.getThread()) { + if (ev?.getThread()) { payload.event_id = ev.getId(); } @@ -254,7 +255,7 @@ export default abstract class BasePlatform { return false; } - public getSettingValue(settingName: string): Promise { + public async getSettingValue(settingName: string): Promise { return undefined; } @@ -277,7 +278,7 @@ export default abstract class BasePlatform { public setSpellCheckEnabled(enabled: boolean): void {} public async getSpellCheckEnabled(): Promise { - return null; + return false; } public setSpellCheckLanguages(preferredLangs: string[]): void {} @@ -308,9 +309,9 @@ export default abstract class BasePlatform { return null; } - protected getSSOCallbackUrl(fragmentAfterLogin: string): URL { + protected getSSOCallbackUrl(fragmentAfterLogin = ""): URL { const url = new URL(window.location.href); - url.hash = fragmentAfterLogin || ""; + url.hash = fragmentAfterLogin; return url; } @@ -319,24 +320,26 @@ export default abstract class BasePlatform { * @param {MatrixClient} mxClient the matrix client using which we should start the flow * @param {"sso"|"cas"} loginType the type of SSO it is, CAS/SSO. * @param {string} fragmentAfterLogin the hash to pass to the app during sso callback. + * @param {SSOAction} action the SSO flow to indicate to the IdP, optional. * @param {string} idpId The ID of the Identity Provider being targeted, optional. */ public startSingleSignOn( mxClient: MatrixClient, loginType: "sso" | "cas", - fragmentAfterLogin: string, + fragmentAfterLogin?: string, idpId?: string, + action?: SSOAction, ): void { // persist hs url and is url for when the user is returned to the app with the login token localStorage.setItem(SSO_HOMESERVER_URL_KEY, mxClient.getHomeserverUrl()); if (mxClient.getIdentityServerUrl()) { - localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()); + localStorage.setItem(SSO_ID_SERVER_URL_KEY, mxClient.getIdentityServerUrl()!); } if (idpId) { localStorage.setItem(SSO_IDP_ID_KEY, idpId); } const callbackUrl = this.getSSOCallbackUrl(fragmentAfterLogin); - window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId); // redirect to SSO + window.location.href = mxClient.getSsoLoginUrl(callbackUrl.toString(), loginType, idpId, action); // redirect to SSO } /** diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index 1381e9431ec..85ca90067d5 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -372,7 +372,7 @@ export default class ContentMessages { const replyToEvent = SdkContextClass.instance.roomViewStore.getQuotingEvent(); if (!this.mediaConfig) { // hot-path optimization to not flash a spinner if we don't need to - const modal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner"); + const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner"); await this.ensureMediaConfigFetched(matrixClient); modal.close(); } diff --git a/src/DateUtils.ts b/src/DateUtils.ts index 5973a7c5f2e..c279c1ad1b2 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -175,7 +175,7 @@ function withinCurrentYear(prevDate: Date, nextDate: Date): boolean { return prevDate.getFullYear() === nextDate.getFullYear(); } -export function wantsDateSeparator(prevEventDate: Date, nextEventDate: Date): boolean { +export function wantsDateSeparator(prevEventDate: Date | undefined, nextEventDate: Date | undefined): boolean { if (!nextEventDate || !prevEventDate) { return false; } @@ -269,3 +269,16 @@ export function formatPreciseDuration(durationMs: number): string { } return _t("%(value)ss", { value: seconds }); } + +/** + * Formats a timestamp to a short date + * (eg 25/12/22 in uk locale) + * localised by system locale + * @param timestamp - epoch timestamp + * @returns {string} formattedDate + */ +export const formatLocalDateShort = (timestamp: number): string => + new Intl.DateTimeFormat( + undefined, // locales + { day: "2-digit", month: "2-digit", year: "2-digit" }, + ).format(timestamp); diff --git a/src/DecryptionFailureTracker.ts b/src/DecryptionFailureTracker.ts index c275a176eb7..7329c665bc2 100644 --- a/src/DecryptionFailureTracker.ts +++ b/src/DecryptionFailureTracker.ts @@ -83,8 +83,8 @@ export class DecryptionFailureTracker { public trackedEvents: Set = new Set(); // Set to an interval ID when `start` is called - public checkInterval: number = null; - public trackInterval: number = null; + public checkInterval: number | null = null; + public trackInterval: number | null = null; // Spread the load on `Analytics` by tracking at a low frequency, `TRACK_INTERVAL_MS`. public static TRACK_INTERVAL_MS = 60000; diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index be48717415f..eccfc1c0e3e 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -58,12 +58,12 @@ export default class DeviceListener { private dismissedThisDeviceToast = false; // cache of the key backup info private keyBackupInfo: IKeyBackupInfo | null = null; - private keyBackupFetchedAt: number = null; + private keyBackupFetchedAt: number | null = null; private keyBackupStatusChecked = false; // We keep a list of our own device IDs so we can batch ones that were already // there the last time the app launched into a single toast, but display new // ones in their own toasts. - private ourDeviceIdsAtStart: Set = null; + private ourDeviceIdsAtStart: Set | null = null; // The set of device IDs we're currently displaying toasts for private displayingToastsForDeviceIds = new Set(); private running = false; @@ -203,7 +203,7 @@ export default class DeviceListener { } }; - private onSync = (state: SyncState, prevState?: SyncState): void => { + private onSync = (state: SyncState, prevState: SyncState | null): void => { if (state === "PREPARED" && prevState === null) { this.recheck(); } diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index 0da7bb69c0e..7f397802716 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -17,16 +17,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; +import React, { ReactElement, ReactNode } from "react"; import sanitizeHtml from "sanitize-html"; import cheerio from "cheerio"; import classNames from "classnames"; import EMOJIBASE_REGEX from "emojibase-regex"; -import { split } from "lodash"; +import { merge, split } from "lodash"; import katex from "katex"; import { decode } from "html-entities"; import { IContent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; +import _Linkify from "linkify-react"; import { _linkifyElement, @@ -49,11 +50,8 @@ const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; // (with plenty of false positives, but that's OK) const SYMBOL_PATTERN = /([\u2100-\u2bff])/; -// Regex pattern for Zero-Width joiner unicode characters -const ZWJ_REGEX = /[\u200D\u2003]/g; - -// Regex pattern for whitespace characters -const WHITESPACE_REGEX = /\s/g; +// Regex pattern for non-emoji characters that can appear in an "all-emoji" message (Zero-Width Joiner, Zero-Width Space, other whitespace) +const EMOJI_SEPARATOR_REGEX = /[\u200D\u200B\s]/g; const BIGEMOJI_REGEX = new RegExp(`^(${EMOJIBASE_REGEX.source})+$`, "i"); @@ -611,14 +609,11 @@ export function bodyToHtml(content: IContent, highlights: Optional, op if (!opts.disableBigEmoji) { let contentBodyTrimmed = (bodyHasEmoji && contentBody !== undefined) ? contentBody.trim() : ""; - // Ignore spaces in body text. Emojis with spaces in between should - // still be counted as purely emoji messages. - contentBodyTrimmed = contentBodyTrimmed.replace(WHITESPACE_REGEX, ""); - - // Remove zero width joiner characters from emoji messages. This ensures - // that emojis that are made up of multiple unicode characters are still - // presented as large. - contentBodyTrimmed = contentBodyTrimmed.replace(ZWJ_REGEX, ""); + // Remove zero width joiner, zero width spaces and other spaces in body + // text. This ensures that emojis with spaces in between or that are made + // up of multiple unicode characters are still counted as purely emoji + // messages. + contentBodyTrimmed = contentBodyTrimmed.replace(EMOJI_SEPARATOR_REGEX, ""); const match = BIGEMOJI_REGEX.exec(contentBodyTrimmed); const matched = match && match[0] && match[0].length === contentBodyTrimmed.length; @@ -704,6 +699,15 @@ export function topicToHtml( ); } +/* Wrapper around linkify-react merging in our default linkify options */ +export function Linkify({ as, options, children }: React.ComponentProps): ReactElement { + return ( + <_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}> + {children} + + ); +} + /** * Linkifies the given string. This is a wrapper around 'linkifyjs/string'. * diff --git a/src/ImageUtils.ts b/src/ImageUtils.ts index 42db71ebab4..e8564fb0172 100644 --- a/src/ImageUtils.ts +++ b/src/ImageUtils.ts @@ -28,7 +28,12 @@ limitations under the License. * consume in the timeline, when performing scroll offset calculations * (e.g. scroll locking) */ -export function thumbHeight(fullWidth: number, fullHeight: number, thumbWidth: number, thumbHeight: number): number { +export function thumbHeight( + fullWidth: number, + fullHeight: number, + thumbWidth: number, + thumbHeight: number, +): number | null { if (!fullWidth || !fullHeight) { // Cannot calculate thumbnail height for image: missing w/h in metadata. We can't even // log this because it's spammy diff --git a/src/Lifecycle.ts b/src/Lifecycle.ts index 30aab429fb7..b28a7f80380 100644 --- a/src/Lifecycle.ts +++ b/src/Lifecycle.ts @@ -23,6 +23,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import { decryptAES, encryptAES, IEncryptedPayload } from "matrix-js-sdk/src/crypto/aes"; import { QueryDict } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; +import { SSOAction } from "matrix-js-sdk/src/@types/auth"; import { IMatrixClientCreds, MatrixClientPeg } from "./MatrixClientPeg"; import SecurityCustomisations from "./customisations/Security"; @@ -248,7 +249,7 @@ export function attemptTokenLogin( idBaseUrl: identityServer, }); const idpId = localStorage.getItem(SSO_IDP_ID_KEY) || undefined; - PlatformPeg.get().startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId); + PlatformPeg.get()?.startSingleSignOn(cli, "sso", fragmentAfterLogin, idpId, SSOAction.LOGIN); } }, }); diff --git a/src/Login.ts b/src/Login.ts index 90f8f5d0eb6..6475a9f5c93 100644 --- a/src/Login.ts +++ b/src/Login.ts @@ -19,7 +19,7 @@ limitations under the License. import { createClient } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { logger } from "matrix-js-sdk/src/logger"; -import { ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth"; +import { DELEGATED_OIDC_COMPATIBILITY, ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth"; import { IMatrixClientCreds } from "./MatrixClientPeg"; import SecurityCustomisations from "./customisations/Security"; @@ -32,7 +32,6 @@ export default class Login { private hsUrl: string; private isUrl: string; private fallbackHsUrl: string; - // TODO: Flows need a type in JS SDK private flows: Array; private defaultDeviceDisplayName: string; private tempClient: MatrixClient; @@ -81,8 +80,13 @@ export default class Login { public async getFlows(): Promise> { const client = this.createTemporaryClient(); - const { flows } = await client.loginFlows(); - this.flows = flows; + const { flows }: { flows: LoginFlow[] } = await client.loginFlows(); + // If an m.login.sso flow is present which is also flagged as being for MSC3824 OIDC compatibility then we only + // return that flow as (per MSC3824) it is the only one that the user should be offered to give the best experience + const oidcCompatibilityFlow = flows.find( + (f) => f.type === "m.login.sso" && DELEGATED_OIDC_COMPATIBILITY.findIn(f), + ); + this.flows = oidcCompatibilityFlow ? [oidcCompatibilityFlow] : flows; return this.flows; } diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index f674892bf7c..19a9eb5fde8 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -218,7 +218,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours - opts.experimentalThreadSupport = SettingsStore.getValue("feature_threadenabled"); + opts.threadSupport = SettingsStore.getValue("feature_threadenabled"); if (SettingsStore.getValue("feature_sliding_sync")) { const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url"); diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index d63baa3e0f8..8e34087bf15 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016, 2019, 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -16,18 +15,19 @@ limitations under the License. */ import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; -import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; -import { - ConditionKind, - IPushRule, - PushRuleActionName, - PushRuleKind, - TweakName, -} from "matrix-js-sdk/src/@types/PushRules"; -import { EventType } from "matrix-js-sdk/src/@types/event"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { NotificationCountType } from "matrix-js-sdk/src/models/room"; +import { ConditionKind, PushRuleActionName, PushRuleKind, TweakName } from "matrix-js-sdk/src/@types/PushRules"; +import type { IPushRule } from "matrix-js-sdk/src/@types/PushRules"; +import type { Room } from "matrix-js-sdk/src/models/room"; +import type { MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "./MatrixClientPeg"; +import { NotificationColor } from "./stores/notifications/NotificationColor"; +import { getUnsentMessages } from "./components/structures/RoomStatusBar"; +import { doesRoomHaveUnreadMessages, doesRoomOrThreadHaveUnreadMessages } from "./Unread"; +import { EffectiveMembership, getEffectiveMembership } from "./utils/membership"; +import SettingsStore from "./settings/SettingsStore"; +import { isRoomMarkedAsUnread } from "./Rooms"; export enum RoomNotifState { AllMessagesLoud = "all_messages_loud", @@ -36,7 +36,7 @@ export enum RoomNotifState { Mute = "mute", } -export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNotifState { +export function getRoomNotifsState(client: MatrixClient, roomId: string): RoomNotifState | null { if (client.isGuest()) return RoomNotifState.AllMessages; // look through the override rules for a rule affecting this room: @@ -87,11 +87,11 @@ export function getUnreadNotificationCount(room: Room, type: NotificationCountTy // Check notification counts in the old room just in case there's some lost // there. We only go one level down to avoid performance issues, and theory // is that 1st generation rooms will have already been read by the 3rd generation. - const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); - const predecessor = createEvent?.getContent().predecessor; + const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); + const predecessor = room.findPredecessor(msc3946ProcessDynamicPredecessor); // Exclude threadId, as the same thread can't continue over a room upgrade - if (!threadId && predecessor) { - const oldRoomId = predecessor.room_id; + if (!threadId && predecessor?.roomId) { + const oldRoomId = predecessor.roomId; const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId); if (oldRoom) { // We only ever care if there's highlights in the old room. No point in @@ -177,7 +177,7 @@ function setRoomNotifsStateUnmuted(roomId: string, newState: RoomNotifState): Pr return Promise.all(promises); } -function findOverrideMuteRule(roomId: string): IPushRule { +function findOverrideMuteRule(roomId: string): IPushRule | null { const cli = MatrixClientPeg.get(); if (!cli?.pushRules?.global?.override) { return null; @@ -201,3 +201,62 @@ function isRuleForRoom(roomId: string, rule: IPushRule): boolean { function isMuteRule(rule: IPushRule): boolean { return rule.actions.length === 1 && rule.actions[0] === PushRuleActionName.DontNotify; } + +export function determineUnreadState( + room?: Room, + threadId?: string, +): { color: NotificationColor; symbol: string | null; count: number } { + if (!room) { + return { symbol: null, count: 0, color: NotificationColor.None }; + } + const markUnreadEnabled = SettingsStore.getValue("feature_mark_unread"); + const markedUnread = markUnreadEnabled && isRoomMarkedAsUnread(room); + + if (getUnsentMessages(room, threadId).length > 0) { + return { symbol: "!", count: 1, color: NotificationColor.Unsent }; + } + + if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) { + return { symbol: "!", count: 1, color: NotificationColor.Red }; + } + + // SC: Muted rooms may also be rendered "marked as unread" --> exclude + if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute && !markedUnread) { + if (Unread.doesRoomHaveUnreadMessages(room)) { + // SC: muted can still show unread counter + return { symbol: null, count: 0, color: NotificationColor.Bold }; + } else { + // When muted we suppress all notification states, even if we have context on them. + return { symbol: null, count: 0, color: NotificationColor.None }; + } + } + + const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId); + const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId); + + const trueCount = greyNotifs || redNotifs; + if (redNotifs > 0) { + return { symbol: null, count: trueCount, color: NotificationColor.Red }; + } + + if (greyNotifs > 0) { + return { symbol: null, count: trueCount, color: NotificationColor.Grey }; + } + + // SC: Render marked-as-unread badge as green exclamation mark + if (markedUnread) { + return { symbol: "!", count: 1, color: NotificationColor.Grey }; + } + + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + let hasUnread = false; + if (threadId) hasUnread = doesRoomOrThreadHaveUnreadMessages(room.getThread(threadId)!); + else hasUnread = doesRoomHaveUnreadMessages(room); + + return { + symbol: null, + count: trueCount, + color: hasUnread ? NotificationColor.Bold : NotificationColor.None, + }; +} diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 9b23bd41386..3434090d8ea 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -33,7 +33,7 @@ import dis from "./dispatcher/dispatcher"; import { _t, _td, ITranslatableError, newTranslatableError } from "./languageHandler"; import Modal from "./Modal"; import MultiInviter from "./utils/MultiInviter"; -import { linkifyElement, topicToHtml } from "./HtmlUtils"; +import { Linkify, topicToHtml } from "./HtmlUtils"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import WidgetUtils from "./utils/WidgetUtils"; import { textToHtmlRainbow } from "./utils/colour"; @@ -501,14 +501,11 @@ export const Commands = [ ? ContentHelpers.parseTopicContent(content) : { text: _t("This room has no topic.") }; - const ref = (e): void => { - if (e) linkifyElement(e); - }; - const body = topicToHtml(topic.text, topic.html, ref, true); + const body = topicToHtml(topic.text, topic.html, undefined, true); Modal.createDialog(InfoDialog, { title: room.name, - description:
{body}
, + description: {body}, hasCloseButton: true, className: "markdown-body", }); diff --git a/src/accessibility/KeyboardShortcutUtils.ts b/src/accessibility/KeyboardShortcutUtils.ts index 394daf7ef90..bb42f7c1ce9 100644 --- a/src/accessibility/KeyboardShortcutUtils.ts +++ b/src/accessibility/KeyboardShortcutUtils.ts @@ -72,7 +72,7 @@ const getUIOnlyShortcuts = (): IKeyboardShortcuts => { }, }; - if (PlatformPeg.get().overrideBrowserShortcuts()) { + if (PlatformPeg.get()?.overrideBrowserShortcuts()) { // XXX: This keyboard shortcut isn't manually added to // KeyBindingDefaults as it can't be easily handled by the // KeyBindingManager @@ -92,7 +92,7 @@ const getUIOnlyShortcuts = (): IKeyboardShortcuts => { * This function gets keyboard shortcuts that can be consumed by the KeyBindingDefaults. */ export const getKeyboardShortcuts = (): IKeyboardShortcuts => { - const overrideBrowserShortcuts = PlatformPeg.get().overrideBrowserShortcuts(); + const overrideBrowserShortcuts = PlatformPeg.get()?.overrideBrowserShortcuts(); return Object.keys(KEYBOARD_SHORTCUTS) .filter((k: KeyBindingAction) => { @@ -120,11 +120,11 @@ export const getKeyboardShortcutsForUI = (): IKeyboardShortcuts => { }, {} as IKeyboardShortcuts); }; -export const getKeyboardShortcutValue = (name: string): KeyCombo => { +export const getKeyboardShortcutValue = (name: string): KeyCombo | undefined => { return getKeyboardShortcutsForUI()[name]?.default; }; -export const getKeyboardShortcutDisplayName = (name: string): string | null => { +export const getKeyboardShortcutDisplayName = (name: string): string | undefined => { const keyboardShortcutDisplayName = getKeyboardShortcutsForUI()[name]?.displayName; return keyboardShortcutDisplayName && _t(keyboardShortcutDisplayName); }; diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index e90aed87a90..605ffb1f5b5 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -56,7 +56,7 @@ export function checkInputableElement(el: HTMLElement): boolean { } export interface IState { - activeRef: Ref; + activeRef?: Ref; refs: Ref[]; } @@ -67,7 +67,6 @@ interface IContext { export const RovingTabIndexContext = createContext({ state: { - activeRef: null, refs: [], // list of refs in DOM order }, dispatch: () => {}, @@ -102,7 +101,7 @@ export const reducer: Reducer = (state: IState, action: IAction return 0; } - const position = a.current.compareDocumentPosition(b.current); + const position = a.current!.compareDocumentPosition(b.current!); if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { return -1; @@ -167,7 +166,7 @@ export const findSiblingElement = ( refs: RefObject[], startIndex: number, backwards = false, -): RefObject => { +): RefObject | undefined => { if (backwards) { for (let i = startIndex; i < refs.length && i >= 0; i--) { if (refs[i].current?.offsetParent !== null) { @@ -191,7 +190,6 @@ export const RovingTabIndexProvider: React.FC = ({ onKeyDown, }) => { const [state, dispatch] = useReducer>(reducer, { - activeRef: null, refs: [], }); @@ -208,7 +206,7 @@ export const RovingTabIndexProvider: React.FC = ({ let handled = false; const action = getKeyBindingsManager().getAccessibilityAction(ev); - let focusRef: RefObject; + let focusRef: RefObject | undefined; // Don't interfere with input default keydown behaviour // but allow people to move focus from it with Tab. if (checkInputableElement(ev.target as HTMLElement)) { @@ -216,7 +214,7 @@ export const RovingTabIndexProvider: React.FC = ({ case KeyBindingAction.Tab: handled = true; if (context.state.refs.length > 0) { - const idx = context.state.refs.indexOf(context.state.activeRef); + const idx = context.state.refs.indexOf(context.state.activeRef!); focusRef = findSiblingElement( context.state.refs, idx + (ev.shiftKey ? -1 : 1), @@ -252,7 +250,7 @@ export const RovingTabIndexProvider: React.FC = ({ ) { handled = true; if (context.state.refs.length > 0) { - const idx = context.state.refs.indexOf(context.state.activeRef); + const idx = context.state.refs.indexOf(context.state.activeRef!); focusRef = findSiblingElement(context.state.refs, idx + 1); } } @@ -266,7 +264,7 @@ export const RovingTabIndexProvider: React.FC = ({ ) { handled = true; if (context.state.refs.length > 0) { - const idx = context.state.refs.indexOf(context.state.activeRef); + const idx = context.state.refs.indexOf(context.state.activeRef!); focusRef = findSiblingElement(context.state.refs, idx - 1, true); } } diff --git a/src/actions/MatrixActionCreators.ts b/src/actions/MatrixActionCreators.ts index 88de8b3d17f..bb40a463baa 100644 --- a/src/actions/MatrixActionCreators.ts +++ b/src/actions/MatrixActionCreators.ts @@ -221,7 +221,7 @@ function createRoomTimelineAction( action: "MatrixActions.Room.timeline", event: timelineEvent, isLiveEvent: data.liveEvent, - isLiveUnfilteredRoomTimelineEvent: room && data.timeline.getTimelineSet() === room.getUnfilteredTimelineSet(), + isLiveUnfilteredRoomTimelineEvent: data.timeline.getTimelineSet() === room?.getUnfilteredTimelineSet(), room, }; } diff --git a/src/audio/PlaybackQueue.ts b/src/audio/PlaybackQueue.ts index 7c521b9ca65..0828a3df1d8 100644 --- a/src/audio/PlaybackQueue.ts +++ b/src/audio/PlaybackQueue.ts @@ -45,7 +45,7 @@ export class PlaybackQueue { private playbacks = new Map(); // keyed by event ID private clockStates = new Map(); // keyed by event ID private playbackIdOrder: string[] = []; // event IDs, last == current - private currentPlaybackId: string; // event ID, broken out from above for ease of use + private currentPlaybackId: string | null = null; // event ID, broken out from above for ease of use private recentFullPlays = new Set(); // event IDs public constructor(private room: Room) { @@ -68,7 +68,7 @@ export class PlaybackQueue { const room = cli.getRoom(roomId); if (!room) throw new Error("Unknown room"); if (PlaybackQueue.queues.has(room.roomId)) { - return PlaybackQueue.queues.get(room.roomId); + return PlaybackQueue.queues.get(room.roomId)!; } const queue = new PlaybackQueue(room); PlaybackQueue.queues.set(room.roomId, queue); @@ -101,7 +101,7 @@ export class PlaybackQueue { const wasLastPlaying = this.currentPlaybackId === mxEvent.getId(); if (newState === PlaybackState.Stopped && this.clockStates.has(mxEvent.getId()) && !wasLastPlaying) { // noinspection JSIgnoredPromiseFromCall - playback.skipTo(this.clockStates.get(mxEvent.getId())); + playback.skipTo(this.clockStates.get(mxEvent.getId())!); } else if (newState === PlaybackState.Stopped) { // Remove the now-useless clock for some space savings this.clockStates.delete(mxEvent.getId()); diff --git a/src/autocomplete/AutocompleteProvider.tsx b/src/autocomplete/AutocompleteProvider.tsx index 546e052f583..32c2d80c6fc 100644 --- a/src/autocomplete/AutocompleteProvider.tsx +++ b/src/autocomplete/AutocompleteProvider.tsx @@ -70,7 +70,7 @@ export default abstract class AutocompleteProvider { * @param {boolean} force True if the user is forcing completion * @return {object} { command, range } where both objects fields are null if no match */ - public getCurrentCommand(query: string, selection: ISelectionRange, force = false): ICommand { + public getCurrentCommand(query: string, selection: ISelectionRange, force = false): ICommand | null { let commandRegex = this.commandRegex; if (force && this.shouldForceComplete()) { @@ -83,7 +83,7 @@ export default abstract class AutocompleteProvider { commandRegex.lastIndex = 0; - let match: RegExpExecArray; + let match: RegExpExecArray | null; while ((match = commandRegex.exec(query)) !== null) { const start = match.index; const end = start + match[0].length; diff --git a/src/autocomplete/Autocompleter.ts b/src/autocomplete/Autocompleter.ts index 80c28923236..7a619fedc79 100644 --- a/src/autocomplete/Autocompleter.ts +++ b/src/autocomplete/Autocompleter.ts @@ -87,7 +87,7 @@ export default class Autocompleter { to predict whether an action will actually do what is intended */ // list of results from each provider, each being a list of completions or null if it times out - const completionsList: ICompletion[][] = await Promise.all( + const completionsList: Array = await Promise.all( this.providers.map(async (provider): Promise => { return timeout( provider.getCompletions(query, selection, force, limit), @@ -113,6 +113,6 @@ export default class Autocompleter { command: this.providers[i].getCurrentCommand(query, selection, force), }; }) - .filter(Boolean); + .filter(Boolean) as IProviderCompletions[]; } } diff --git a/src/autocomplete/CommandProvider.tsx b/src/autocomplete/CommandProvider.tsx index caafe98f088..113c9287901 100644 --- a/src/autocomplete/CommandProvider.tsx +++ b/src/autocomplete/CommandProvider.tsx @@ -56,10 +56,10 @@ export default class CommandProvider extends AutocompleteProvider { if (command[0] !== command[1]) { // The input looks like a command with arguments, perform exact match const name = command[1].slice(1); // strip leading `/` - if (CommandMap.has(name) && CommandMap.get(name).isEnabled()) { + if (CommandMap.has(name) && CommandMap.get(name)!.isEnabled()) { // some commands, namely `me` don't suit having the usage shown whilst typing their arguments - if (CommandMap.get(name).hideCompletionAfterSpace) return []; - matches = [CommandMap.get(name)]; + if (CommandMap.get(name)!.hideCompletionAfterSpace) return []; + matches = [CommandMap.get(name)!]; } } else { if (query === "/") { diff --git a/src/autocomplete/RoomProvider.tsx b/src/autocomplete/RoomProvider.tsx index bf102d55bc0..9cde5013650 100644 --- a/src/autocomplete/RoomProvider.tsx +++ b/src/autocomplete/RoomProvider.tsx @@ -39,12 +39,12 @@ function canonicalScore(displayedAlias: string, room: Room): number { function matcherObject( room: Room, - displayedAlias: string, + displayedAlias: string | null, matchName = "", ): { room: Room; matchName: string; - displayedAlias: string; + displayedAlias: string | null; } { return { room, @@ -58,7 +58,7 @@ export default class RoomProvider extends AutocompleteProvider { public constructor(room: Room, renderingType?: TimelineRenderingType) { super({ commandRegex: ROOM_REGEX, renderingType }); - this.matcher = new QueryMatcher([], { + this.matcher = new QueryMatcher>([], { keys: ["displayedAlias", "matchName"], }); } @@ -79,7 +79,7 @@ export default class RoomProvider extends AutocompleteProvider { const { command, range } = this.getCurrentCommand(query, selection, force); if (command) { // the only reason we need to do this is because Fuse only matches on properties - let matcherObjects = this.getRooms().reduce((aliases, room) => { + let matcherObjects = this.getRooms().reduce[]>((aliases, room) => { if (room.getCanonicalAlias()) { aliases = aliases.concat(matcherObject(room, room.getCanonicalAlias(), room.name)); } diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index a0a1f448411..15f67ea50e4 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -137,8 +137,10 @@ import { SdkContextClass, SDKContext } from "../../contexts/SDKContext"; import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSettings"; import { cleanUpBroadcasts, VoiceBroadcastResumer } from "../../voice-broadcast"; import GenericToast from "../views/toasts/GenericToast"; -import { Linkify } from "../views/elements/Linkify"; import RovingSpotlightDialog, { Filter } from "../views/dialogs/spotlight/SpotlightDialog"; +import { findDMForUser } from "../../utils/dm/findDMForUser"; +import { Linkify } from "../../HtmlUtils"; +import { NotificationColor } from "../../stores/notifications/NotificationColor"; // legacy export export { default as Views } from "../../Views"; @@ -648,7 +650,7 @@ export default class MatrixChat extends React.PureComponent { onFinished: (confirm) => { if (confirm) { // FIXME: controller shouldn't be loading a view :( - const modal = Modal.createDialog(Spinner, null, "mx_Dialog_spinner"); + const modal = Modal.createDialog(Spinner, undefined, "mx_Dialog_spinner"); MatrixClientPeg.get() .leave(payload.room_id) @@ -1102,13 +1104,12 @@ export default class MatrixChat extends React.PureComponent { // TODO: Immutable DMs replaces this const client = MatrixClientPeg.get(); - const dmRoomMap = new DMRoomMap(client); - const dmRooms = dmRoomMap.getDMRoomsForUserId(userId); + const dmRoom = findDMForUser(client, userId); - if (dmRooms.length > 0) { + if (dmRoom) { dis.dispatch({ action: Action.ViewRoom, - room_id: dmRooms[0], + room_id: dmRoom.roomId, metricsTrigger: "MessageUser", }); } else { @@ -1979,6 +1980,8 @@ export default class MatrixChat extends React.PureComponent { } if (numUnreadRooms > 0) { this.subTitleStatus += `[${numUnreadRooms}]`; + } else if (notificationState.color >= NotificationColor.Bold) { + this.subTitleStatus += `*`; } this.setPageSubtitle(); diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index d15f62ff65e..da75a2f6073 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -73,7 +73,7 @@ const groupedStateEvents = [ // check if there is a previous event and it has the same sender as this event // and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL export function shouldFormContinuation( - prevEvent: MatrixEvent, + prevEvent: MatrixEvent | null, mxEvent: MatrixEvent, showHiddenEvents: boolean, threadsEnabled: boolean, @@ -834,7 +834,7 @@ export default class MessagePanel extends React.Component { // here. return !this.props.canBackPaginate; } - return wantsDateSeparator(prevEvent.getDate(), nextEventDate); + return wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate); } // Get a list of read receipts that should be shown next to this event diff --git a/src/components/structures/PictureInPictureDragger.tsx b/src/components/structures/PictureInPictureDragger.tsx index 19205229c8a..40c1caee6f9 100644 --- a/src/components/structures/PictureInPictureDragger.tsx +++ b/src/components/structures/PictureInPictureDragger.tsx @@ -70,6 +70,8 @@ export default class PictureInPictureDragger extends React.Component { () => this.animationCallback(), () => requestAnimationFrame(() => this.scheduledUpdate.trigger()), ); + private startingPositionX = 0; + private startingPositionY = 0; private _moving = false; public get moving(): boolean { @@ -192,11 +194,22 @@ export default class PictureInPictureDragger extends React.Component { event.stopPropagation(); this.mouseHeld = true; + this.startingPositionX = event.clientX; + this.startingPositionY = event.clientY; }; private onMoving = (event: MouseEvent): void => { if (!this.mouseHeld) return; + if ( + Math.abs(this.startingPositionX - event.clientX) < 5 && + Math.abs(this.startingPositionY - event.clientY) < 5 + ) { + // User needs to move the widget by at least five pixels. + // Improves click detection when using a touchpad or with nervous hands. + return; + } + event.preventDefault(); event.stopPropagation(); diff --git a/src/components/structures/PipContainer.tsx b/src/components/structures/PipContainer.tsx index 416458e6ff6..0697f4a6da6 100644 --- a/src/components/structures/PipContainer.tsx +++ b/src/components/structures/PipContainer.tsx @@ -192,10 +192,7 @@ class PipContainerInner extends React.Component { }; private onWidgetPersistence = (): void => { - this.updateShowWidgetInPip( - ActiveWidgetStore.instance.getPersistentWidgetId(), - ActiveWidgetStore.instance.getPersistentRoomId(), - ); + this.updateShowWidgetInPip(); }; private onWidgetDockChanges = (): void => { @@ -234,11 +231,10 @@ class PipContainerInner extends React.Component { } }; - // Accepts a persistentWidgetId to be able to skip awaiting the setState for persistentWidgetId - public updateShowWidgetInPip( - persistentWidgetId = this.state.persistentWidgetId, - persistentRoomId = this.state.persistentRoomId, - ): void { + public updateShowWidgetInPip(): void { + const persistentWidgetId = ActiveWidgetStore.instance.getPersistentWidgetId(); + const persistentRoomId = ActiveWidgetStore.instance.getPersistentRoomId(); + let fromAnotherRoom = false; let notDocked = false; // Sanity check the room - the widget may have been destroyed between render cycles, and @@ -258,17 +254,16 @@ class PipContainerInner extends React.Component { } private createVoiceBroadcastPlaybackPipContent(voiceBroadcastPlayback: VoiceBroadcastPlayback): CreatePipChildren { - if (this.state.viewedRoomId === voiceBroadcastPlayback.infoEvent.getRoomId()) { - return ({ onStartMoving }) => ( -
- -
+ const content = + this.state.viewedRoomId === voiceBroadcastPlayback.infoEvent.getRoomId() ? ( + + ) : ( + ); - } return ({ onStartMoving }) => ( -
- +
+ {content}
); } diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 6e1d988f0a8..58565bd4316 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -51,7 +51,7 @@ import TextWithTooltip from "../views/elements/TextWithTooltip"; import { useStateToggle } from "../../hooks/useStateToggle"; import { getChildOrder } from "../../stores/spaces/SpaceStore"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; -import { linkifyElement, topicToHtml } from "../../HtmlUtils"; +import { Linkify, topicToHtml } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; import { Action } from "../../dispatcher/actions"; import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; @@ -210,6 +210,25 @@ const Tile: React.FC = ({ topic = room.topic; } + let topicSection: ReactNode | undefined; + if (topic) { + topicSection = ( + + {" · "} + {topic} + + ); + } + let joinedSection: ReactElement | undefined; if (joinedRoom) { joinedSection =
{_t("Joined")}
; @@ -231,19 +250,9 @@ const Tile: React.FC = ({ {joinedSection} {suggestedSection}
-
e && linkifyElement(e)} - onClick={(ev) => { - // prevent clicks on links from bubbling up to the room tile - if ((ev.target as HTMLElement).tagName === "A") { - ev.stopPropagation(); - } - }} - > +
{description} - {topic && " · "} - {topic} + {topicSection}
@@ -413,9 +422,18 @@ interface IHierarchyLevelProps { onToggleClick?(parentId: string, childId: string): void; } -const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom): IHierarchyRoom => { +export const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, hierarchy: RoomHierarchy): IHierarchyRoom => { const history = cli.getRoomUpgradeHistory(room.room_id, true); - const cliRoom = history[history.length - 1]; + + // Pick latest room that is actually part of the hierarchy + let cliRoom = null; + for (let idx = history.length - 1; idx >= 0; --idx) { + if (hierarchy.roomMap.get(history[idx].roomId)) { + cliRoom = history[idx]; + break; + } + } + if (cliRoom) { return { ...room, @@ -424,7 +442,7 @@ const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom): IHierarchyRoom => name: cliRoom.name, topic: cliRoom.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic, avatar_url: cliRoom.getMxcAvatarUrl(), - canonical_alias: cliRoom.getCanonicalAlias(), + canonical_alias: cliRoom.getCanonicalAlias() ?? undefined, aliases: cliRoom.getAltAliases(), world_readable: cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent() @@ -461,7 +479,7 @@ export const HierarchyLevel: React.FC = ({ (result, ev: IHierarchyRelation) => { const room = hierarchy.roomMap.get(ev.state_key); if (room && roomSet.has(room)) { - result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room)); + result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room, hierarchy)); } return result; }, diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 57bb7cdaf09..ef97bd71c8e 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -1,5 +1,5 @@ /* -Copyright 2021 - 2022 The Matrix.org Foundation C.I.C. +Copyright 2021 - 2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -32,16 +32,9 @@ import { Layout } from "../../settings/enums/Layout"; import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import Measured from "../views/elements/Measured"; import PosthogTrackers from "../../PosthogTrackers"; -import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; -import { BetaPill } from "../views/beta/BetaCard"; -import Modal from "../../Modal"; -import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog"; -import { Action } from "../../dispatcher/actions"; -import { UserTab } from "../views/dialogs/UserTab"; -import dis from "../../dispatcher/dispatcher"; +import { ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; import Heading from "../views/typography/Heading"; -import { shouldShowFeedback } from "../../utils/Feedback"; interface IProps { roomId: string; @@ -231,14 +224,6 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => } }, [timelineSet, timelinePanel]); - const openFeedback = shouldShowFeedback() - ? () => { - Modal.createDialog(BetaFeedbackDialog, { - featureId: "feature_threadenabled", - }); - } - : null; - return ( = ({ roomId, onClose, permalinkCreator }) => empty={!hasThreads} /> } - footer={ - <> - { - dis.dispatch({ - action: Action.ViewUserSettings, - initialTabId: UserTab.Labs, - }); - }} - /> - {openFeedback && - _t( - "Give feedback", - {}, - { - a: (sub) => ( - - {sub} - - ), - }, - )} - - } className="mx_ThreadPanel" onClose={onClose} withoutScrollContainer={true} diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index c857b96fe50..4cbe0f5bc60 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -18,7 +18,7 @@ import React, { ReactNode } from "react"; import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth"; +import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; import { _t, _td } from "../../../languageHandler"; import Login from "../../../Login"; @@ -345,6 +345,7 @@ export default class LoginComponent extends React.PureComponent this.loginLogic.createTemporaryClient(), ssoKind, this.props.fragmentAfterLogin, + SSOAction.REGISTER, ); } else { // Don't intercept - just go through to the register page @@ -549,6 +550,7 @@ export default class LoginComponent extends React.PureComponent loginType={loginType} fragmentAfterLogin={this.props.fragmentAfterLogin} primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} + action={SSOAction.LOGIN} /> ); }; diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index f2c2314b02d..aac39334a0d 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -19,7 +19,7 @@ import React, { Fragment, ReactNode } from "react"; import { IRequestTokenResponse, MatrixClient } from "matrix-js-sdk/src/client"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { ISSOFlow } from "matrix-js-sdk/src/@types/auth"; +import { ISSOFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; import { _t, _td } from "../../../languageHandler"; import { messageForResourceLimitError } from "../../../utils/ErrorUtils"; @@ -539,6 +539,7 @@ export default class Registration extends React.Component { flow={this.state.ssoFlow} loginType={this.state.ssoFlow.type === "m.login.sso" ? "sso" : "cas"} fragmentAfterLogin={this.props.fragmentAfterLogin} + action={SSOAction.REGISTER} />

{_t("%(ssoButtons)s Or %(usernamePassword)s", { diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index 5eabfa0956c..d6ad4bfb165 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -17,7 +17,7 @@ limitations under the License. import React from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { Optional } from "matrix-events-sdk"; -import { ISSOFlow, LoginFlow } from "matrix-js-sdk/src/@types/auth"; +import { ISSOFlow, LoginFlow, SSOAction } from "matrix-js-sdk/src/@types/auth"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; @@ -256,6 +256,7 @@ export default class SoftLogout extends React.Component { loginType={loginType} fragmentAfterLogin={this.props.fragmentAfterLogin} primary={!this.state.flows.find((flow) => flow.type === "m.login.password")} + action={SSOAction.LOGIN} />

); diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index 78fcdf7c2ee..4a995e4d06b 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -692,6 +692,89 @@ export class MsisdnAuthEntry extends React.Component { + public static readonly LOGIN_TYPE = AuthType.RegistrationToken; + + public constructor(props: IAuthEntryProps) { + super(props); + + this.state = { + registrationToken: "", + }; + } + + public componentDidMount(): void { + this.props.onPhaseChange(DEFAULT_PHASE); + } + + private onSubmit = (e: FormEvent): void => { + e.preventDefault(); + if (this.props.busy) return; + + this.props.submitAuthDict({ + // Could be AuthType.RegistrationToken or AuthType.UnstableRegistrationToken + type: this.props.loginType, + token: this.state.registrationToken, + }); + }; + + private onRegistrationTokenFieldChange = (ev: ChangeEvent): void => { + // enable the submit button if the registration token is non-empty + this.setState({ + registrationToken: ev.target.value, + }); + }; + + public render(): JSX.Element { + const registrationTokenBoxClass = classNames({ + error: this.props.errorText, + }); + + let submitButtonOrSpinner; + if (this.props.busy) { + submitButtonOrSpinner = ; + } else { + submitButtonOrSpinner = ( + + {_t("Continue")} + + ); + } + + let errorSection; + if (this.props.errorText) { + errorSection = ( +
+ {this.props.errorText} +
+ ); + } + + return ( +
+

{_t("Enter a registration token provided by the homeserver administrator.")}

+
+ + {errorSection} +
{submitButtonOrSpinner}
+ +
+ ); + } +} + interface ISSOAuthEntryProps extends IAuthEntryProps { continueText?: string; continueKind?: string; @@ -713,7 +796,7 @@ export class SSOAuthEntry extends React.Component return ( M_POLL_START.matches(mxEvent.getType()) && this.state.canRedact && - !isPollEnded(mxEvent, MatrixClientPeg.get(), this.props.getRelationsForEvent) + !isPollEnded(mxEvent, MatrixClientPeg.get()) ); } diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index 862bf27c8ad..0df6ce42065 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -1,6 +1,6 @@ /* Copyright 2022 Michael Telatynski <7t3chguy@gmail.com> -Copyright 2018-2021 The Matrix.org Foundation C.I.C. +Copyright 2018-2023 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -32,6 +32,8 @@ import SettingsFlag from "../elements/SettingsFlag"; import { SettingLevel } from "../../../settings/SettingLevel"; import ServerInfo from "./devtools/ServerInfo"; import { Features } from "../../../settings/Settings"; +import CopyableText from "../elements/CopyableText"; +import RoomNotifications from "./devtools/RoomNotifications"; enum Category { Room, @@ -43,13 +45,14 @@ const categoryLabels: Record = { [Category.Other]: _td("Other"), }; -export type Tool = React.FC; +export type Tool = React.FC | ((props: IDevtoolsProps) => JSX.Element); const Tools: Record = { [Category.Room]: [ [_td("Send custom timeline event"), TimelineEventEditor], [_td("Explore room state"), RoomStateExplorer], [_td("Explore room account data"), RoomAccountDataExplorer], [_td("View servers in room"), ServersInRoom], + [_td("Notifications debug"), RoomNotifications], [_td("Verification explorer"), VerificationExplorer], [_td("Active Widgets"), WidgetExplorer], ], @@ -119,11 +122,15 @@ const DevtoolsDialog: React.FC = ({ roomId, onFinished }) => { {(cli) => ( <>
{label}
-
{_t("Room ID: %(roomId)s", { roomId })}
+ roomId} border={false}> + {_t("Room ID: %(roomId)s", { roomId })} +
- - {body} - + {cli.getRoom(roomId) && ( + + {body} + + )} )} diff --git a/src/components/views/dialogs/EndPollDialog.tsx b/src/components/views/dialogs/EndPollDialog.tsx index 946f209d31c..463605553e2 100644 --- a/src/components/views/dialogs/EndPollDialog.tsx +++ b/src/components/views/dialogs/EndPollDialog.tsx @@ -35,26 +35,34 @@ interface IProps extends IDialogProps { } export default class EndPollDialog extends React.Component { - private onFinished = (endPoll: boolean): void => { - const topAnswer = findTopAnswer(this.props.event, this.props.matrixClient, this.props.getRelationsForEvent); + private onFinished = async (endPoll: boolean): Promise => { + if (endPoll) { + const room = this.props.matrixClient.getRoom(this.props.event.getRoomId()); + const poll = room?.polls.get(this.props.event.getId()!); - const message = - topAnswer === "" - ? _t("The poll has ended. No votes were cast.") - : _t("The poll has ended. Top answer: %(topAnswer)s", { topAnswer }); + if (!poll) { + throw new Error("No poll instance found in room."); + } - if (endPoll) { - const endEvent = PollEndEvent.from(this.props.event.getId(), message).serialize(); + try { + const responses = await poll.getResponses(); + const topAnswer = findTopAnswer(this.props.event, responses); + + const message = + topAnswer === "" + ? _t("The poll has ended. No votes were cast.") + : _t("The poll has ended. Top answer: %(topAnswer)s", { topAnswer }); + + const endEvent = PollEndEvent.from(this.props.event.getId()!, message).serialize(); - this.props.matrixClient - .sendEvent(this.props.event.getRoomId(), endEvent.type, endEvent.content) - .catch((e: any) => { - console.error("Failed to submit poll response event:", e); - Modal.createDialog(ErrorDialog, { - title: _t("Failed to end poll"), - description: _t("Sorry, the poll did not end. Please try again."), - }); + await this.props.matrixClient.sendEvent(this.props.event.getRoomId()!, endEvent.type, endEvent.content); + } catch (e) { + console.error("Failed to submit poll response event:", e); + Modal.createDialog(ErrorDialog, { + title: _t("Failed to end poll"), + description: _t("Sorry, the poll did not end. Please try again."), }); + } } this.props.onFinished(endPoll); }; diff --git a/src/components/views/dialogs/MessageEditHistoryDialog.tsx b/src/components/views/dialogs/MessageEditHistoryDialog.tsx index 943e7f58d2d..8775b4eb5c3 100644 --- a/src/components/views/dialogs/MessageEditHistoryDialog.tsx +++ b/src/components/views/dialogs/MessageEditHistoryDialog.tsx @@ -130,7 +130,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent { - if (!lastEvent || wantsDateSeparator(lastEvent.getDate(), e.getDate())) { + if (!lastEvent || wantsDateSeparator(lastEvent.getDate() || undefined, e.getDate() || undefined)) { nodes.push(
  • diff --git a/src/components/views/dialogs/ServerOfflineDialog.tsx b/src/components/views/dialogs/ServerOfflineDialog.tsx index bacb4257aea..b4b199661d9 100644 --- a/src/components/views/dialogs/ServerOfflineDialog.tsx +++ b/src/components/views/dialogs/ServerOfflineDialog.tsx @@ -48,7 +48,8 @@ export default class ServerOfflineDialog extends React.PureComponent { private renderTimeline(): React.ReactElement[] { return EchoStore.instance.contexts.map((c, i) => { if (!c.firstFailedTime) return null; // not useful - if (!(c instanceof RoomEchoContext)) throw new Error("Cannot render unknown context: " + c); + if (!(c instanceof RoomEchoContext)) + throw new Error("Cannot render unknown context: " + c.constructor.name); const header = (
    diff --git a/src/components/views/dialogs/devtools/RoomNotifications.tsx b/src/components/views/dialogs/devtools/RoomNotifications.tsx new file mode 100644 index 00000000000..7ddfb5d8baa --- /dev/null +++ b/src/components/views/dialogs/devtools/RoomNotifications.tsx @@ -0,0 +1,180 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { Thread } from "matrix-js-sdk/src/models/thread"; +import React, { useContext } from "react"; + +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { useNotificationState } from "../../../../hooks/useRoomNotificationState"; +import { _t } from "../../../../languageHandler"; +import { determineUnreadState } from "../../../../RoomNotifs"; +import { humanReadableNotificationColor } from "../../../../stores/notifications/NotificationColor"; +import { doesRoomOrThreadHaveUnreadMessages } from "../../../../Unread"; +import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool"; + +export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Element { + const { room } = useContext(DevtoolsContext); + const cli = useContext(MatrixClientContext); + + const { color, count } = determineUnreadState(room); + const [notificationState] = useNotificationState(room); + + return ( + +
    +

    {_t("Room status")}

    +
      +
    • + {_t("Room unread status: ")} + {humanReadableNotificationColor(color)} + {count > 0 && ( + <> + {_t(", count:")} {count} + + )} +
    • +
    • + {_t("Notification state is")} {notificationState} +
    • +
    • + {_t("Room is ")} + + {cli.isRoomEncrypted(room.roomId!) ? _t("encrypted ✅") : _t("not encrypted 🚨")} + +
    • +
    +
    + +
    +

    {_t("Main timeline")}

    + +
      +
    • + {_t("Total: ")} {room.getRoomUnreadNotificationCount(NotificationCountType.Total)} +
    • +
    • + {_t("Highlight: ")} {room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)} +
    • +
    • + {_t("Dot: ")} {doesRoomOrThreadHaveUnreadMessages(room) + ""} +
    • + {roomHasUnread(room) && ( + <> +
    • + {_t("User read up to: ")} + + {room.getReadReceiptForUserId(cli.getSafeUserId())?.eventId ?? + _t("No receipt found")} + +
    • +
    • + {_t("Last event:")} +
        +
      • + {_t("ID: ")} {room.timeline[room.timeline.length - 1].getId()} +
      • +
      • + {_t("Type: ")}{" "} + {room.timeline[room.timeline.length - 1].getType()} +
      • +
      • + {_t("Sender: ")}{" "} + {room.timeline[room.timeline.length - 1].getSender()} +
      • +
      +
    • + + )} +
    +
    + +
    +

    {_t("Threads timeline")}

    +
      + {room + .getThreads() + .filter((thread) => threadHasUnread(thread)) + .map((thread) => ( +
    • + {_t("Thread Id: ")} {thread.id} +
        +
      • + {_t("Total: ")} + + {room.getThreadUnreadNotificationCount( + thread.id, + NotificationCountType.Total, + )} + +
      • +
      • + {_t("Highlight: ")} + + {room.getThreadUnreadNotificationCount( + thread.id, + NotificationCountType.Highlight, + )} + +
      • +
      • + {_t("Dot: ")} {doesRoomOrThreadHaveUnreadMessages(thread) + ""} +
      • +
      • + {_t("User read up to: ")} + + {thread.getReadReceiptForUserId(cli.getSafeUserId())?.eventId ?? + _t("No receipt found")} + +
      • +
      • + {_t("Last event:")} +
          +
        • + {_t("ID: ")} {thread.lastReply()?.getId()} +
        • +
        • + {_t("Type: ")} {thread.lastReply()?.getType()} +
        • +
        • + {_t("Sender: ")} {thread.lastReply()?.getSender()} +
        • +
        +
      • +
      +
    • + ))} +
    +
    +
    + ); +} + +function threadHasUnread(thread: Thread): boolean { + const total = thread.room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total); + const highlight = thread.room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight); + const dot = doesRoomOrThreadHaveUnreadMessages(thread); + + return total > 0 || highlight > 0 || dot; +} + +function roomHasUnread(room: Room): boolean { + const total = room.getRoomUnreadNotificationCount(NotificationCountType.Total); + const highlight = room.getRoomUnreadNotificationCount(NotificationCountType.Highlight); + const dot = doesRoomOrThreadHaveUnreadMessages(room); + + return total > 0 || highlight > 0 || dot; +} diff --git a/src/components/views/dialogs/devtools/VerificationExplorer.tsx b/src/components/views/dialogs/devtools/VerificationExplorer.tsx index 7092d87f645..c535d32b327 100644 --- a/src/components/views/dialogs/devtools/VerificationExplorer.tsx +++ b/src/components/views/dialogs/devtools/VerificationExplorer.tsx @@ -25,7 +25,7 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../../hooks/useEventEmitter"; import { _t, _td } from "../../../../languageHandler"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; -import BaseTool, { DevtoolsContext } from "./BaseTool"; +import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool"; import { Tool } from "../DevtoolsDialog"; const PHASE_MAP: Record = { @@ -81,7 +81,7 @@ const VerificationRequestExplorer: React.FC<{ ); }; -const VerificationExplorer: Tool = ({ onBack }) => { +const VerificationExplorer: Tool = ({ onBack }: IDevtoolsProps) => { const cli = useContext(MatrixClientContext); const context = useContext(DevtoolsContext); diff --git a/src/components/views/dialogs/polls/PollHistoryDialog.tsx b/src/components/views/dialogs/polls/PollHistoryDialog.tsx new file mode 100644 index 00000000000..4671da92465 --- /dev/null +++ b/src/components/views/dialogs/polls/PollHistoryDialog.tsx @@ -0,0 +1,40 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +import { _t } from "../../../../languageHandler"; +import BaseDialog from "../BaseDialog"; +import { IDialogProps } from "../IDialogProps"; +import { PollHistoryList } from "./PollHistoryList"; +import { getPolls } from "./usePollHistory"; + +type PollHistoryDialogProps = Pick & { + roomId: string; + matrixClient: MatrixClient; +}; +export const PollHistoryDialog: React.FC = ({ roomId, matrixClient, onFinished }) => { + const pollStartEvents = getPolls(roomId, matrixClient); + + return ( + +
    + +
    +
    + ); +}; diff --git a/src/components/views/dialogs/polls/PollHistoryList.tsx b/src/components/views/dialogs/polls/PollHistoryList.tsx new file mode 100644 index 00000000000..ff0ea3a7cfc --- /dev/null +++ b/src/components/views/dialogs/polls/PollHistoryList.tsx @@ -0,0 +1,40 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import PollListItem from "./PollListItem"; +import { _t } from "../../../../languageHandler"; + +type PollHistoryListProps = { + pollStartEvents: MatrixEvent[]; +}; +export const PollHistoryList: React.FC = ({ pollStartEvents }) => { + return ( +
    + {!!pollStartEvents.length ? ( +
      + {pollStartEvents.map((pollStartEvent) => ( + + ))} +
    + ) : ( + {_t("There are no polls in this room")} + )} +
    + ); +}; diff --git a/src/components/views/dialogs/polls/PollListItem.tsx b/src/components/views/dialogs/polls/PollListItem.tsx new file mode 100644 index 00000000000..49df399bd73 --- /dev/null +++ b/src/components/views/dialogs/polls/PollListItem.tsx @@ -0,0 +1,43 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { Icon as PollIcon } from "../../../../../res/img/element-icons/room/composer/poll.svg"; +import { formatLocalDateShort } from "../../../../DateUtils"; + +interface Props { + event: MatrixEvent; +} + +const PollListItem: React.FC = ({ event }) => { + const pollEvent = event.unstableExtensibleEvent as unknown as PollStartEvent; + if (!pollEvent) { + return null; + } + const formattedDate = formatLocalDateShort(event.getTs()); + return ( +
  • + {formattedDate} + + {pollEvent.question.text} +
  • + ); +}; + +export default PollListItem; diff --git a/src/components/views/dialogs/polls/usePollHistory.ts b/src/components/views/dialogs/polls/usePollHistory.ts new file mode 100644 index 00000000000..aa730b84ee5 --- /dev/null +++ b/src/components/views/dialogs/polls/usePollHistory.ts @@ -0,0 +1,40 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +/** + * Get poll start events in a rooms live timeline + * @param roomId - id of room to retrieve polls for + * @param matrixClient - client + * @returns {MatrixEvent[]} - array fo poll start events + */ +export const getPolls = (roomId: string, matrixClient: MatrixClient): MatrixEvent[] => { + const room = matrixClient.getRoom(roomId); + + if (!room) { + throw new Error("Cannot find room"); + } + + // @TODO(kerrya) poll history will be actively fetched in PSG-1043 + // for now, just display polls that are in the current timeline + const timelineEvents = room.getLiveTimeline().getEvents(); + const pollStartEvents = timelineEvents.filter((event) => M_POLL_START.matches(event.getType())); + + return pollStartEvents; +}; diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 9bad916ff5d..d7154b3aa2f 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -404,6 +404,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent diff --git a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx index 7fb84292e65..bf255695682 100644 --- a/src/components/views/dialogs/spotlight/SpotlightDialog.tsx +++ b/src/components/views/dialogs/spotlight/SpotlightDialog.tsx @@ -50,7 +50,6 @@ import { useDebouncedCallback } from "../../../../hooks/spotlight/useDebouncedCa import { useRecentSearches } from "../../../../hooks/spotlight/useRecentSearches"; import { useProfileInfo } from "../../../../hooks/useProfileInfo"; import { usePublicRoomDirectory } from "../../../../hooks/usePublicRoomDirectory"; -import { useFeatureEnabled } from "../../../../hooks/useSettings"; import { useSpaceResults } from "../../../../hooks/useSpaceResults"; import { useUserDirectory } from "../../../../hooks/useUserDirectory"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; @@ -93,6 +92,7 @@ import { TooltipOption } from "./TooltipOption"; import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom"; import { shouldShowFeedback } from "../../../../utils/Feedback"; import RoomAvatar from "../../avatars/RoomAvatar"; +import { useFeatureEnabled } from "../../../../hooks/useSettings"; const MAX_RECENT_SEARCHES = 10; const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons @@ -242,8 +242,8 @@ export const useWebSearchMetrics = (numResults: number, queryLength: number, via }, [numResults, queryLength, viaSpotlight]); }; -const findVisibleRooms = (cli: MatrixClient): Room[] => { - return cli.getVisibleRooms().filter((room) => { +const findVisibleRooms = (cli: MatrixClient, msc3946ProcessDynamicPredecessor: boolean): Room[] => { + return cli.getVisibleRooms(msc3946ProcessDynamicPredecessor).filter((room) => { // Do not show local rooms if (isLocalRoom(room)) return false; @@ -252,9 +252,13 @@ const findVisibleRooms = (cli: MatrixClient): Room[] => { }); }; -const findVisibleRoomMembers = (cli: MatrixClient, filterDMs = true): RoomMember[] => { +const findVisibleRoomMembers = ( + cli: MatrixClient, + msc3946ProcessDynamicPredecessor: boolean, + filterDMs = true, +): RoomMember[] => { return Object.values( - findVisibleRooms(cli) + findVisibleRooms(cli, msc3946ProcessDynamicPredecessor) .filter((room) => !filterDMs || !DMRoomMap.shared().getUserIdForRoomId(room.roomId)) .reduce((members, room) => { for (const member of room.getJoinedMembers()) { @@ -304,6 +308,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n const memberScores = buildMemberScores(cli); return compareMembers(activityScores, memberScores); }, [cli]); + const msc3946ProcessDynamicPredecessor = useFeatureEnabled("feature_dynamic_room_predecessors"); const ownInviteLink = makeUserPermalink(cli.getUserId()); const [inviteLinkCopied, setInviteLinkCopied] = useState(false); @@ -339,7 +344,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n const possibleResults = useMemo(() => { const userResults: IMemberResult[] = []; - const roomResults = findVisibleRooms(cli).map(toRoomResult); + const roomResults = findVisibleRooms(cli, msc3946ProcessDynamicPredecessor).map(toRoomResult); // If we already have a DM with the user we're looking for, we will // show that DM instead of the user themselves const alreadyAddedUserIds = roomResults.reduce((userIds, result) => { @@ -349,7 +354,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n userIds.add(userId); return userIds; }, new Set()); - for (const user of [...findVisibleRoomMembers(cli), ...users]) { + for (const user of [...findVisibleRoomMembers(cli, msc3946ProcessDynamicPredecessor), ...users]) { // Make sure we don't have any user more than once if (alreadyAddedUserIds.has(user.userId)) continue; alreadyAddedUserIds.add(user.userId); @@ -381,7 +386,7 @@ const SpotlightDialog: React.FC = ({ initialText = "", initialFilter = n ), ...publicRooms.map(toPublicRoomResult), ].filter((result) => filter === null || result.filter.includes(filter)); - }, [cli, users, profile, publicRooms, filter]); + }, [cli, users, profile, publicRooms, filter, msc3946ProcessDynamicPredecessor]); const results = useMemo>(() => { const results: Record = { diff --git a/src/components/views/elements/EventListSummary.tsx b/src/components/views/elements/EventListSummary.tsx index 4436b2d9bf7..60288fb2f56 100644 --- a/src/components/views/elements/EventListSummary.tsx +++ b/src/components/views/elements/EventListSummary.tsx @@ -507,39 +507,36 @@ export default class EventListSummary extends React.Component { eventsToRender.forEach((e, index) => { const type = e.getType(); - let userId = e.getSender(); - if (type === EventType.RoomMember) { - userId = e.getStateKey(); + let userKey = e.getSender()!; + if (type === EventType.RoomThirdPartyInvite) { + userKey = e.getContent().display_name; + } else if (type === EventType.RoomMember) { + userKey = e.getStateKey(); } else if (e.isRedacted()) { - userId = e.getUnsigned()?.redacted_because?.sender; + userKey = e.getUnsigned()?.redacted_because?.sender; } // Initialise a user's events - if (!userEvents[userId]) { - userEvents[userId] = []; + if (!userEvents[userKey]) { + userEvents[userKey] = []; } - let displayName = userId; - if (type === EventType.RoomThirdPartyInvite) { - displayName = e.getContent().display_name; - if (e.sender) { - latestUserAvatarMember.set(userId, e.sender); - } - } else if (e.isRedacted()) { - const sender = this.context?.room.getMember(userId); + let displayName = userKey; + if (e.isRedacted()) { + const sender = this.context?.room?.getMember(userKey); if (sender) { displayName = sender.name; - latestUserAvatarMember.set(userId, sender); + latestUserAvatarMember.set(userKey, sender); } } else if (e.target && TARGET_AS_DISPLAY_NAME_EVENTS.includes(type as EventType)) { displayName = e.target.name; - latestUserAvatarMember.set(userId, e.target); - } else if (e.sender) { + latestUserAvatarMember.set(userKey, e.target); + } else if (e.sender && type !== EventType.RoomThirdPartyInvite) { displayName = e.sender.name; - latestUserAvatarMember.set(userId, e.sender); + latestUserAvatarMember.set(userKey, e.sender); } - userEvents[userId].push({ + userEvents[userKey].push({ mxEvent: e, displayName, index: index, diff --git a/src/components/views/elements/RoomTopic.tsx b/src/components/views/elements/RoomTopic.tsx index 7f2d068d35d..e760d38218a 100644 --- a/src/components/views/elements/RoomTopic.tsx +++ b/src/components/views/elements/RoomTopic.tsx @@ -29,9 +29,8 @@ import InfoDialog from "../dialogs/InfoDialog"; import { useDispatcher } from "../../../hooks/useDispatcher"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AccessibleButton from "./AccessibleButton"; -import { Linkify } from "./Linkify"; import TooltipTarget from "./TooltipTarget"; -import { topicToHtml } from "../../../HtmlUtils"; +import { Linkify, topicToHtml } from "../../../HtmlUtils"; interface IProps extends React.HTMLProps { room?: Room; @@ -71,12 +70,14 @@ export default function RoomTopic({ room, ...props }: IProps): JSX.Element { description: (
    { - if ((ev.target as HTMLElement).tagName.toUpperCase() === "A") { - modal.close(); - } + options={{ + attributes: { + onClick() { + modal.close(); + }, + }, }} + as="p" > {body} diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index ab9ebc29562..0dffacb7ce9 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -19,7 +19,13 @@ import { chunk } from "lodash"; import classNames from "classnames"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Signup } from "@matrix-org/analytics-events/types/typescript/Signup"; -import { IdentityProviderBrand, IIdentityProvider, ISSOFlow } from "matrix-js-sdk/src/@types/auth"; +import { + IdentityProviderBrand, + IIdentityProvider, + ISSOFlow, + DELEGATED_OIDC_COMPATIBILITY, + SSOAction, +} from "matrix-js-sdk/src/@types/auth"; import PlatformPeg from "../../../PlatformPeg"; import AccessibleButton from "./AccessibleButton"; @@ -28,9 +34,10 @@ import AccessibleTooltipButton from "./AccessibleTooltipButton"; import { mediaFromMxc } from "../../../customisations/Media"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; -interface ISSOButtonProps extends Omit { +interface ISSOButtonProps extends IProps { idp?: IIdentityProvider; mini?: boolean; + action?: SSOAction; } const getIcon = (brand: IdentityProviderBrand | string): string | null => { @@ -79,20 +86,29 @@ const SSOButton: React.FC = ({ idp, primary, mini, + action, + flow, ...props }) => { - const label = idp ? _t("Continue with %(provider)s", { provider: idp.name }) : _t("Sign in with single sign-on"); + let label: string; + if (idp) { + label = _t("Continue with %(provider)s", { provider: idp.name }); + } else if (DELEGATED_OIDC_COMPATIBILITY.findIn(flow)) { + label = _t("Continue"); + } else { + label = _t("Sign in with single sign-on"); + } const onClick = (): void => { const authenticationType = getAuthenticationType(idp?.brand ?? ""); PosthogAnalytics.instance.setAuthenticationType(authenticationType); - PlatformPeg.get().startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id); + PlatformPeg.get()?.startSingleSignOn(matrixClient, loginType, fragmentAfterLogin, idp?.id, action); }; - let icon; - let brandClass; - const brandIcon = idp ? getIcon(idp.brand) : null; - if (brandIcon) { + let icon: JSX.Element | undefined; + let brandClass: string | undefined; + const brandIcon = idp?.brand ? getIcon(idp.brand) : null; + if (idp?.brand && brandIcon) { const brandName = idp.brand.split(".").pop(); brandClass = `mx_SSOButton_brand_${brandName}`; icon = {brandName}; @@ -101,12 +117,16 @@ const SSOButton: React.FC = ({ icon = {idp.name}; } - const classes = classNames("mx_SSOButton", { - [brandClass]: brandClass, - mx_SSOButton_mini: mini, - mx_SSOButton_default: !idp, - mx_SSOButton_primary: primary, - }); + const brandPart = brandClass ? { [brandClass]: brandClass } : undefined; + const classes = classNames( + "mx_SSOButton", + { + mx_SSOButton_mini: mini, + mx_SSOButton_default: !idp, + mx_SSOButton_primary: primary, + }, + brandPart, + ); if (mini) { // TODO fallback icon @@ -128,14 +148,15 @@ const SSOButton: React.FC = ({ interface IProps { matrixClient: MatrixClient; flow: ISSOFlow; - loginType?: "sso" | "cas"; + loginType: "sso" | "cas"; fragmentAfterLogin?: string; primary?: boolean; + action?: SSOAction; } const MAX_PER_ROW = 6; -const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary }) => { +const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary, action }) => { const providers = flow.identity_providers || []; if (providers.length < 2) { return ( @@ -146,6 +167,8 @@ const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentA fragmentAfterLogin={fragmentAfterLogin} idp={providers[0]} primary={primary} + action={action} + flow={flow} />
    ); @@ -167,6 +190,8 @@ const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentA idp={idp} mini={true} primary={primary} + action={action} + flow={flow} /> ))}
    diff --git a/src/components/views/messages/MKeyVerificationConclusion.tsx b/src/components/views/messages/MKeyVerificationConclusion.tsx index 656745997c8..24b925544e3 100644 --- a/src/components/views/messages/MKeyVerificationConclusion.tsx +++ b/src/components/views/messages/MKeyVerificationConclusion.tsx @@ -72,7 +72,7 @@ export default class MKeyVerificationConclusion extends React.Component this.forceUpdate(); }; - public static shouldRender(mxEvent: MatrixEvent, request: VerificationRequest): boolean { + public static shouldRender(mxEvent: MatrixEvent, request?: VerificationRequest): boolean { // normally should not happen if (!request) { return false; @@ -99,9 +99,9 @@ export default class MKeyVerificationConclusion extends React.Component return true; } - public render(): JSX.Element { + public render(): JSX.Element | null { const { mxEvent } = this.props; - const request = mxEvent.verificationRequest; + const request = mxEvent.verificationRequest!; if (!MKeyVerificationConclusion.shouldRender(mxEvent, request)) { return null; @@ -110,7 +110,7 @@ export default class MKeyVerificationConclusion extends React.Component const client = MatrixClientPeg.get(); const myUserId = client.getUserId(); - let title; + let title: string | undefined; if (request.done) { title = _t("You verified %(name)s", { diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index 7a9dfbe7c65..ad48f7fde2a 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -14,17 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ReactNode } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; -import { Relations, RelationsEvent } from "matrix-js-sdk/src/models/relations"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Relations } from "matrix-js-sdk/src/models/relations"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations"; -import { NamespacedValue } from "matrix-events-sdk"; import { PollStartEvent, PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent"; +import { Poll, PollEvent } from "matrix-js-sdk/src/models/poll"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; @@ -36,11 +36,14 @@ import ErrorDialog from "../dialogs/ErrorDialog"; import { GetRelationsForEvent } from "../rooms/EventTile"; import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import Spinner from "../elements/Spinner"; interface IState { + poll?: Poll; + // poll instance has fetched at least one page of responses + pollInitialised: boolean; selected?: string | null | undefined; // Which option was clicked by the local user - voteRelations: RelatedRelations; // Voting (response) events - endRelations: RelatedRelations; // Poll end events + voteRelations?: Relations; // Voting (response) events } export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, eventId: string): RelatedRelations { @@ -59,15 +62,7 @@ export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, return new RelatedRelations(relationsList); } -export function findTopAnswer( - pollEvent: MatrixEvent, - matrixClient: MatrixClient, - getRelationsForEvent?: GetRelationsForEvent, -): string { - if (!getRelationsForEvent) { - return ""; - } - +export function findTopAnswer(pollEvent: MatrixEvent, voteRelations: Relations): string { const pollEventId = pollEvent.getId(); if (!pollEventId) { logger.warn( @@ -87,25 +82,7 @@ export function findTopAnswer( return poll.answers.find((a) => a.id === answerId)?.text ?? ""; }; - const voteRelations = createVoteRelations(getRelationsForEvent, pollEventId); - - const relationsList: Relations[] = []; - - const pollEndRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.name); - if (pollEndRelations) { - relationsList.push(pollEndRelations); - } - - const pollEndAltRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.altName); - if (pollEndAltRelations) { - relationsList.push(pollEndAltRelations); - } - - const endRelations = new RelatedRelations(relationsList); - - const userVotes: Map = collectUserVotes( - allVotes(pollEvent, matrixClient, voteRelations, endRelations), - ); + const userVotes: Map = collectUserVotes(allVotes(voteRelations)); const votes: Map = countVotes(userVotes, poll); const highestScore: number = Math.max(...votes.values()); @@ -122,62 +99,13 @@ export function findTopAnswer( return formatCommaSeparatedList(bestAnswerTexts, 3); } -export function isPollEnded( - pollEvent: MatrixEvent, - matrixClient: MatrixClient, - getRelationsForEvent?: GetRelationsForEvent, -): boolean { - if (!getRelationsForEvent) { - return false; - } - - const pollEventId = pollEvent.getId(); - if (!pollEventId) { - logger.warn( - "isPollEnded: Poll event must have event ID in order to determine whether it has ended " + - "- assuming poll has not ended", - ); - return false; - } - - const roomId = pollEvent.getRoomId(); - if (!roomId) { - logger.warn( - "isPollEnded: Poll event must have room ID in order to determine whether it has ended " + - "- assuming poll has not ended", - ); +export function isPollEnded(pollEvent: MatrixEvent, matrixClient: MatrixClient): boolean { + const room = matrixClient.getRoom(pollEvent.getRoomId()); + const poll = room?.polls.get(pollEvent.getId()!); + if (!poll || poll.isFetchingResponses) { return false; } - - const roomCurrentState = matrixClient.getRoom(roomId)?.currentState; - function userCanRedact(endEvent: MatrixEvent): boolean { - const endEventSender = endEvent.getSender(); - return ( - endEventSender && roomCurrentState && roomCurrentState.maySendRedactionForEvent(pollEvent, endEventSender) - ); - } - - const relationsList: Relations[] = []; - - const pollEndRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.name); - if (pollEndRelations) { - relationsList.push(pollEndRelations); - } - - const pollEndAltRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.altName); - if (pollEndAltRelations) { - relationsList.push(pollEndAltRelations); - } - - const endRelations = new RelatedRelations(relationsList); - - if (!endRelations) { - return false; - } - - const authorisedRelations = endRelations.getRelations().filter(userCanRedact); - - return authorisedRelations.length > 0; + return poll.isEnded; } export function pollAlreadyHasVotes(mxEvent: MatrixEvent, getRelationsForEvent?: GetRelationsForEvent): boolean { @@ -215,75 +143,58 @@ export default class MPollBody extends React.Component { public static contextType = MatrixClientContext; public context!: React.ContextType; private seenEventIds: string[] = []; // Events we have already seen - private voteRelationsReceived = false; - private endRelationsReceived = false; public constructor(props: IBodyProps) { super(props); this.state = { selected: null, - voteRelations: this.fetchVoteRelations(), - endRelations: this.fetchEndRelations(), + pollInitialised: false, }; + } - this.addListeners(this.state.voteRelations, this.state.endRelations); - this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + public componentDidMount(): void { + const room = this.context.getRoom(this.props.mxEvent.getRoomId()); + const poll = room?.polls.get(this.props.mxEvent.getId()!); + if (poll) { + this.setPollInstance(poll); + } else { + room?.on(PollEvent.New, this.setPollInstance.bind(this)); + } } public componentWillUnmount(): void { - this.props.mxEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); - this.removeListeners(this.state.voteRelations, this.state.endRelations); + this.removeListeners(); } - private addListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations): void { - if (voteRelations) { - voteRelations.on(RelationsEvent.Add, this.onRelationsChange); - voteRelations.on(RelationsEvent.Remove, this.onRelationsChange); - voteRelations.on(RelationsEvent.Redaction, this.onRelationsChange); - } - if (endRelations) { - endRelations.on(RelationsEvent.Add, this.onRelationsChange); - endRelations.on(RelationsEvent.Remove, this.onRelationsChange); - endRelations.on(RelationsEvent.Redaction, this.onRelationsChange); + private async setPollInstance(poll: Poll): Promise { + if (poll.pollId !== this.props.mxEvent.getId()) { + return; } - } + this.setState({ poll }, () => { + this.addListeners(); + }); + const responses = await poll.getResponses(); + const voteRelations = responses; - private removeListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations): void { - if (voteRelations) { - voteRelations.off(RelationsEvent.Add, this.onRelationsChange); - voteRelations.off(RelationsEvent.Remove, this.onRelationsChange); - voteRelations.off(RelationsEvent.Redaction, this.onRelationsChange); - } - if (endRelations) { - endRelations.off(RelationsEvent.Add, this.onRelationsChange); - endRelations.off(RelationsEvent.Remove, this.onRelationsChange); - endRelations.off(RelationsEvent.Redaction, this.onRelationsChange); - } + this.setState({ pollInitialised: true, voteRelations }); } - private onRelationsCreated = (relationType: string, eventType: string): void => { - if (relationType !== "m.reference") { - return; - } + private addListeners(): void { + this.state.poll?.on(PollEvent.Responses, this.onResponsesChange); + this.state.poll?.on(PollEvent.End, this.onRelationsChange); + } - if (M_POLL_RESPONSE.matches(eventType)) { - this.voteRelationsReceived = true; - const newVoteRelations = this.fetchVoteRelations(); - this.addListeners(newVoteRelations); - this.removeListeners(this.state.voteRelations); - this.setState({ voteRelations: newVoteRelations }); - } else if (M_POLL_END.matches(eventType)) { - this.endRelationsReceived = true; - const newEndRelations = this.fetchEndRelations(); - this.addListeners(newEndRelations); - this.removeListeners(this.state.endRelations); - this.setState({ endRelations: newEndRelations }); + private removeListeners(): void { + if (this.state.poll) { + this.state.poll.off(PollEvent.Responses, this.onResponsesChange); + this.state.poll.off(PollEvent.End, this.onRelationsChange); } + } - if (this.voteRelationsReceived && this.endRelationsReceived) { - this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); - } + private onResponsesChange = (responses: Relations): void => { + this.setState({ voteRelations: responses }); + this.onRelationsChange(); }; private onRelationsChange = (): void => { @@ -295,19 +206,19 @@ export default class MPollBody extends React.Component { }; private selectOption(answerId: string): void { - if (this.isEnded()) { + if (this.state.poll?.isEnded) { return; } const userVotes = this.collectUserVotes(); - const userId = this.context.getUserId(); + const userId = this.context.getSafeUserId(); const myVote = userVotes.get(userId)?.answers[0]; if (answerId === myVote) { return; } - const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()).serialize(); + const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()!).serialize(); - this.context.sendEvent(this.props.mxEvent.getRoomId(), response.type, response.content).catch((e: any) => { + this.context.sendEvent(this.props.mxEvent.getRoomId()!, response.type, response.content).catch((e: any) => { console.error("Failed to submit poll response event:", e); Modal.createDialog(ErrorDialog, { @@ -323,51 +234,14 @@ export default class MPollBody extends React.Component { this.selectOption(e.currentTarget.value); }; - private fetchVoteRelations(): RelatedRelations | null { - return this.fetchRelations(M_POLL_RESPONSE); - } - - private fetchEndRelations(): RelatedRelations | null { - return this.fetchRelations(M_POLL_END); - } - - private fetchRelations(eventType: NamespacedValue): RelatedRelations | null { - if (this.props.getRelationsForEvent) { - const relationsList: Relations[] = []; - - const eventId = this.props.mxEvent.getId(); - if (!eventId) { - return null; - } - - const relations = this.props.getRelationsForEvent(eventId, "m.reference", eventType.name); - if (relations) { - relationsList.push(relations); - } - - // If there is an alternatve experimental event type, also look for that - if (eventType.altName) { - const altRelations = this.props.getRelationsForEvent(eventId, "m.reference", eventType.altName); - if (altRelations) { - relationsList.push(altRelations); - } - } - - return new RelatedRelations(relationsList); - } else { - return null; - } - } - /** * @returns userId -> UserVote */ private collectUserVotes(): Map { - return collectUserVotes( - allVotes(this.props.mxEvent, this.context, this.state.voteRelations, this.state.endRelations), - this.context.getUserId(), - this.state.selected, - ); + if (!this.state.voteRelations) { + return new Map(); + } + return collectUserVotes(allVotes(this.state.voteRelations), this.context.getUserId(), this.state.selected); } /** @@ -379,10 +253,10 @@ export default class MPollBody extends React.Component { * have already seen. */ private unselectIfNewEventFromMe(): void { - const newEvents: MatrixEvent[] = this.state.voteRelations - .getRelations() - .filter(isPollResponse) - .filter((mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!)); + const relations = this.state.voteRelations?.getRelations() || []; + const newEvents: MatrixEvent[] = relations.filter( + (mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!), + ); let newSelected = this.state.selected; if (newEvents.length > 0) { @@ -392,7 +266,7 @@ export default class MPollBody extends React.Component { } } } - const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId()); + const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId()!); this.seenEventIds = this.seenEventIds.concat(newEventIds); this.setState({ selected: newSelected }); } @@ -405,30 +279,30 @@ export default class MPollBody extends React.Component { return sum; } - private isEnded(): boolean { - return isPollEnded(this.props.mxEvent, this.context, this.props.getRelationsForEvent); - } + public render(): ReactNode { + const { poll, pollInitialised } = this.state; + if (!poll?.pollEvent) { + return null; + } - public render(): JSX.Element { - const poll = this.props.mxEvent.unstableExtensibleEvent as PollStartEvent; - if (!poll?.isEquivalentTo(M_POLL_START)) return null; // invalid + const pollEvent = poll.pollEvent; - const ended = this.isEnded(); - const pollId = this.props.mxEvent.getId(); + const pollId = this.props.mxEvent.getId()!; + const isFetchingResponses = !pollInitialised || poll.isFetchingResponses; const userVotes = this.collectUserVotes(); - const votes = countVotes(userVotes, poll); + const votes = countVotes(userVotes, pollEvent); const totalVotes = this.totalVotes(votes); const winCount = Math.max(...votes.values()); const userId = this.context.getUserId(); const myVote = userVotes?.get(userId!)?.answers[0]; - const disclosed = M_POLL_KIND_DISCLOSED.matches(poll.kind.name); + const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name); // Disclosed: votes are hidden until I vote or the poll ends // Undisclosed: votes are hidden until poll ends - const showResults = ended || (disclosed && myVote !== undefined); + const showResults = poll.isEnded || (disclosed && myVote !== undefined); let totalText: string; - if (ended) { + if (poll.isEnded) { totalText = _t("Final result based on %(count)s votes", { count: totalVotes }); } else if (!disclosed) { totalText = _t("Results will be visible when the poll is ended"); @@ -449,11 +323,11 @@ export default class MPollBody extends React.Component { return (

    - {poll.question.text} + {pollEvent.question.text} {editedSpan}

    - {poll.answers.map((answer: PollAnswerSubevent) => { + {pollEvent.answers.map((answer: PollAnswerSubevent) => { let answerVotes = 0; let votesText = ""; @@ -462,11 +336,12 @@ export default class MPollBody extends React.Component { votesText = _t("%(count)s votes", { count: answerVotes }); } - const checked = (!ended && myVote === answer.id) || (ended && answerVotes === winCount); + const checked = + (!poll.isEnded && myVote === answer.id) || (poll.isEnded && answerVotes === winCount); const cls = classNames({ mx_MPollBody_option: true, mx_MPollBody_option_checked: checked, - mx_MPollBody_option_ended: ended, + mx_MPollBody_option_ended: poll.isEnded, }); const answerPercent = totalVotes === 0 ? 0 : Math.round((100.0 * answerVotes) / totalVotes); @@ -477,7 +352,7 @@ export default class MPollBody extends React.Component { className={cls} onClick={() => this.selectOption(answer.id)} > - {ended ? ( + {poll.isEnded ? ( ) : ( {
    {totalText} + {isFetchingResponses && } {this.props.scBubbleTimestamp}
    @@ -563,68 +439,17 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote { throw new Error("Failed to parse Poll Response Event to determine user response"); } - return new UserVote(event.getTs(), event.getSender(), response.answerIds); + return new UserVote(event.getTs(), event.getSender()!, response.answerIds); } -export function allVotes( - pollEvent: MatrixEvent, - matrixClient: MatrixClient, - voteRelations: RelatedRelations, - endRelations: RelatedRelations, -): Array { - const endTs = pollEndTs(pollEvent, matrixClient, endRelations); - - function isOnOrBeforeEnd(responseEvent: MatrixEvent): boolean { - // From MSC3381: - // "Votes sent on or before the end event's timestamp are valid votes" - return endTs === null || responseEvent.getTs() <= endTs; - } - +export function allVotes(voteRelations: Relations): Array { if (voteRelations) { - return voteRelations - .getRelations() - .filter(isPollResponse) - .filter(isOnOrBeforeEnd) - .map(userResponseFromPollResponseEvent); + return voteRelations.getRelations().map(userResponseFromPollResponseEvent); } else { return []; } } -/** - * Returns the earliest timestamp from the supplied list of end_poll events - * or null if there are no authorised events. - */ -export function pollEndTs( - pollEvent: MatrixEvent, - matrixClient: MatrixClient, - endRelations: RelatedRelations, -): number | null { - if (!endRelations) { - return null; - } - - const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState; - function userCanRedact(endEvent: MatrixEvent): boolean { - return roomCurrentState.maySendRedactionForEvent(pollEvent, endEvent.getSender()); - } - - const tss: number[] = endRelations - .getRelations() - .filter(userCanRedact) - .map((evt: MatrixEvent) => evt.getTs()); - - if (tss.length === 0) { - return null; - } else { - return Math.min(...tss); - } -} - -function isPollResponse(responseEvent: MatrixEvent): boolean { - return responseEvent.unstableExtensibleEvent?.isEquivalentTo(M_POLL_RESPONSE); -} - /** * Figure out the correct vote for each user. * @param userResponses current vote responses in the poll @@ -663,7 +488,7 @@ function countVotes(userVotes: Map, pollStart: PollStartEvent) if (!tempResponse.spoiled) { for (const answerId of tempResponse.answerIds) { if (collected.has(answerId)) { - collected.set(answerId, collected.get(answerId) + 1); + collected.set(answerId, collected.get(answerId)! + 1); } else { collected.set(answerId, 1); } diff --git a/src/components/views/messages/RoomCreate.tsx b/src/components/views/messages/RoomCreate.tsx deleted file mode 100644 index a9035ca03cb..00000000000 --- a/src/components/views/messages/RoomCreate.tsx +++ /dev/null @@ -1,75 +0,0 @@ -/* -Copyright 2018 New Vector Ltd -Copyright 2019 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 { MatrixEvent } from "matrix-js-sdk/src/models/event"; - -import dis from "../../../dispatcher/dispatcher"; -import { Action } from "../../../dispatcher/actions"; -import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { _t } from "../../../languageHandler"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import EventTileBubble from "./EventTileBubble"; -import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; - -interface IProps { - /* the MatrixEvent to show */ - mxEvent: MatrixEvent; - timestamp?: JSX.Element; -} - -export default class RoomCreate extends React.Component { - private onLinkClicked = (e: React.MouseEvent): void => { - e.preventDefault(); - - const predecessor = this.props.mxEvent.getContent()["predecessor"]; - - dis.dispatch({ - action: Action.ViewRoom, - event_id: predecessor["event_id"], - highlighted: true, - room_id: predecessor["room_id"], - metricsTrigger: "Predecessor", - metricsViaKeyboard: e.type !== "click", - }); - }; - - public render(): JSX.Element { - const predecessor = this.props.mxEvent.getContent()["predecessor"]; - if (predecessor === undefined) { - return
    ; // We should never have been instantiated in this case - } - const prevRoom = MatrixClientPeg.get().getRoom(predecessor["room_id"]); - const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor["room_id"]); - permalinkCreator.load(); - const predecessorPermalink = permalinkCreator.forEvent(predecessor["event_id"]); - const link = ( - - {_t("Click here to see older messages.")} - - ); - - return ( - - ); - } -} diff --git a/src/components/views/messages/RoomPredecessorTile.tsx b/src/components/views/messages/RoomPredecessorTile.tsx new file mode 100644 index 00000000000..5c47cda56fe --- /dev/null +++ b/src/components/views/messages/RoomPredecessorTile.tsx @@ -0,0 +1,115 @@ +/* +Copyright 2018 New Vector Ltd +Copyright 2019, 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { useCallback, useContext } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import dis from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { _t } from "../../../languageHandler"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import EventTileBubble from "./EventTileBubble"; +import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import RoomContext from "../../../contexts/RoomContext"; +import { useRoomState } from "../../../hooks/useRoomState"; +import SettingsStore from "../../../settings/SettingsStore"; + +interface IProps { + /** The m.room.create MatrixEvent that this tile represents */ + mxEvent: MatrixEvent; + timestamp?: JSX.Element; +} + +/** + * A message tile showing that this room was created as an upgrade of a previous + * room. + */ +export const RoomPredecessorTile: React.FC = ({ mxEvent, timestamp }) => { + const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors"); + + // Note: we ask the room for its predecessor here, instead of directly using + // the information inside mxEvent. This allows us the flexibility later to + // use a different predecessor (e.g. through MSC3946) and still display it + // in the timeline location of the create event. + const roomContext = useContext(RoomContext); + const predecessor = useRoomState( + roomContext.room, + useCallback( + (state) => state.findPredecessor(msc3946ProcessDynamicPredecessor), + [msc3946ProcessDynamicPredecessor], + ), + ); + + const onLinkClicked = useCallback( + (e: React.MouseEvent): void => { + e.preventDefault(); + + dis.dispatch({ + action: Action.ViewRoom, + event_id: predecessor.eventId, + highlighted: true, + room_id: predecessor.roomId, + metricsTrigger: "Predecessor", + metricsViaKeyboard: e.type !== "click", + }); + }, + [predecessor?.eventId, predecessor?.roomId], + ); + + if (!roomContext.room || roomContext.room.roomId !== mxEvent.getRoomId()) { + logger.warn( + "RoomPredecessorTile unexpectedly used outside of the context of the" + + "room containing this m.room.create event.", + ); + return <>; + } + + if (!predecessor) { + logger.warn("RoomPredecessorTile unexpectedly used in a room with no predecessor."); + return
    ; + } + + const prevRoom = MatrixClientPeg.get().getRoom(predecessor.roomId); + if (!prevRoom) { + logger.warn(`Failed to find predecessor room with id ${predecessor.roomId}`); + return <>; + } + const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor.roomId); + permalinkCreator.load(); + let predecessorPermalink: string; + if (predecessor.eventId) { + predecessorPermalink = permalinkCreator.forEvent(predecessor.eventId); + } else { + predecessorPermalink = permalinkCreator.forRoom(); + } + const link = ( + + {_t("Click here to see older messages.")} + + ); + + return ( + + ); +}; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 7f4e4197a9e..c57ffcd7a21 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -266,7 +266,7 @@ export default class TextualBody extends React.Component { // We don't use highlightElement here because we can't force language detection // off. It should use the one we've found in the CSS class but we'd rather pass // it in explicitly to make sure. - code.innerHTML = highlight.highlight(advertisedLang, code.textContent).value; + code.innerHTML = highlight.highlight(code.textContent, { language: advertisedLang }).value; } else if ( SettingsStore.getValue("enableSyntaxHighlightLanguageDetection") && code.parentElement instanceof HTMLPreElement @@ -438,7 +438,7 @@ export default class TextualBody extends React.Component { private onBodyLinkClick = (e: MouseEvent): void => { let target = e.target as HTMLLinkElement; // links processed by linkifyjs have their own handler so don't handle those here - if (target.classList.contains(linkifyOpts.className)) return; + if (target.classList.contains(linkifyOpts.className as string)) return; if (target.nodeName !== "A") { // Jump to parent as the `` may contain children, e.g. an anchor wrapping an inline code section target = target.closest("a"); diff --git a/src/components/views/right_panel/PinnedMessagesCard.tsx b/src/components/views/right_panel/PinnedMessagesCard.tsx index 02032f0f663..561a21bb76d 100644 --- a/src/components/views/right_panel/PinnedMessagesCard.tsx +++ b/src/components/views/right_panel/PinnedMessagesCard.tsx @@ -135,6 +135,7 @@ const PinnedMessagesCard: React.FC = ({ room, onClose, userNameColorMode if (event.isEncrypted()) { await cli.decryptEventIfNeeded(event); // TODO await? } + await room.processPollEvents([event]); if (event && PinningUtils.isPinnable(event)) { // Inject sender information diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index 93e549b4aba..67509801d37 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -22,7 +22,6 @@ import React from "react"; import classNames from "classnames"; import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { ThreadEvent } from "matrix-js-sdk/src/models/thread"; -import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { _t } from "../../../languageHandler"; import HeaderButton from "./HeaderButton"; @@ -39,12 +38,9 @@ import { UPDATE_STATUS_INDICATOR, } from "../../../stores/notifications/RoomNotificationStateStore"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; -import { ThreadsRoomNotificationState } from "../../../stores/notifications/ThreadsRoomNotificationState"; import { SummarizedNotificationState } from "../../../stores/notifications/SummarizedNotificationState"; -import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import PosthogTrackers from "../../../PosthogTrackers"; import { ButtonEvent } from "../elements/AccessibleButton"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread"; const ROOM_INFO_PHASES = [ @@ -133,74 +129,48 @@ interface IProps { export default class RoomHeaderButtons extends HeaderButtons { private static readonly THREAD_PHASES = [RightPanelPhases.ThreadPanel, RightPanelPhases.ThreadView]; - private threadNotificationState: ThreadsRoomNotificationState | null; private globalNotificationState: SummarizedNotificationState; - private get supportsThreadNotifications(): boolean { - const client = MatrixClientPeg.get(); - return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; - } - public constructor(props: IProps) { super(props, HeaderKind.Room); - - this.threadNotificationState = - !this.supportsThreadNotifications && this.props.room - ? RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room) - : null; this.globalNotificationState = RoomNotificationStateStore.instance.globalState; } public componentDidMount(): void { super.componentDidMount(); - if (!this.supportsThreadNotifications) { - this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate); - } else { - // Notification badge may change if the notification counts from the - // server change, if a new thread is created or updated, or if a - // receipt is sent in the thread. - this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); - this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate); - this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate); - this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate); - } + // Notification badge may change if the notification counts from the + // server change, if a new thread is created or updated, or if a + // receipt is sent in the thread. + this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate); + this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate); + this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate); this.onNotificationUpdate(); RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } public componentWillUnmount(): void { super.componentWillUnmount(); - if (!this.supportsThreadNotifications) { - this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate); - } else { - this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); - this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate); - this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate); - this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate); - } + this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate); + this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate); + this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate); RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } private onNotificationUpdate = (): void => { - let threadNotificationColor: NotificationColor; - if (!this.supportsThreadNotifications) { - threadNotificationColor = this.threadNotificationState?.color ?? NotificationColor.None; - } else { - threadNotificationColor = this.notificationColor; - } - // console.log // XXX: why don't we read from this.state.threadNotificationColor in the render methods? this.setState({ - threadNotificationColor, + threadNotificationColor: this.notificationColor, }); }; diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index baf7683f08a..9b9fdb7d191 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -23,7 +23,7 @@ import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import BaseCard, { Group } from "./BaseCard"; import { _t } from "../../../languageHandler"; import RoomAvatar from "../avatars/RoomAvatar"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent, IAccessibleButtonProps } from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import Modal from "../../../Modal"; @@ -52,6 +52,7 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import RoomTopic from "../elements/RoomTopic"; import PosthogTrackers from "../../../PosthogTrackers"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; +import { PollHistoryDialog } from "../dialogs/polls/PollHistoryDialog"; interface IProps { room: Room; @@ -62,14 +63,15 @@ interface IAppsSectionProps { room: Room; } -interface IButtonProps { +interface IButtonProps extends IAccessibleButtonProps { className: string; onClick(ev: ButtonEvent): void; } -const Button: React.FC = ({ children, className, onClick }) => { +const Button: React.FC = ({ children, className, onClick, ...props }) => { return ( @@ -282,6 +284,13 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { }); }; + const onRoomPollHistoryClick = (): void => { + Modal.createDialog(PollHistoryDialog, { + roomId: room.roomId, + matrixClient: cli, + }); + }; + const isRoomEncrypted = useIsEncrypted(cli, room); const roomContext = useContext(RoomContext); const e2eStatus = roomContext.e2eStatus; @@ -316,6 +325,8 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { const pinningEnabled = useFeatureEnabled("feature_pinning"); const pinCount = usePinnedEvents(pinningEnabled && room)?.length; + const isPollHistoryEnabled = useFeatureEnabled("feature_poll_history"); + return ( @@ -328,6 +339,11 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { {_t("Files")} )} + {!isVideoRoom && isPollHistoryEnabled && ( + + )} {pinningEnabled && !isVideoRoom && ( )} -
    -
    - { description } +
    + {description}
    @@ -211,8 +200,8 @@ export default class LinkPreviewWidget extends React.Component { {" - " + p["og:site_name"]} )} -
    - {description} +
    + {description}
    diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index d0a7adba124..0dc9ca2ad9a 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -512,6 +512,7 @@ export class MessageComposer extends React.Component { e2eStatus={this.props.e2eStatus} menuPosition={menuPosition} placeholder={this.renderPlaceholderText()} + eventRelation={this.props.relation} /> ); } else { diff --git a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx index 33612ad7316..f09c1516909 100644 --- a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx @@ -21,7 +21,7 @@ import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications import { StatelessNotificationBadge } from "./StatelessNotificationBadge"; interface Props { - room: Room; + room?: Room; threadId?: string; } diff --git a/src/components/views/rooms/PinnedEventTile.tsx b/src/components/views/rooms/PinnedEventTile.tsx index d36a0673d88..c08d4cb211f 100644 --- a/src/components/views/rooms/PinnedEventTile.tsx +++ b/src/components/views/rooms/PinnedEventTile.tsx @@ -19,8 +19,6 @@ import React from "react"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from "matrix-js-sdk/src/models/relations"; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; -import { logger } from "matrix-js-sdk/src/logger"; -import { M_POLL_START, M_POLL_RESPONSE, M_POLL_END } from "matrix-js-sdk/src/@types/polls"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; @@ -71,47 +69,6 @@ export default class PinnedEventTile extends React.Component { } }; - public async componentDidMount(): Promise { - // Fetch poll responses - if (M_POLL_START.matches(this.props.event.getType())) { - const eventId = this.props.event.getId(); - const roomId = this.props.event.getRoomId(); - const room = this.context.getRoom(roomId); - - try { - await Promise.all( - [M_POLL_RESPONSE.name, M_POLL_RESPONSE.altName, M_POLL_END.name, M_POLL_END.altName].map( - async (eventType): Promise => { - const relations = new Relations(RelationType.Reference, eventType, room); - relations.setTargetEvent(this.props.event); - - if (!this.relations.has(RelationType.Reference)) { - this.relations.set(RelationType.Reference, new Map()); - } - this.relations.get(RelationType.Reference).set(eventType, relations); - - let nextBatch: string | undefined; - do { - const page = await this.context.relations( - roomId, - eventId, - RelationType.Reference, - eventType, - { from: nextBatch }, - ); - nextBatch = page.nextBatch; - page.events.forEach((event) => relations.addEvent(event)); - } while (nextBatch); - }, - ), - ); - } catch (err) { - logger.error(`Error fetching responses to pinned poll ${eventId} in room ${roomId}`); - logger.error(err); - } - } - } - public render(): JSX.Element { const sender = this.props.event.getSender(); diff --git a/src/components/views/rooms/RoomInfoLine.tsx b/src/components/views/rooms/RoomInfoLine.tsx index 18b5b1c766a..114a613dcad 100644 --- a/src/components/views/rooms/RoomInfoLine.tsx +++ b/src/components/views/rooms/RoomInfoLine.tsx @@ -37,7 +37,7 @@ const RoomInfoLine: FC = ({ room }) => { const summary = useAsyncMemo(async (): Promise> | null> => { if (room.getMyMembership() !== "invite") return null; try { - return room.client.getRoomSummary(room.roomId); + return await room.client.getRoomSummary(room.roomId); } catch (e) { return null; } diff --git a/src/components/views/rooms/RoomPreviewCard.tsx b/src/components/views/rooms/RoomPreviewCard.tsx index 4c0a016f85d..2714bec93ab 100644 --- a/src/components/views/rooms/RoomPreviewCard.tsx +++ b/src/components/views/rooms/RoomPreviewCard.tsx @@ -116,7 +116,7 @@ const RoomPreviewCard: FC = ({ room, onJoinButtonClicked, onRejectButton joinButtons = ( <> { setBusy(true); onRejectButtonClicked(); diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index 2ac3fd1afa3..b46b028bb6c 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -88,7 +88,7 @@ export default class SearchResultTile extends React.Component { // is this a continuation of the previous message? const continuation = prevEv && - !wantsDateSeparator(prevEv.getDate(), mxEv.getDate()) && + !wantsDateSeparator(prevEv.getDate() || undefined, mxEv.getDate() || undefined) && shouldFormContinuation( prevEv, mxEv, @@ -100,7 +100,10 @@ export default class SearchResultTile extends React.Component { let lastInSection = true; const nextEv = timeline[j + 1]; if (nextEv) { - const willWantDateSeparator = wantsDateSeparator(mxEv.getDate(), nextEv.getDate()); + const willWantDateSeparator = wantsDateSeparator( + mxEv.getDate() || undefined, + nextEv.getDate() || undefined, + ); lastInSection = willWantDateSeparator || mxEv.getSender() !== nextEv.getSender() || diff --git a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts index c8a369ead29..19daf8fde8d 100644 --- a/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts +++ b/src/components/views/rooms/wysiwyg_composer/ComposerContext.ts @@ -15,17 +15,22 @@ limitations under the License. */ import { createContext, useContext } from "react"; +import { IEventRelation } from "matrix-js-sdk/src/matrix"; import { SubSelection } from "./types"; +import EditorStateTransfer from "../../../../utils/EditorStateTransfer"; -export function getDefaultContextValue(): { selection: SubSelection } { +export function getDefaultContextValue(defaultValue?: Partial): { selection: SubSelection } { return { - selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0 }, + selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, isForward: true }, + ...defaultValue, }; } export interface ComposerContextState { selection: SubSelection; + editorStateTransfer?: EditorStateTransfer; + eventRelation?: IEventRelation; } export const ComposerContext = createContext(getDefaultContextValue()); diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 502afc96226..c0915469e2b 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -52,7 +52,7 @@ export default function EditWysiwygComposer({ className, ...props }: EditWysiwygComposerProps): JSX.Element { - const defaultContextValue = useRef(getDefaultContextValue()); + const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer })); const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || initialContent !== undefined; diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 2691df80dd1..d12432481db 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { ForwardedRef, forwardRef, MutableRefObject, useRef } from "react"; +import { IEventRelation } from "matrix-js-sdk/src/models/event"; import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler"; import { WysiwygComposer } from "./components/WysiwygComposer"; @@ -48,6 +49,7 @@ interface SendWysiwygComposerProps { onChange: (content: string) => void; onSend: () => void; menuPosition: MenuProps; + eventRelation?: IEventRelation; } // Default needed for React.lazy @@ -55,10 +57,11 @@ export default function SendWysiwygComposer({ isRichTextEnabled, e2eStatus, menuPosition, + eventRelation, ...props }: SendWysiwygComposerProps): JSX.Element { const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer; - const defaultContextValue = useRef(getDefaultContextValue()); + const defaultContextValue = useRef(getDefaultContextValue({ eventRelation })); return ( diff --git a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index 80f2563d1d8..7bc4b33d412 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -28,6 +28,8 @@ import { Icon as LinkIcon } from "../../../../../../res/img/element-icons/room/c import { Icon as BulletedListIcon } from "../../../../../../res/img/element-icons/room/composer/bulleted_list.svg"; import { Icon as NumberedListIcon } from "../../../../../../res/img/element-icons/room/composer/numbered_list.svg"; import { Icon as CodeBlockIcon } from "../../../../../../res/img/element-icons/room/composer/code_block.svg"; +import { Icon as IndentIcon } from "../../../../../../res/img/element-icons/room/composer/indent_increase.svg"; +import { Icon as UnIndentIcon } from "../../../../../../res/img/element-icons/room/composer/indent_decrease.svg"; import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton"; import { Alignment } from "../../../elements/Tooltip"; import { KeyboardShortcut } from "../../../settings/KeyboardShortcut"; @@ -127,6 +129,18 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP onClick={() => composer.orderedList()} icon={} /> + + + + + + + + +
    +

    + Other +

    + + + +
    +
    +

    + Options +

    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + +`; diff --git a/test/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap new file mode 100644 index 00000000000..0eb2683003d --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/MessageEditHistoryDialog-test.tsx.snap @@ -0,0 +1,322 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should match the snapshot 1`] = ` +
    +
    +