diff --git a/.github/workflows/cypress.yaml b/.github/workflows/cypress.yaml
index 57e6a7837e..cbb5347173 100644
--- a/.github/workflows/cypress.yaml
+++ b/.github/workflows/cypress.yaml
@@ -79,8 +79,8 @@ jobs:
strategy:
fail-fast: false
matrix:
- # Run 3 instances in Parallel
- runner: [1, 2, 3]
+ # Run 4 instances in Parallel
+ runner: [1, 2, 3, 4]
steps:
- uses: actions/checkout@v2
with:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index edb088cd64..2765dbe450 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,53 @@
+Changes in [3.59.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.59.0) (2022-10-25)
+=====================================================================================================
+
+## ✨ Features
+ * Include a file-safe room name and ISO date in chat exports ([\#9440](https://github.com/matrix-org/matrix-react-sdk/pull/9440)). Fixes vector-im/element-web#21812 and vector-im/element-web#19724.
+ * Room call banner ([\#9378](https://github.com/matrix-org/matrix-react-sdk/pull/9378)). Fixes vector-im/element-web#23453. Contributed by @toger5.
+ * Device manager - spinners while devices are signing out ([\#9433](https://github.com/matrix-org/matrix-react-sdk/pull/9433)). Fixes vector-im/element-web#15865.
+ * Device manager - silence call ringers when local notifications are silenced ([\#9420](https://github.com/matrix-org/matrix-react-sdk/pull/9420)).
+ * Pass the current language to Element Call ([\#9427](https://github.com/matrix-org/matrix-react-sdk/pull/9427)).
+ * Hide screen-sharing button in Element Call on desktop ([\#9423](https://github.com/matrix-org/matrix-react-sdk/pull/9423)).
+ * Add reply support to WysiwygComposer ([\#9422](https://github.com/matrix-org/matrix-react-sdk/pull/9422)). Contributed by @florianduros.
+ * Disconnect other connected devices (of the same user) when joining an Element call ([\#9379](https://github.com/matrix-org/matrix-react-sdk/pull/9379)).
+ * Device manager - device tile main click target ([\#9409](https://github.com/matrix-org/matrix-react-sdk/pull/9409)).
+ * Add formatting buttons to the rich text editor ([\#9410](https://github.com/matrix-org/matrix-react-sdk/pull/9410)). Contributed by @florianduros.
+ * Device manager - current session context menu ([\#9386](https://github.com/matrix-org/matrix-react-sdk/pull/9386)).
+ * Remove piwik config fallback for privacy policy URL ([\#9390](https://github.com/matrix-org/matrix-react-sdk/pull/9390)).
+ * Add the first step to integrate the matrix wysiwyg composer ([\#9374](https://github.com/matrix-org/matrix-react-sdk/pull/9374)). Contributed by @florianduros.
+ * Device manager - UA parsing tweaks ([\#9382](https://github.com/matrix-org/matrix-react-sdk/pull/9382)).
+ * Device manager - remove client information events when disabling setting ([\#9384](https://github.com/matrix-org/matrix-react-sdk/pull/9384)).
+ * Add Element Call participant limit ([\#9358](https://github.com/matrix-org/matrix-react-sdk/pull/9358)).
+ * Add Element Call room settings ([\#9347](https://github.com/matrix-org/matrix-react-sdk/pull/9347)).
+ * Device manager - render extended device information ([\#9360](https://github.com/matrix-org/matrix-react-sdk/pull/9360)).
+ * New group call experience: Room header and PiP designs ([\#9351](https://github.com/matrix-org/matrix-react-sdk/pull/9351)).
+ * Pass language to Jitsi Widget ([\#9346](https://github.com/matrix-org/matrix-react-sdk/pull/9346)). Contributed by @Fox32.
+ * Add notifications and toasts for Element Call calls ([\#9337](https://github.com/matrix-org/matrix-react-sdk/pull/9337)).
+ * Device manager - device type icon ([\#9355](https://github.com/matrix-org/matrix-react-sdk/pull/9355)).
+ * Delete the remainder of groups ([\#9357](https://github.com/matrix-org/matrix-react-sdk/pull/9357)). Fixes vector-im/element-web#22770.
+ * Device manager - display client information in device details ([\#9315](https://github.com/matrix-org/matrix-react-sdk/pull/9315)).
+
+## 🐛 Bug Fixes
+ * Send Content-Type: application/json header for integration manager /register API ([\#9490](https://github.com/matrix-org/matrix-react-sdk/pull/9490)). Fixes vector-im/element-web#23580.
+ * Device manager - put client/browser device metadata in correct section ([\#9447](https://github.com/matrix-org/matrix-react-sdk/pull/9447)).
+ * update the room unread notification counter when the server changes the value without any related read receipt ([\#9438](https://github.com/matrix-org/matrix-react-sdk/pull/9438)).
+ * Don't show call banners in video rooms ([\#9441](https://github.com/matrix-org/matrix-react-sdk/pull/9441)).
+ * Prevent useContextMenu isOpen from being true if the button ref goes away ([\#9418](https://github.com/matrix-org/matrix-react-sdk/pull/9418)). Fixes matrix-org/element-web-rageshakes#15637.
+ * Automatically focus the WYSIWYG composer when you enter a room ([\#9412](https://github.com/matrix-org/matrix-react-sdk/pull/9412)).
+ * Improve the tooltips on the call lobby join button ([\#9428](https://github.com/matrix-org/matrix-react-sdk/pull/9428)).
+ * Pass the homeserver's base URL to Element Call ([\#9429](https://github.com/matrix-org/matrix-react-sdk/pull/9429)). Fixes vector-im/element-web#23301.
+ * Better accommodate long room names in call toasts ([\#9426](https://github.com/matrix-org/matrix-react-sdk/pull/9426)).
+ * Hide virtual widgets from the room info panel ([\#9424](https://github.com/matrix-org/matrix-react-sdk/pull/9424)). Fixes vector-im/element-web#23494.
+ * Inhibit clicking on sender avatar in threads list ([\#9417](https://github.com/matrix-org/matrix-react-sdk/pull/9417)). Fixes vector-im/element-web#23482.
+ * Correct the dir parameter of MSC3715 ([\#9391](https://github.com/matrix-org/matrix-react-sdk/pull/9391)). Contributed by @dhenneke.
+ * Use a more correct subset of users in `/remakeolm` developer command ([\#9402](https://github.com/matrix-org/matrix-react-sdk/pull/9402)).
+ * use correct default for notification silencing ([\#9388](https://github.com/matrix-org/matrix-react-sdk/pull/9388)). Fixes vector-im/element-web#23456.
+ * Device manager - eagerly create `m.local_notification_settings` events ([\#9353](https://github.com/matrix-org/matrix-react-sdk/pull/9353)).
+ * Close incoming Element call toast when viewing the call lobby ([\#9375](https://github.com/matrix-org/matrix-react-sdk/pull/9375)).
+ * Always allow enabling sending read receipts ([\#9367](https://github.com/matrix-org/matrix-react-sdk/pull/9367)). Fixes vector-im/element-web#23433.
+ * Fixes (vector-im/element-web/issues/22609) where the white theme is not applied when `white -> dark -> white` sequence is done. ([\#9320](https://github.com/matrix-org/matrix-react-sdk/pull/9320)). Contributed by @florianduros.
+ * Fix applying programmatically set height for "top" room layout ([\#9339](https://github.com/matrix-org/matrix-react-sdk/pull/9339)). Contributed by @Fox32.
+
Changes in [3.58.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.58.1) (2022-10-11)
=====================================================================================================
diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts
index f3fc374cf0..6fe562e12a 100644
--- a/cypress/e2e/composer/composer.spec.ts
+++ b/cypress/e2e/composer/composer.spec.ts
@@ -64,6 +64,21 @@ describe("Composer", () => {
cy.contains('.mx_EventTile_body strong', 'bold message');
});
+ it("should allow user to input emoji via graphical picker", () => {
+ cy.getComposer(false).within(() => {
+ cy.get('[aria-label="Emoji"]').click();
+ });
+
+ cy.get('[data-testid="mx_EmojiPicker"]').within(() => {
+ cy.contains(".mx_EmojiPicker_item", "😇").click();
+ });
+
+ cy.get(".mx_ContextualMenu_background").click(); // Close emoji picker
+ cy.get('div[contenteditable=true]').type("{enter}"); // Send message
+
+ cy.contains(".mx_EventTile_body", "😇");
+ });
+
describe("when Ctrl+Enter is required to send", () => {
beforeEach(() => {
cy.setSettingValue("MessageComposerInput.ctrlEnterToSend", null, SettingLevel.ACCOUNT, true);
diff --git a/cypress/e2e/settings/device-management.spec.ts b/cypress/e2e/settings/device-management.spec.ts
index 1709475e17..da4d12e35d 100644
--- a/cypress/e2e/settings/device-management.spec.ts
+++ b/cypress/e2e/settings/device-management.spec.ts
@@ -78,6 +78,7 @@ describe("Device manager", () => {
cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem .mx_Checkbox').last().click();
// sign out from list selection action buttons
cy.get('[data-testid="sign-out-selection-cta"]').click();
+ cy.get('[data-testid="dialog-primary-button"]').click();
// list updated after sign out
cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 1);
// security recommendation count updated
@@ -106,6 +107,8 @@ describe("Device manager", () => {
// sign out using the device details sign out
cy.get('[data-testid="device-detail-sign-out-cta"]').click();
});
+ // confirm the signout
+ cy.get('[data-testid="dialog-primary-button"]').click();
// no other sessions or security recommendations sections when only one session
cy.contains('Other sessions').should('not.exist');
diff --git a/cypress/plugins/sliding-sync/index.ts b/cypress/plugins/sliding-sync/index.ts
index 61a62aad13..608ada8dbf 100644
--- a/cypress/plugins/sliding-sync/index.ts
+++ b/cypress/plugins/sliding-sync/index.ts
@@ -77,7 +77,7 @@ async function proxyStart(synapse: SynapseInstance): Promise {
const port = await getFreePort();
console.log(new Date(), "starting proxy container...");
const containerId = await dockerRun({
- image: "ghcr.io/matrix-org/sliding-sync-proxy:v0.4.0",
+ image: "ghcr.io/matrix-org/sliding-sync-proxy:v0.6.0",
containerName: "react-sdk-cypress-sliding-sync-proxy",
params: [
"--rm",
diff --git a/package.json b/package.json
index f0ab2c266b..9d7d7baa65 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
- "version": "3.58.1",
+ "version": "3.59.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.2.0",
- "@matrix-org/matrix-wysiwyg": "^0.3.0",
+ "@matrix-org/matrix-wysiwyg": "^0.3.2",
"@matrix-org/react-sdk-module-api": "^0.0.3",
"@sentry/browser": "^6.11.0",
"@sentry/tracing": "^6.11.0",
diff --git a/res/css/_components.pcss b/res/css/_components.pcss
index 819afe64a4..cc7c6a2e2a 100644
--- a/res/css/_components.pcss
+++ b/res/css/_components.pcss
@@ -4,7 +4,6 @@
@import "./_font-sizes.pcss";
@import "./_font-weights.pcss";
@import "./_spacing.pcss";
-@import "./compound/_Icon.pcss";
@import "./components/views/beacon/_BeaconListItem.pcss";
@import "./components/views/beacon/_BeaconStatus.pcss";
@import "./components/views/beacon/_BeaconStatusTooltip.pcss";
@@ -19,6 +18,7 @@
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
@import "./components/views/context_menus/_KebabContextMenu.pcss";
@import "./components/views/elements/_FilterDropdown.pcss";
+@import "./components/views/elements/_LearnMore.pcss";
@import "./components/views/location/_EnableLiveShare.pcss";
@import "./components/views/location/_LiveDurationDropdown.pcss";
@import "./components/views/location/_LocationShareMenu.pcss";
@@ -44,6 +44,7 @@
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
@import "./components/views/spaces/_QuickThemeSwitcher.pcss";
@import "./components/views/typography/_Caption.pcss";
+@import "./compound/_Icon.pcss";
@import "./structures/_AutoHideScrollbar.pcss";
@import "./structures/_BackdropPanel.pcss";
@import "./structures/_CompatibilityPage.pcss";
@@ -299,8 +300,10 @@
@import "./views/rooms/_TopUnreadMessagesBar.pcss";
@import "./views/rooms/_VoiceRecordComposerTile.pcss";
@import "./views/rooms/_WhoIsTypingTile.pcss";
-@import "./views/rooms/wysiwyg_composer/_FormattingButtons.pcss";
-@import "./views/rooms/wysiwyg_composer/_WysiwygComposer.pcss";
+@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss";
+@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss";
+@import "./views/rooms/wysiwyg_composer/components/_Editor.pcss";
+@import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss";
@import "./views/settings/_AvatarSetting.pcss";
@import "./views/settings/_CrossSigningPanel.pcss";
@import "./views/settings/_CryptographyPanel.pcss";
@@ -371,6 +374,4 @@
@import "./voice-broadcast/atoms/_PlaybackControlButton.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastControl.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss";
-@import "./voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss";
-@import "./voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss";
-@import "./voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss";
+@import "./voice-broadcast/molecules/_VoiceBroadcastBody.pcss";
diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss b/res/css/components/views/elements/_LearnMore.pcss
similarity index 71%
rename from res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss
rename to res/css/components/views/elements/_LearnMore.pcss
index 11921e1f95..97f3b4c527 100644
--- a/res/css/voice-broadcast/molecules/_VoiceBroadcastPlaybackBody.pcss
+++ b/res/css/components/views/elements/_LearnMore.pcss
@@ -14,14 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_VoiceBroadcastPlaybackBody {
- background-color: $quinary-content;
- border-radius: 8px;
- display: inline-block;
- padding: 12px;
-}
-
-.mx_VoiceBroadcastPlaybackBody_controls {
- display: flex;
- justify-content: center;
+.mx_LearnMore_button {
+ margin-left: $spacing-4;
}
diff --git a/res/css/views/dialogs/_SpotlightDialog.pcss b/res/css/views/dialogs/_SpotlightDialog.pcss
index cf6917a475..8602ca9a43 100644
--- a/res/css/views/dialogs/_SpotlightDialog.pcss
+++ b/res/css/views/dialogs/_SpotlightDialog.pcss
@@ -247,7 +247,7 @@ limitations under the License.
}
&.mx_SpotlightDialog_result_multiline {
- align-items: start;
+ align-items: flex-start;
.mx_AccessibleButton {
padding: $spacing-4 $spacing-20;
diff --git a/res/css/views/elements/_UseCaseSelection.pcss b/res/css/views/elements/_UseCaseSelection.pcss
index 3daf15772f..2b907e7b67 100644
--- a/res/css/views/elements/_UseCaseSelection.pcss
+++ b/res/css/views/elements/_UseCaseSelection.pcss
@@ -65,7 +65,7 @@ limitations under the License.
.mx_UseCaseSelection_skip {
display: flex;
flex-direction: column;
- align-self: start;
+ align-self: flex-start;
}
}
diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss
index 35cd87b136..55702c787b 100644
--- a/res/css/views/rooms/_EventTile.pcss
+++ b/res/css/views/rooms/_EventTile.pcss
@@ -426,7 +426,7 @@ $left-gutter: 64px;
}
&.mx_EventTile_selected .mx_EventTile_line {
- // TODO: check if this would be necessary
+ /* TODO: check if this would be necessary; */
padding-inline-start: calc(var(--EventTile_group_line-spacing-inline-start) + 20px);
}
}
@@ -894,15 +894,22 @@ $left-gutter: 64px;
}
/* Display notification dot */
- &[data-notification]::before {
+ &[data-notification]::before,
+ .mx_NotificationBadge {
+ position: absolute;
$notification-inset-block-start: 14px; /* 14px: align the dot with the timestamp row */
- width: $notification-dot-size;
- height: $notification-dot-size;
+ /* !important to fix overly specific CSS selector applied on mx_NotificationBadge */
+ width: $notification-dot-size !important;
+ height: $notification-dot-size !important;
border-radius: 50%;
inset: $notification-inset-block-start $spacing-8 auto auto;
}
+ .mx_NotificationBadge_count {
+ display: none;
+ }
+
&[data-notification="total"]::before {
background-color: $room-icon-unread-color;
}
@@ -1301,7 +1308,8 @@ $left-gutter: 64px;
}
}
- &[data-shape="ThreadsList"][data-notification]::before {
+ &[data-shape="ThreadsList"][data-notification]::before,
+ .mx_NotificationBadge {
/* stylelint-disable-next-line declaration-colon-space-after */
inset-block-start:
calc($notification-inset-block-start - var(--MatrixChat_useCompactLayout_group-padding-top));
diff --git a/res/css/views/rooms/_MessageComposer.pcss b/res/css/views/rooms/_MessageComposer.pcss
index 4cddf31084..4d22f60a12 100644
--- a/res/css/views/rooms/_MessageComposer.pcss
+++ b/res/css/views/rooms/_MessageComposer.pcss
@@ -240,7 +240,7 @@ limitations under the License.
*/
.mx_MessageComposer_wysiwyg {
.mx_MessageComposer_e2eIcon.mx_E2EIcon,.mx_MessageComposer_button, .mx_MessageComposer_sendMessage {
- margin-top: 22px;
+ margin-top: 28px;
}
}
@@ -264,6 +264,14 @@ limitations under the License.
mask-image: url('$(res)/img/element-icons/room/composer/emoji.svg');
}
+.mx_MessageComposer_plain_text::before {
+ mask-image: url('$(res)/img/element-icons/room/composer/plain_text.svg');
+}
+
+.mx_MessageComposer_rich_text::before {
+ mask-image: url('$(res)/img/element-icons/room/composer/rich_text.svg');
+}
+
.mx_MessageComposer_location::before {
mask-image: url('$(res)/img/element-icons/room/composer/location.svg');
}
diff --git a/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss
new file mode 100644
index 0000000000..73e5fef6e9
--- /dev/null
+++ b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss
@@ -0,0 +1,58 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_EditWysiwygComposer {
+ --EditWysiwygComposer-padding-inline: 3px;
+
+ display: flex;
+ flex-direction: column;
+ max-width: 100%; /* disable overflow */
+ width: auto;
+ gap: 8px;
+ padding: 8px var(--EditWysiwygComposer-padding-inline);
+
+ .mx_WysiwygComposer_content {
+ border-radius: 4px;
+ border: solid 1px $primary-hairline-color;
+ background-color: $background;
+ max-height: 200px;
+ padding: 3px 6px;
+
+ &:focus {
+ border-color: rgba($accent, 0.5); /* Only ever used here */
+ }
+ }
+
+ .mx_EditWysiwygComposer_buttons {
+ display: flex;
+ flex-flow: row wrap-reverse; /* display "Save" over "Cancel" */
+ justify-content: flex-end;
+ gap: 5px;
+ margin-inline-start: auto;
+
+ .mx_AccessibleButton {
+ flex: 1;
+ box-sizing: border-box;
+ min-width: 100px; /* magic number to align the edge of the button with the input area */
+ }
+ }
+
+ .mx_FormattingButtons_Button {
+ &:first-child {
+ margin-left: 0px;
+ }
+ }
+}
diff --git a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss
similarity index 98%
rename from res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss
rename to res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss
index 133b66388e..a00f8c7e11 100644
--- a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss
+++ b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_WysiwygComposer {
+.mx_SendWysiwygComposer {
flex: 1;
display: flex;
flex-direction: column;
diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss
new file mode 100644
index 0000000000..6a6b68af7c
--- /dev/null
+++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss
@@ -0,0 +1,37 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+.mx_WysiwygComposer_container {
+ position: relative;
+
+ @keyframes visualbell {
+ from { background-color: $visual-bell-bg-color; }
+ to { background-color: $background; }
+ }
+
+ .mx_WysiwygComposer_content {
+ white-space: pre-wrap;
+ word-wrap: break-word;
+ outline: none;
+ overflow-x: hidden;
+
+ /* Force caret nodes to be selected in full so that they can be */
+ /* navigated through in a single keypress */
+ .caretNode {
+ user-select: all;
+ }
+ }
+}
diff --git a/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss
similarity index 97%
rename from res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss
rename to res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss
index 36f84ae5f1..cd0ac38e0e 100644
--- a/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss
+++ b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss
@@ -16,7 +16,7 @@ limitations under the License.
.mx_FormattingButtons {
display: flex;
- justify-content: start;
+ justify-content: flex-start;
.mx_FormattingButtons_Button {
--size: 28px;
@@ -45,7 +45,7 @@ limitations under the License.
left: 6px;
height: 16px;
width: 16px;
- background-color: $icon-button-color;
+ background-color: $tertiary-content;
mask-repeat: no-repeat;
mask-size: contain;
mask-position: center;
diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss
similarity index 84%
rename from res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss
rename to res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss
index 11534a4797..37606f993c 100644
--- a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingPip.pcss
+++ b/res/css/voice-broadcast/molecules/_VoiceBroadcastBody.pcss
@@ -14,22 +14,26 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_VoiceBroadcastRecordingPip {
- background-color: $system;
+.mx_VoiceBroadcastBody {
+ background-color: $quinary-content;
border-radius: 8px;
- box-shadow: 0 2px 8px 0 #0000004a;
display: inline-block;
padding: $spacing-12;
}
-.mx_VoiceBroadcastRecordingPip_divider {
+.mx_VoiceBroadcastBody--pip {
+ background-color: $system;
+ box-shadow: 0 2px 8px 0 #0000004a;
+}
+
+.mx_VoiceBroadcastBody_divider {
background-color: $quinary-content;
border: 0;
height: 1px;
margin: $spacing-12 0;
}
-.mx_VoiceBroadcastRecordingPip_controls {
+.mx_VoiceBroadcastBody_controls {
display: flex;
justify-content: space-around;
}
diff --git a/res/img/element-icons/room/composer/plain_text.svg b/res/img/element-icons/room/composer/plain_text.svg
new file mode 100644
index 0000000000..d2da9d2551
--- /dev/null
+++ b/res/img/element-icons/room/composer/plain_text.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/res/img/element-icons/room/composer/rich_text.svg b/res/img/element-icons/room/composer/rich_text.svg
new file mode 100644
index 0000000000..7ff47fe085
--- /dev/null
+++ b/res/img/element-icons/room/composer/rich_text.svg
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Login.ts b/src/Login.ts
index c36f5770b9..4dc96bc17d 100644
--- a/src/Login.ts
+++ b/src/Login.ts
@@ -24,13 +24,6 @@ import { ILoginParams, LoginFlow } from "matrix-js-sdk/src/@types/auth";
import { IMatrixClientCreds } from "./MatrixClientPeg";
import SecurityCustomisations from "./customisations/Security";
-export {
- IdentityProviderBrand,
- IIdentityProvider,
- ISSOFlow,
- LoginFlow,
-} from "matrix-js-sdk/src/@types/auth";
-
interface ILoginOptions {
defaultDeviceDisplayName?: string;
}
diff --git a/src/Notifier.ts b/src/Notifier.ts
index cc84acb2fa..b75b821ae8 100644
--- a/src/Notifier.ts
+++ b/src/Notifier.ts
@@ -435,7 +435,16 @@ export const Notifier = {
if (actions?.notify) {
this._performCustomEventHandling(ev);
- if (SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId &&
+ const store = SdkContextClass.instance.roomViewStore;
+ const isViewingRoom = store.getRoomId() === room.roomId;
+ const threadId: string | undefined = ev.getId() !== ev.threadRootId
+ ? ev.threadRootId
+ : undefined;
+ const isViewingThread = store.getThreadId() === threadId;
+
+ const isViewingEventTimeline = isViewingRoom && (!threadId || isViewingThread);
+
+ if (isViewingEventTimeline &&
UserActivity.sharedInstance().userActiveRecently() &&
!Modal.hasDialogs()
) {
diff --git a/src/Roles.ts b/src/Roles.ts
index ae0d316d30..77c50fe64c 100644
--- a/src/Roles.ts
+++ b/src/Roles.ts
@@ -16,7 +16,7 @@ limitations under the License.
import { _t } from './languageHandler';
-export function levelRoleMap(usersDefault: number) {
+export function levelRoleMap(usersDefault: number): Record {
return {
undefined: _t('Default'),
0: _t('Restricted'),
diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts
index 08c15970c5..6c1e07e66b 100644
--- a/src/RoomNotifs.ts
+++ b/src/RoomNotifs.ts
@@ -78,15 +78,23 @@ export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Pr
}
}
-export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number {
- let notificationCount = room.getUnreadNotificationCount(type);
+export function getUnreadNotificationCount(
+ room: Room,
+ type: NotificationCountType,
+ threadId?: string,
+): number {
+ let notificationCount = (!!threadId
+ ? room.getThreadUnreadNotificationCount(threadId, type)
+ : room.getUnreadNotificationCount(type));
// 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, "");
- if (createEvent && createEvent.getContent()['predecessor']) {
- const oldRoomId = createEvent.getContent()['predecessor']['room_id'];
+ const predecessor = createEvent?.getContent().predecessor;
+ // Exclude threadId, as the same thread can't continue over a room upgrade
+ if (!threadId && predecessor) {
+ const oldRoomId = predecessor.room_id;
const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId);
if (oldRoom) {
// We only ever care if there's highlights in the old room. No point in
diff --git a/src/ScalarAuthClient.ts b/src/ScalarAuthClient.ts
index 5dacd07973..3ee1e7c15d 100644
--- a/src/ScalarAuthClient.ts
+++ b/src/ScalarAuthClient.ts
@@ -190,6 +190,9 @@ export default class ScalarAuthClient {
const res = await fetch(scalarRestUrl, {
method: "POST",
body: JSON.stringify(openidTokenObject),
+ headers: {
+ "Content-Type": "application/json",
+ },
});
if (!res.ok) {
diff --git a/src/SlidingSyncManager.ts b/src/SlidingSyncManager.ts
index 0e5736465e..c41e6a78e3 100644
--- a/src/SlidingSyncManager.ts
+++ b/src/SlidingSyncManager.ts
@@ -63,6 +63,15 @@ const DEFAULT_ROOM_SUBSCRIPTION_INFO = {
required_state: [
["*", "*"], // all events
],
+ include_old_rooms: {
+ timeline_limit: 0,
+ required_state: [ // state needed to handle space navigation and tombstone chains
+ [EventType.RoomCreate, ""],
+ [EventType.RoomTombstone, ""],
+ [EventType.SpaceChild, "*"],
+ [EventType.SpaceParent, "*"],
+ ],
+ },
};
export type PartialSlidingSyncRequest = {
@@ -121,6 +130,16 @@ export class SlidingSyncManager {
[EventType.SpaceParent, "*"], // all space parents
[EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
],
+ include_old_rooms: {
+ timeline_limit: 0,
+ required_state: [
+ [EventType.RoomCreate, ""],
+ [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
+ [EventType.SpaceChild, "*"], // all space children
+ [EventType.SpaceParent, "*"], // all space parents
+ [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
+ ],
+ },
filters: {
room_types: ["m.space"],
},
@@ -176,7 +195,7 @@ export class SlidingSyncManager {
list = {
ranges: [[0, 20]],
sort: [
- "by_highlight_count", "by_notification_count", "by_recency",
+ "by_notification_level", "by_recency",
],
timeline_limit: 1, // most recent message display: though this seems to only be needed for favourites?
required_state: [
@@ -187,6 +206,16 @@ export class SlidingSyncManager {
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.RoomMember, this.client.getUserId()], // lets the client calculate that we are in fact in the room
],
+ include_old_rooms: {
+ timeline_limit: 0,
+ required_state: [
+ [EventType.RoomCreate, ""],
+ [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
+ [EventType.SpaceChild, "*"], // all space children
+ [EventType.SpaceParent, "*"], // all space parents
+ [EventType.RoomMember, this.client.getUserId()!], // lets the client calculate that we are in fact in the room
+ ],
+ },
};
list = Object.assign(list, updateArgs);
} else {
diff --git a/src/Unread.ts b/src/Unread.ts
index 1804ddefb7..60ef9ca19e 100644
--- a/src/Unread.ts
+++ b/src/Unread.ts
@@ -23,7 +23,6 @@ import { MatrixClientPeg } from "./MatrixClientPeg";
import shouldHideEvent from './shouldHideEvent';
import { haveRendererForEvent } from "./events/EventTileFactory";
import SettingsStore from "./settings/SettingsStore";
-import { RoomNotificationStateStore } from "./stores/notifications/RoomNotificationStateStore";
/**
* Returns true if this event arriving in a room should affect the room's
@@ -77,11 +76,6 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean {
if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) {
return false;
}
- } else {
- const threadState = RoomNotificationStateStore.instance.getThreadsRoomState(room);
- if (threadState.color > 0) {
- return true;
- }
}
// if the read receipt relates to an event is that part of a thread
diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts
index 0e18756fe5..99f878868d 100644
--- a/src/audio/VoiceRecording.ts
+++ b/src/audio/VoiceRecording.ts
@@ -60,6 +60,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
private recorderProcessor: ScriptProcessorNode;
private recording = false;
private observable: SimpleObservable;
+ private targetMaxLength: number | null = TARGET_MAX_LENGTH;
public amplitudes: number[] = []; // at each second mark, generated
private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0);
public onDataAvailable: (data: ArrayBuffer) => void;
@@ -83,6 +84,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
return true; // we don't ever care if the event had listeners, so just return "yes"
}
+ public disableMaxLength(): void {
+ this.targetMaxLength = null;
+ }
+
private async makeRecorder() {
try {
this.recorderStream = await navigator.mediaDevices.getUserMedia({
@@ -203,6 +208,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
// In testing, recorder time and worker time lag by about 400ms, which is roughly the
// time needed to encode a sample/frame.
//
+
+ if (!this.targetMaxLength) {
+ // skip time checks if max length has been disabled
+ return;
+ }
+
const secondsLeft = TARGET_MAX_LENGTH - this.recorderSeconds;
if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame
// noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx
index 223eb0a6db..56c396750f 100644
--- a/src/components/structures/MessagePanel.tsx
+++ b/src/components/structures/MessagePanel.tsx
@@ -835,6 +835,13 @@ export default class MessagePanel extends React.Component {
: room;
const receipts: IReadReceiptProps[] = [];
+
+ if (!receiptDestination) {
+ logger.debug("Discarding request, could not find the receiptDestination for event: "
+ + this.context.threadId);
+ return receipts;
+ }
+
receiptDestination.getReceiptsForEvent(event).forEach((r) => {
if (
!r.userId ||
diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx
index d46ad12b50..e703252546 100644
--- a/src/components/structures/RoomStatusBar.tsx
+++ b/src/components/structures/RoomStatusBar.tsx
@@ -34,10 +34,12 @@ const STATUS_BAR_HIDDEN = 0;
const STATUS_BAR_EXPANDED = 1;
const STATUS_BAR_EXPANDED_LARGE = 2;
-export function getUnsentMessages(room: Room): MatrixEvent[] {
+export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] {
if (!room) { return []; }
return room.getPendingEvents().filter(function(ev) {
- return ev.status === EventStatus.NOT_SENT;
+ const isNotSent = ev.status === EventStatus.NOT_SENT;
+ const belongsToTheThread = threadId === ev.threadRootId;
+ return isNotSent && (!threadId || belongsToTheThread);
});
}
diff --git a/src/components/structures/ThreadView.tsx b/src/components/structures/ThreadView.tsx
index 8350b5e734..3463bcd304 100644
--- a/src/components/structures/ThreadView.tsx
+++ b/src/components/structures/ThreadView.tsx
@@ -18,9 +18,6 @@ import React, { createRef, KeyboardEvent } from 'react';
import { Thread, THREAD_RELATION_TYPE, ThreadEvent } from 'matrix-js-sdk/src/models/thread';
import { Room, RoomEvent } from 'matrix-js-sdk/src/models/room';
import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
-import { TimelineWindow } from 'matrix-js-sdk/src/timeline-window';
-import { Direction } from 'matrix-js-sdk/src/models/event-timeline';
-import { IRelationsRequestOpts } from 'matrix-js-sdk/src/@types/requests';
import { logger } from 'matrix-js-sdk/src/logger';
import classNames from 'classnames';
@@ -55,6 +52,7 @@ import Spinner from "../views/elements/Spinner";
import { ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload";
import Heading from '../views/typography/Heading';
import { SdkContextClass } from '../../contexts/SDKContext';
+import { ThreadPayload } from '../../dispatcher/payloads/ThreadPayload';
interface IProps {
room: Room;
@@ -132,6 +130,11 @@ export default class ThreadView extends React.Component {
metricsTrigger: undefined, // room doesn't change
});
}
+
+ dis.dispatch({
+ action: Action.ViewThread,
+ thread_id: null,
+ });
}
public componentDidUpdate(prevProps) {
@@ -225,11 +228,13 @@ export default class ThreadView extends React.Component {
};
private async postThreadUpdate(thread: Thread): Promise {
+ dis.dispatch({
+ action: Action.ViewThread,
+ thread_id: thread.id,
+ });
thread.emit(ThreadEvent.ViewThread);
- await thread.fetchInitialEvents();
this.updateThreadRelation();
- this.nextBatch = thread.liveTimeline.getPaginationToken(Direction.Backward);
- this.timelinePanel.current?.refreshTimeline();
+ this.timelinePanel.current?.refreshTimeline(this.props.initialEvent?.getId());
}
private setupThreadListeners(thread?: Thread | undefined, oldThread?: Thread | undefined): void {
@@ -283,40 +288,6 @@ export default class ThreadView extends React.Component {
}
};
- private nextBatch: string | undefined | null = null;
-
- private onPaginationRequest = async (
- timelineWindow: TimelineWindow | null,
- direction = Direction.Backward,
- limit = 20,
- ): Promise => {
- if (!Thread.hasServerSideSupport && timelineWindow) {
- timelineWindow.extend(direction, limit);
- return true;
- }
-
- const opts: IRelationsRequestOpts = {
- limit,
- };
-
- if (this.nextBatch) {
- opts.from = this.nextBatch;
- }
-
- let nextBatch: string | null | undefined = null;
- if (this.state.thread) {
- const response = await this.state.thread.fetchEvents(opts);
- nextBatch = response.nextBatch;
- this.nextBatch = nextBatch;
- }
-
- // Advances the marker on the TimelineWindow to define the correct
- // window of events to display on screen
- timelineWindow?.extend(direction, limit);
-
- return !!nextBatch;
- };
-
private onFileDrop = (dataTransfer: DataTransfer) => {
const roomId = this.props.mxEvent.getRoomId();
if (roomId) {
@@ -399,7 +370,6 @@ export default class ThreadView extends React.Component {
highlightedEventId={highlightedEventId}
eventScrollIntoView={this.props.initialEventScrollIntoView}
onEventScrolledIntoView={this.resetJumpToEvent}
- onPaginationRequest={this.onPaginationRequest}
/>
>;
} else {
diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx
index 7ddeca11bc..fe7ecc8248 100644
--- a/src/components/structures/TimelinePanel.tsx
+++ b/src/components/structures/TimelinePanel.tsx
@@ -1409,24 +1409,28 @@ class TimelinePanel extends React.Component {
// quite slow. So we detect that situation and shortcut straight to
// calling _reloadEvents and updating the state.
- const timeline = this.props.timelineSet.getTimelineForEvent(eventId);
- if (timeline) {
- // This is a hot-path optimization by skipping a promise tick
- // by repeating a no-op sync branch in TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
- this.timelineWindow.load(eventId, INITIAL_SIZE); // in this branch this method will happen in sync time
+ // This is a hot-path optimization by skipping a promise tick
+ // by repeating a no-op sync branch in
+ // TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
+ if (this.props.timelineSet.getTimelineForEvent(eventId)) {
+ // if we've got an eventId, and the timeline exists, we can skip
+ // the promise tick.
+ this.timelineWindow.load(eventId, INITIAL_SIZE);
+ // in this branch this method will happen in sync time
onLoaded();
- } else {
- const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
- this.buildLegacyCallEventGroupers();
- this.setState({
- events: [],
- liveEvents: [],
- canBackPaginate: false,
- canForwardPaginate: false,
- timelineLoading: true,
- });
- prom.then(onLoaded, onError);
+ return;
}
+
+ const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
+ this.buildLegacyCallEventGroupers();
+ this.setState({
+ events: [],
+ liveEvents: [],
+ canBackPaginate: false,
+ canForwardPaginate: false,
+ timelineLoading: true,
+ });
+ prom.then(onLoaded, onError);
}
// handle the completion of a timeline load or localEchoUpdate, by
@@ -1443,8 +1447,8 @@ class TimelinePanel extends React.Component {
}
// Force refresh the timeline before threads support pending events
- public refreshTimeline(): void {
- this.loadTimeline();
+ public refreshTimeline(eventId?: string): void {
+ this.loadTimeline(eventId, undefined, undefined, false);
this.reloadEvents();
}
diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx
index c9fc7e001d..7c1564c9d9 100644
--- a/src/components/structures/auth/Login.tsx
+++ b/src/components/structures/auth/Login.tsx
@@ -18,9 +18,10 @@ 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 { _t, _td } from '../../../languageHandler';
-import Login, { ISSOFlow, LoginFlow } from '../../../Login';
+import Login from '../../../Login';
import SdkConfig from '../../../SdkConfig';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx
index b5770110f6..c155b5acc2 100644
--- a/src/components/structures/auth/Registration.tsx
+++ b/src/components/structures/auth/Registration.tsx
@@ -19,6 +19,7 @@ import React, { Fragment, ReactNode } from 'react';
import { 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 { _t, _td } from '../../../languageHandler';
import { messageForResourceLimitError } from '../../../utils/ErrorUtils';
@@ -26,7 +27,7 @@ import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import * as Lifecycle from '../../../Lifecycle';
import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg";
import AuthPage from "../../views/auth/AuthPage";
-import Login, { ISSOFlow } from "../../../Login";
+import Login from "../../../Login";
import dis from "../../../dispatcher/dispatcher";
import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from '../../views/elements/ServerPicker';
diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx
index 64dcdce645..7b946070ca 100644
--- a/src/components/structures/auth/SoftLogout.tsx
+++ b/src/components/structures/auth/SoftLogout.tsx
@@ -17,13 +17,14 @@ 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 { _t } from '../../../languageHandler';
import dis from '../../../dispatcher/dispatcher';
import * as Lifecycle from '../../../Lifecycle';
import Modal from '../../../Modal';
import { MatrixClientPeg } from "../../../MatrixClientPeg";
-import { ISSOFlow, LoginFlow, sendLoginRequest } from "../../../Login";
+import { sendLoginRequest } from "../../../Login";
import AuthPage from "../../views/auth/AuthPage";
import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform";
import SSOButtons from "../../views/elements/SSOButtons";
diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx
index f50232fb22..c09b598cee 100644
--- a/src/components/views/avatars/RoomAvatar.tsx
+++ b/src/components/views/avatars/RoomAvatar.tsx
@@ -16,11 +16,10 @@ limitations under the License.
import React, { ComponentProps } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
-import { ResizeMethod } from 'matrix-js-sdk/src/@types/partials';
import { MatrixEvent } from 'matrix-js-sdk/src/models/event';
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import classNames from "classnames";
-import { EventType } from "matrix-js-sdk/src/@types/event";
+import { EventType, RoomType } from "matrix-js-sdk/src/@types/event";
import BaseAvatar from './BaseAvatar';
import ImageView from '../elements/ImageView';
@@ -39,11 +38,7 @@ interface IProps extends Omit, "name" | "idNam
oobData?: IOOBData & {
roomId?: string;
};
- width?: number;
- height?: number;
- resizeMethod?: ResizeMethod;
viewAvatarOnClick?: boolean;
- className?: string;
onClick?(): void;
}
@@ -72,10 +67,7 @@ export default class RoomAvatar extends React.Component {
}
public componentWillUnmount() {
- const cli = MatrixClientPeg.get();
- if (cli) {
- cli.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
- }
+ MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
}
public static getDerivedStateFromProps(nextProps: IProps): IState {
@@ -133,7 +125,7 @@ export default class RoomAvatar extends React.Component {
public render() {
const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props;
- const roomName = room ? room.name : oobData.name;
+ const roomName = room?.name ?? oobData.name;
// If the room is a DM, we use the other user's ID for the color hash
// in order to match the room avatar with their avatar
const idName = room ? (DMRoomMap.shared().getUserIdForRoomId(room.roomId) ?? room.roomId) : oobData.roomId;
@@ -142,7 +134,7 @@ export default class RoomAvatar extends React.Component {
= ({ latestLocationState }) => {
return <>
public render(): JSX.Element {
const cli = MatrixClientPeg.get();
const me = cli.getUserId();
- const { mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain } = this.props;
+ const {
+ mxEvent, rightClick, link, eventTileOps, reactions, collapseReplyChain,
+ ...other
+ } = this.props;
+ delete other.getRelationsForEvent;
+ delete other.permalinkCreator;
+
const eventStatus = mxEvent.status;
const unsentReactionsCount = this.getUnsentReactions().length;
const contentActionable = isContentActionable(mxEvent);
@@ -747,7 +753,7 @@ export default class MessageContextMenu extends React.Component
return (
= ({ initialText = "", initialFilter = n
shouldPeek: result.publicRoom.world_readable || cli.isGuest(),
}, true, ev.type !== "click");
};
+
return (
= ({ initialText = "", initialFilter = n
aria-describedby={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_alias`}
aria-details={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}_details`}
>
-
diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx
index 7036575cd1..6f11fa12bd 100644
--- a/src/components/views/elements/AccessibleButton.tsx
+++ b/src/components/views/elements/AccessibleButton.tsx
@@ -75,7 +75,7 @@ type IProps = DynamicHtmlElementProps
onClick: ((e: ButtonEvent) => void | Promise) | null;
};
-interface IAccessibleButtonProps extends React.InputHTMLAttributes {
+export interface IAccessibleButtonProps extends React.InputHTMLAttributes {
ref?: React.Ref;
}
diff --git a/src/components/views/elements/DialogButtons.tsx b/src/components/views/elements/DialogButtons.tsx
index bf018e14f4..522d847e1b 100644
--- a/src/components/views/elements/DialogButtons.tsx
+++ b/src/components/views/elements/DialogButtons.tsx
@@ -82,7 +82,7 @@ export default class DialogButtons extends React.Component {
cancelButton = {
{ cancelButton }
{ this.props.children }
= ({
+ title,
+ description,
+ ...rest
+}) => {
+ const onClick = () => {
+ Modal.createDialog(
+ InfoDialog,
+ {
+ title,
+ description,
+ button: _t('Got it'),
+ hasCloseButton: true,
+ },
+ );
+ };
+
+ return
+ { _t('Learn more') }
+ ;
+};
+
+export default LearnMore;
diff --git a/src/components/views/elements/PowerSelector.tsx b/src/components/views/elements/PowerSelector.tsx
index 3a9e87d158..8b251b91a5 100644
--- a/src/components/views/elements/PowerSelector.tsx
+++ b/src/components/views/elements/PowerSelector.tsx
@@ -44,14 +44,13 @@ interface IProps {
}
interface IState {
- levelRoleMap: {};
+ levelRoleMap: Partial>;
// List of power levels to show in the drop-down
options: number[];
customValue: number;
selectValue: number | string;
custom?: boolean;
- customLevel?: number;
}
export default class PowerSelector extends React.Component {
@@ -101,7 +100,7 @@ export default class PowerSelector extends React.Component {
levelRoleMap,
options,
custom: isCustom,
- customLevel: newProps.value,
+ customValue: newProps.value,
selectValue: isCustom ? CUSTOM_VALUE : newProps.value,
});
}
@@ -125,7 +124,11 @@ export default class PowerSelector extends React.Component {
event.preventDefault();
event.stopPropagation();
- this.props.onChange(this.state.customValue, this.props.powerLevelKey);
+ if (Number.isFinite(this.state.customValue)) {
+ this.props.onChange(this.state.customValue, this.props.powerLevelKey);
+ } else {
+ this.initStateFromProps(this.props); // reset, invalid input
+ }
};
private onCustomKeyDown = (event: React.KeyboardEvent): void => {
diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx
index 666e55eab4..4332c914e7 100644
--- a/src/components/views/elements/SSOButtons.tsx
+++ b/src/components/views/elements/SSOButtons.tsx
@@ -19,11 +19,11 @@ 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 PlatformPeg from "../../../PlatformPeg";
import AccessibleButton from "./AccessibleButton";
import { _t } from "../../../languageHandler";
-import { IdentityProviderBrand, IIdentityProvider, ISSOFlow } from "../../../Login";
import AccessibleTooltipButton from "./AccessibleTooltipButton";
import { mediaFromMxc } from "../../../customisations/Media";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx
index 23ba901acd..ab9c27f7fb 100644
--- a/src/components/views/messages/TextualBody.tsx
+++ b/src/components/views/messages/TextualBody.tsx
@@ -48,6 +48,7 @@ import RoomContext from "../../../contexts/RoomContext";
import AccessibleButton from '../elements/AccessibleButton';
import { options as linkifyOpts } from "../../../linkify-matrix";
import { getParentEventId } from '../../../utils/Reply';
+import { EditWysiwygComposer } from '../rooms/wysiwyg_composer';
const MAX_HIGHLIGHT_LENGTH = 4096;
@@ -562,7 +563,10 @@ export default class TextualBody extends React.Component {
render() {
if (this.props.editState) {
- return ;
+ const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
+ return isWysiwygComposerEnabled ?
+ :
+ ;
}
const mxEvent = this.props.mxEvent;
const content = mxEvent.getContent();
diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx
index 262b8fc38d..c6e012fff4 100644
--- a/src/components/views/right_panel/RoomHeaderButtons.tsx
+++ b/src/components/views/right_panel/RoomHeaderButtons.tsx
@@ -20,7 +20,8 @@ limitations under the License.
import React from "react";
import classNames from "classnames";
-import { Room } from "matrix-js-sdk/src/models/room";
+import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
+import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { _t } from '../../../languageHandler';
import HeaderButton from './HeaderButton';
@@ -43,6 +44,7 @@ import { SummarizedNotificationState } from "../../../stores/notifications/Summa
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import PosthogTrackers from "../../../PosthogTrackers";
import { ButtonEvent } from "../elements/AccessibleButton";
+import { MatrixClientPeg } from "../../../MatrixClientPeg";
const ROOM_INFO_PHASES = [
RightPanelPhases.RoomSummary,
@@ -136,32 +138,67 @@ export default class RoomHeaderButtons extends HeaderButtons {
private threadNotificationState: ThreadsRoomNotificationState;
private globalNotificationState: SummarizedNotificationState;
+ private get supportsThreadNotifications(): boolean {
+ const client = MatrixClientPeg.get();
+ return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
+ }
+
constructor(props: IProps) {
super(props, HeaderKind.Room);
- this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room);
+ if (!this.supportsThreadNotifications) {
+ this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room);
+ }
this.globalNotificationState = RoomNotificationStateStore.instance.globalState;
}
public componentDidMount(): void {
super.componentDidMount();
- this.threadNotificationState.on(NotificationStateEvents.Update, this.onThreadNotification);
+ if (!this.supportsThreadNotifications) {
+ this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate);
+ } else {
+ this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
+ }
+ this.onNotificationUpdate();
RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
}
public componentWillUnmount(): void {
super.componentWillUnmount();
- this.threadNotificationState.off(NotificationStateEvents.Update, this.onThreadNotification);
+ if (!this.supportsThreadNotifications) {
+ this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate);
+ } else {
+ this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
+ }
RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
}
- private onThreadNotification = (): void => {
+ private onNotificationUpdate = (): void => {
+ let threadNotificationColor: NotificationColor;
+ if (!this.supportsThreadNotifications) {
+ threadNotificationColor = this.threadNotificationState.color;
+ } else {
+ threadNotificationColor = this.notificationColor;
+ }
+
+ // console.log
// XXX: why don't we read from this.state.threadNotificationColor in the render methods?
this.setState({
- threadNotificationColor: this.threadNotificationState.color,
+ threadNotificationColor,
});
};
+ private get notificationColor(): NotificationColor {
+ switch (this.props.room.threadsAggregateNotificationType) {
+ case NotificationCountType.Highlight:
+ return NotificationColor.Red;
+ case NotificationCountType.Total:
+ return NotificationColor.Grey;
+ default:
+ return NotificationColor.None;
+ }
+ }
+
private onUpdateStatus = (notificationState: SummarizedNotificationState): void => {
// XXX: why don't we read from this.state.globalNotificationCount in the render methods?
this.globalNotificationState = notificationState;
@@ -255,12 +292,13 @@ export default class RoomHeaderButtons extends HeaderButtons {
? 0}
+ isUnread={this.state.threadNotificationColor > 0}
>
-
+
: null,
);
diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx
index b13eba33e4..f6b249b291 100644
--- a/src/components/views/rooms/EventTile.tsx
+++ b/src/components/views/rooms/EventTile.tsx
@@ -27,6 +27,7 @@ import { NotificationCountType, Room, RoomEvent } from 'matrix-js-sdk/src/models
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { UserTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning';
+import { Feature, ServerSupport } from 'matrix-js-sdk/src/feature';
import { Icon as LinkIcon } from '../../../../res/img/element-icons/link.svg';
import { Icon as ViewInRoomIcon } from '../../../../res/img/element-icons/view-in-room.svg';
@@ -84,6 +85,7 @@ import { useTooltip } from "../../../utils/useTooltip";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom';
import { ElementCall } from "../../../models/Call";
+import { UnreadNotificationBadge } from './NotificationBadge/UnreadNotificationBadge';
export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations;
@@ -113,7 +115,7 @@ export interface IEventTileType extends React.Component {
getEventTileOps?(): IEventTileOps;
}
-interface IProps {
+export interface EventTileProps {
// the MatrixEvent to show
mxEvent: MatrixEvent;
@@ -248,7 +250,7 @@ interface IState {
}
// MUST be rendered within a RoomContext with a set timelineRenderingType
-export class UnwrappedEventTile extends React.Component {
+export class UnwrappedEventTile extends React.Component {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private tile = React.createRef();
@@ -267,7 +269,7 @@ export class UnwrappedEventTile extends React.Component {
static contextType = RoomContext;
public context!: React.ContextType;
- constructor(props: IProps, context: React.ContextType) {
+ constructor(props: EventTileProps, context: React.ContextType) {
super(props, context);
const thread = this.thread;
@@ -394,7 +396,7 @@ export class UnwrappedEventTile extends React.Component {
if (SettingsStore.getValue("feature_thread")) {
this.props.mxEvent.on(ThreadEvent.Update, this.updateThread);
- if (this.thread) {
+ if (this.thread && !this.supportsThreadNotifications) {
this.setupNotificationListener(this.thread);
}
}
@@ -405,33 +407,40 @@ export class UnwrappedEventTile extends React.Component {
room?.on(ThreadEvent.New, this.onNewThread);
}
+ private get supportsThreadNotifications(): boolean {
+ const client = MatrixClientPeg.get();
+ return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
+ }
+
private setupNotificationListener(thread: Thread): void {
- const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room);
-
- this.threadState = notifications.getThreadRoomState(thread);
-
- this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate);
- this.onThreadStateUpdate();
+ if (!this.supportsThreadNotifications) {
+ const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room);
+ this.threadState = notifications.getThreadRoomState(thread);
+ this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate);
+ this.onThreadStateUpdate();
+ }
}
private onThreadStateUpdate = (): void => {
- let threadNotification = null;
- switch (this.threadState?.color) {
- case NotificationColor.Grey:
- threadNotification = NotificationCountType.Total;
- break;
- case NotificationColor.Red:
- threadNotification = NotificationCountType.Highlight;
- break;
- }
+ if (!this.supportsThreadNotifications) {
+ let threadNotification = null;
+ switch (this.threadState?.color) {
+ case NotificationColor.Grey:
+ threadNotification = NotificationCountType.Total;
+ break;
+ case NotificationColor.Red:
+ threadNotification = NotificationCountType.Highlight;
+ break;
+ }
- this.setState({
- threadNotification,
- });
+ this.setState({
+ threadNotification,
+ });
+ }
};
private updateThread = (thread: Thread) => {
- if (thread !== this.state.thread) {
+ if (thread !== this.state.thread && !this.supportsThreadNotifications) {
if (this.threadState) {
this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
}
@@ -444,7 +453,7 @@ export class UnwrappedEventTile extends React.Component {
// TODO: [REACT-WARNING] Replace with appropriate lifecycle event
// eslint-disable-next-line
- UNSAFE_componentWillReceiveProps(nextProps: IProps) {
+ UNSAFE_componentWillReceiveProps(nextProps: EventTileProps) {
// re-check the sender verification as outgoing events progress through
// the send process.
if (nextProps.eventSendStatus !== this.props.eventSendStatus) {
@@ -452,7 +461,7 @@ export class UnwrappedEventTile extends React.Component {
}
}
- shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean {
+ shouldComponentUpdate(nextProps: EventTileProps, nextState: IState): boolean {
if (objectHasDiff(this.state, nextState)) {
return true;
}
@@ -481,7 +490,7 @@ export class UnwrappedEventTile extends React.Component {
}
}
- componentDidUpdate(prevProps: IProps, prevState: IState, snapshot) {
+ componentDidUpdate() {
// If we're not listening for receipts and expect to be, register a listener.
if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) {
MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt);
@@ -531,7 +540,11 @@ export class UnwrappedEventTile extends React.Component {
private renderThreadInfo(): React.ReactNode {
if (this.state.thread?.id === this.props.mxEvent.getId()) {
- return ;
+ return ;
}
if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) {
@@ -667,7 +680,7 @@ export class UnwrappedEventTile extends React.Component {
}, this.props.onHeightChanged); // Decryption may have caused a change in size
}
- private propsEqual(objA: IProps, objB: IProps): boolean {
+ private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean {
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
@@ -1348,6 +1361,7 @@ export class UnwrappedEventTile extends React.Component {
]);
}
case TimelineRenderingType.ThreadsList: {
+ const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
// tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers
return (
React.createElement(this.props.as || "li", {
@@ -1361,7 +1375,9 @@ export class UnwrappedEventTile extends React.Component {
"data-shape": this.context.timelineRenderingType,
"data-self": isOwnEvent,
"data-has-reply": !!replyChain,
- "data-notification": this.state.threadNotification,
+ "data-notification": !this.supportsThreadNotifications
+ ? this.state.threadNotification
+ : undefined,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onClick": (ev: MouseEvent) => {
@@ -1409,6 +1425,9 @@ export class UnwrappedEventTile extends React.Component {
{ msgOption }
+
>)
);
}
@@ -1512,10 +1531,12 @@ export class UnwrappedEventTile extends React.Component {
}
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
-const SafeEventTile = forwardRef((props: IProps, ref: RefObject) => {
- return
-
- ;
+const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject) => {
+ return <>
+
+
+
+ >;
});
export default SafeEventTile;
diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx
index d1521c7b0c..4b04b87dae 100644
--- a/src/components/views/rooms/MessageComposer.tsx
+++ b/src/components/views/rooms/MessageComposer.tsx
@@ -58,7 +58,9 @@ import {
startNewVoiceBroadcastRecording,
VoiceBroadcastRecordingsStore,
} from '../../../voice-broadcast';
-import { WysiwygComposer } from './wysiwyg_composer/WysiwygComposer';
+import { SendWysiwygComposer, sendMessage } from './wysiwyg_composer/';
+import { MatrixClientProps, withMatrixClientHOC } from '../../../contexts/MatrixClientContext';
+import { htmlToPlainText } from '../../../utils/room/htmlToPlaintext';
let instanceCount = 0;
@@ -78,7 +80,7 @@ function SendButton(props: ISendButtonProps) {
);
}
-interface IProps {
+interface IProps extends MatrixClientProps {
room: Room;
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
@@ -89,6 +91,7 @@ interface IProps {
}
interface IState {
+ composerContent: string;
isComposerEmpty: boolean;
haveRecording: boolean;
recordingTimeLeftSeconds?: number;
@@ -98,15 +101,17 @@ interface IState {
showStickersButton: boolean;
showPollsButton: boolean;
showVoiceBroadcastButton: boolean;
+ isWysiwygLabEnabled: boolean;
+ isRichTextEnabled: boolean;
+ initialComposerContent: string;
}
-export default class MessageComposer extends React.Component {
+export class MessageComposer extends React.Component {
private dispatcherRef?: string;
private messageComposerInput = createRef();
private voiceRecordingButton = createRef();
private ref: React.RefObject = createRef();
private instanceId: number;
- private composerSendMessage?: () => void;
private _voiceRecording: Optional;
@@ -116,6 +121,7 @@ export default class MessageComposer extends React.Component {
public static defaultProps = {
compact: false,
showVoiceBroadcastButton: false,
+ isRichTextEnabled: true,
};
public constructor(props: IProps) {
@@ -124,6 +130,7 @@ export default class MessageComposer extends React.Component {
this.state = {
isComposerEmpty: true,
+ composerContent: '',
haveRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false,
@@ -131,6 +138,9 @@ export default class MessageComposer extends React.Component {
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
+ isWysiwygLabEnabled: SettingsStore.getValue("feature_wysiwyg_composer"),
+ isRichTextEnabled: true,
+ initialComposerContent: '',
};
this.instanceId = instanceCount++;
@@ -138,6 +148,7 @@ export default class MessageComposer extends React.Component {
SettingsStore.monitorSetting("MessageComposerInput.showStickersButton", null);
SettingsStore.monitorSetting("MessageComposerInput.showPollsButton", null);
SettingsStore.monitorSetting(Features.VoiceBroadcast, null);
+ SettingsStore.monitorSetting("feature_wysiwyg_composer", null);
}
private get voiceRecording(): Optional {
@@ -218,6 +229,12 @@ export default class MessageComposer extends React.Component {
}
break;
}
+ case "feature_wysiwyg_composer": {
+ if (this.state.isWysiwygLabEnabled !== settingUpdatedPayload.newValue) {
+ this.setState({ isWysiwygLabEnabled: Boolean(settingUpdatedPayload.newValue) });
+ }
+ break;
+ }
}
}
}
@@ -315,7 +332,15 @@ export default class MessageComposer extends React.Component {
}
this.messageComposerInput.current?.sendMessage();
- this.composerSendMessage?.();
+
+ if (this.state.isWysiwygLabEnabled) {
+ const { permalinkCreator, relation, replyToEvent } = this.props;
+ sendMessage(this.state.composerContent,
+ this.state.isRichTextEnabled,
+ { mxClient: this.props.mxClient, roomContext: this.context, permalinkCreator, relation, replyToEvent });
+ dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer });
+ this.setState({ composerContent: '', initialComposerContent: '' });
+ }
};
private onChange = (model: EditorModel) => {
@@ -326,10 +351,21 @@ export default class MessageComposer extends React.Component {
private onWysiwygChange = (content: string) => {
this.setState({
+ composerContent: content,
isComposerEmpty: content?.length === 0,
});
};
+ private onRichTextToggle = () => {
+ this.setState(state => ({
+ isRichTextEnabled: !state.isRichTextEnabled,
+ initialComposerContent: !state.isRichTextEnabled ?
+ state.composerContent :
+ // TODO when available use rust model plain text
+ htmlToPlainText(state.composerContent),
+ }));
+ };
+
private onVoiceStoreUpdate = () => {
this.updateRecordingState();
};
@@ -385,7 +421,6 @@ export default class MessageComposer extends React.Component {
}
public render() {
- const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
const controls = [
this.props.e2eStatus ?
:
@@ -400,18 +435,15 @@ export default class MessageComposer extends React.Component {
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
if (canSendMessages) {
- if (isWysiwygComposerEnabled) {
+ if (this.state.isWysiwygLabEnabled) {
controls.push(
-
- { (sendMessage) => {
- this.composerSendMessage = sendMessage;
- } }
- ,
+ onSend={this.sendMessage}
+ isRichTextEnabled={this.state.isRichTextEnabled}
+ initialContent={this.state.initialComposerContent}
+ />,
);
} else {
controls.push(
@@ -498,7 +530,7 @@ export default class MessageComposer extends React.Component {
"mx_MessageComposer": true,
"mx_MessageComposer--compact": this.props.compact,
"mx_MessageComposer_e2eStatus": this.props.e2eStatus != undefined,
- "mx_MessageComposer_wysiwyg": isWysiwygComposerEnabled,
+ "mx_MessageComposer_wysiwyg": this.state.isWysiwygLabEnabled && this.state.isRichTextEnabled,
});
return (
@@ -527,6 +559,9 @@ export default class MessageComposer extends React.Component {
showLocationButton={!window.electron}
showPollsButton={this.state.showPollsButton}
showStickersButton={this.showStickersButton}
+ showComposerModeButton={this.state.isWysiwygLabEnabled}
+ isRichTextEnabled={this.state.isRichTextEnabled}
+ onComposerModeClick={this.onRichTextToggle}
toggleButtonMenu={this.toggleButtonMenu}
showVoiceBroadcastButton={this.state.showVoiceBroadcastButton}
onStartVoiceBroadcastClick={() => {
@@ -551,3 +586,6 @@ export default class MessageComposer extends React.Component {
);
}
}
+
+const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
+export default MessageComposerWithMatrixClient;
diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx
index b77bff66a8..d31f6fea27 100644
--- a/src/components/views/rooms/MessageComposerButtons.tsx
+++ b/src/components/views/rooms/MessageComposerButtons.tsx
@@ -17,7 +17,7 @@ limitations under the License.
import classNames from 'classnames';
import { IEventRelation } from "matrix-js-sdk/src/models/event";
import { M_POLL_START } from "matrix-events-sdk";
-import React, { createContext, ReactElement, useContext, useRef } from 'react';
+import React, { createContext, MouseEventHandler, ReactElement, useContext, useRef } from 'react';
import { Room } from 'matrix-js-sdk/src/models/room';
import { MatrixClient } from 'matrix-js-sdk/src/client';
import { THREAD_RELATION_TYPE } from 'matrix-js-sdk/src/models/thread';
@@ -55,6 +55,9 @@ interface IProps {
toggleButtonMenu: () => void;
showVoiceBroadcastButton: boolean;
onStartVoiceBroadcastClick: () => void;
+ isRichTextEnabled: boolean;
+ showComposerModeButton: boolean;
+ onComposerModeClick: () => void;
}
type OverflowMenuCloser = () => void;
@@ -85,6 +88,8 @@ const MessageComposerButtons: React.FC = (props: IProps) => {
} else {
mainButtons = [
emojiButton(props),
+ props.showComposerModeButton &&
+ ,
uploadButton(), // props passed via UploadButtonContext
];
moreButtons = [
@@ -397,4 +402,23 @@ function showLocationButton(
);
}
+interface WysiwygToggleButtonProps {
+ isRichTextEnabled: boolean;
+ onClick: MouseEventHandler;
+}
+
+function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps) {
+ const title = isRichTextEnabled ? _t("Show plain text") : _t("Show formatting");
+
+ return ;
+}
+
export default MessageComposerButtons;
diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx
index 56c7d7224c..371494c79e 100644
--- a/src/components/views/rooms/NewRoomIntro.tsx
+++ b/src/components/views/rooms/NewRoomIntro.tsx
@@ -175,14 +175,22 @@ const NewRoomIntro = () => {
}
const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url;
- body =
-
+ );
+
+ if (!avatarUrl) {
+ avatar = cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, '')}
>
-
-
+ { avatar }
+ ;
+ }
+
+ body =
+ { avatar }
{ room.name }
diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx
index 51745209aa..dccf8b1190 100644
--- a/src/components/views/rooms/NotificationBadge.tsx
+++ b/src/components/views/rooms/NotificationBadge.tsx
@@ -15,16 +15,14 @@ limitations under the License.
*/
import React, { MouseEvent } from "react";
-import classNames from "classnames";
-import { formatCount } from "../../../utils/FormattingUtils";
import SettingsStore from "../../../settings/SettingsStore";
-import AccessibleButton from "../elements/AccessibleButton";
import { XOR } from "../../../@types/common";
import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import Tooltip from "../elements/Tooltip";
import { _t } from "../../../languageHandler";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
+import { StatelessNotificationBadge } from "./NotificationBadge/StatelessNotificationBadge";
interface IProps {
notification: NotificationState;
@@ -113,61 +111,30 @@ export default class NotificationBadge extends React.PureComponent 0;
- let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount;
if (forceCount) {
- isEmptyBadge = false;
if (!notification.hasUnreadCount) return null; // Can't render a badge
}
- let symbol = notification.symbol || formatCount(notification.count);
- if (isEmptyBadge) symbol = "";
-
- const classes = classNames({
- 'mx_NotificationBadge': true,
- 'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount,
- 'mx_NotificationBadge_highlighted': notification.hasMentions,
- 'mx_NotificationBadge_dot': isEmptyBadge,
- 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3,
- 'mx_NotificationBadge_3char': symbol.length > 2,
- });
-
- if (onClick) {
- let label: string;
- let tooltip: JSX.Element;
- if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
- label = _t("Message didn't send. Click for info.");
- tooltip = ;
- }
-
- return (
-
- { symbol }
- { tooltip }
-
- );
+ let label: string;
+ let tooltip: JSX.Element;
+ if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) {
+ label = _t("Message didn't send. Click for info.");
+ tooltip = ;
}
- return (
-
- { symbol }
-
- );
+ return
+ { tooltip }
+ ;
}
}
diff --git a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx
new file mode 100644
index 0000000000..868df3216f
--- /dev/null
+++ b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx
@@ -0,0 +1,81 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { MouseEvent } from "react";
+import classNames from "classnames";
+
+import { formatCount } from "../../../../utils/FormattingUtils";
+import AccessibleButton from "../../elements/AccessibleButton";
+import { NotificationColor } from "../../../../stores/notifications/NotificationColor";
+
+interface Props {
+ symbol: string | null;
+ count: number;
+ color: NotificationColor;
+ onClick?: (ev: MouseEvent) => void;
+ onMouseOver?: (ev: MouseEvent) => void;
+ onMouseLeave?: (ev: MouseEvent) => void;
+ children?: React.ReactChildren | JSX.Element;
+ label?: string;
+}
+
+export function StatelessNotificationBadge({
+ symbol,
+ count,
+ color,
+ ...props }: Props) {
+ // Don't show a badge if we don't need to
+ if (color === NotificationColor.None) return null;
+
+ const hasUnreadCount = color >= NotificationColor.Grey && (!!count || !!symbol);
+
+ const isEmptyBadge = symbol === null && count === 0;
+
+ if (symbol === null && count > 0) {
+ symbol = formatCount(count);
+ }
+
+ const classes = classNames({
+ 'mx_NotificationBadge': true,
+ 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasUnreadCount,
+ 'mx_NotificationBadge_highlighted': color === NotificationColor.Red,
+ 'mx_NotificationBadge_dot': isEmptyBadge,
+ 'mx_NotificationBadge_2char': symbol?.length > 0 && symbol?.length < 3,
+ 'mx_NotificationBadge_3char': symbol?.length > 2,
+ });
+
+ if (props.onClick) {
+ return (
+
+ { symbol }
+ { props.children }
+
+ );
+ }
+
+ return (
+
+ { symbol }
+
+ );
+}
diff --git a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx
new file mode 100644
index 0000000000..a623daa716
--- /dev/null
+++ b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx
@@ -0,0 +1,36 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Room } from "matrix-js-sdk/src/models/room";
+import React from "react";
+
+import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications";
+import { StatelessNotificationBadge } from "./StatelessNotificationBadge";
+
+interface Props {
+ room: Room;
+ threadId?: string;
+}
+
+export function UnreadNotificationBadge({ room, threadId }: Props) {
+ const { symbol, count, color } = useUnreadNotifications(room, threadId);
+
+ return ;
+}
diff --git a/src/components/views/rooms/RoomPreviewBar.tsx b/src/components/views/rooms/RoomPreviewBar.tsx
index 39c82e4eea..eecdf00147 100644
--- a/src/components/views/rooms/RoomPreviewBar.tsx
+++ b/src/components/views/rooms/RoomPreviewBar.tsx
@@ -263,9 +263,9 @@ export default class RoomPreviewBar extends React.Component {
params: {
email: this.props.invitedEmail,
signurl: this.props.signUrl,
- room_name: this.props.oobData ? this.props.oobData.room_name : null,
- room_avatar_url: this.props.oobData ? this.props.oobData.avatarUrl : null,
- inviter_name: this.props.oobData ? this.props.oobData.inviterName : null,
+ room_name: this.props.oobData?.name ?? null,
+ room_avatar_url: this.props.oobData?.avatarUrl ?? null,
+ inviter_name: this.props.oobData?.inviterName ?? null,
},
};
}
diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx
index 9e890e9c21..9f8133d55c 100644
--- a/src/components/views/rooms/RoomSublist.tsx
+++ b/src/components/views/rooms/RoomSublist.tsx
@@ -570,8 +570,7 @@ export default class RoomSublist extends React.Component {
const slidingList = SlidingSyncManager.instance.slidingSync.getList(slidingSyncIndex);
isAlphabetical = slidingList.sort[0] === "by_name";
isUnreadFirst = (
- slidingList.sort[0] === "by_highlight_count" ||
- slidingList.sort[0] === "by_notification_count"
+ slidingList.sort[0] === "by_notification_level"
);
}
diff --git a/src/components/views/rooms/ThreadSummary.tsx b/src/components/views/rooms/ThreadSummary.tsx
index c14a4cc9e1..83dbf0d45a 100644
--- a/src/components/views/rooms/ThreadSummary.tsx
+++ b/src/components/views/rooms/ThreadSummary.tsx
@@ -37,7 +37,7 @@ interface IProps {
thread: Thread;
}
-const ThreadSummary = ({ mxEvent, thread }: IProps) => {
+const ThreadSummary = ({ mxEvent, thread, ...props }: IProps) => {
const roomContext = useContext(RoomContext);
const cardContext = useContext(CardContext);
const count = useTypedEventEmitterState(thread, ThreadEvent.Update, () => thread.length);
@@ -50,6 +50,7 @@ const ThreadSummary = ({ mxEvent, thread }: IProps) => {
return (
{
defaultDispatcher.dispatch({
@@ -94,7 +95,9 @@ export const ThreadMessagePreview = ({ thread, showDisplayname = false }: IPrevi
await cli.decryptEventIfNeeded(lastReply);
return MessagePreviewStore.instance.generatePreviewForEvent(lastReply);
}, [lastReply, content]);
- if (!preview) return null;
+ if (!preview || !lastReply) {
+ return null;
+ }
return <>
(
+ function Content({ disabled }: ContentProps, forwardRef: RefObject) {
+ useWysiwygEditActionHandler(disabled, forwardRef);
+ return null;
+ },
+);
+
+interface EditWysiwygComposerProps {
+ disabled?: boolean;
+ onChange?: (content: string) => void;
+ editorStateTransfer: EditorStateTransfer;
+ className?: string;
+}
+
+export function EditWysiwygComposer({ editorStateTransfer, className, ...props }: EditWysiwygComposerProps) {
+ const initialContent = useInitialContent(editorStateTransfer);
+ const isReady = !editorStateTransfer || Boolean(initialContent);
+
+ const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(initialContent, editorStateTransfer);
+
+ return isReady &&
+ { (ref) => (
+ <>
+
+
+ >)
+ }
+ ;
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx
new file mode 100644
index 0000000000..380b0430ce
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx
@@ -0,0 +1,52 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { forwardRef, RefObject } from 'react';
+
+import { useWysiwygSendActionHandler } from './hooks/useWysiwygSendActionHandler';
+import { WysiwygComposer } from './components/WysiwygComposer';
+import { PlainTextComposer } from './components/PlainTextComposer';
+import { ComposerFunctions } from './types';
+
+interface ContentProps {
+ disabled: boolean;
+ composerFunctions: ComposerFunctions;
+}
+
+const Content = forwardRef(
+ function Content({ disabled, composerFunctions }: ContentProps, forwardRef: RefObject) {
+ useWysiwygSendActionHandler(disabled, forwardRef, composerFunctions);
+ return null;
+ },
+);
+
+interface SendWysiwygComposerProps {
+ initialContent?: string;
+ isRichTextEnabled: boolean;
+ disabled?: boolean;
+ onChange: (content: string) => void;
+ onSend: () => void;
+}
+
+export function SendWysiwygComposer({ isRichTextEnabled, ...props }: SendWysiwygComposerProps) {
+ const Composer = isRichTextEnabled ? WysiwygComposer : PlainTextComposer;
+
+ return
+ { (ref, composerFunctions) => (
+
+ ) }
+ ;
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx
deleted file mode 100644
index c22e3406fa..0000000000
--- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import React, { useCallback, useEffect } from 'react';
-import { IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
-import { useWysiwyg, Wysiwyg, WysiwygInputEvent } from "@matrix-org/matrix-wysiwyg";
-
-import { Editor } from './Editor';
-import { FormattingButtons } from './FormattingButtons';
-import { RoomPermalinkCreator } from '../../../../utils/permalinks/Permalinks';
-import { sendMessage } from './message';
-import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext';
-import { useRoomContext } from '../../../../contexts/RoomContext';
-import { useWysiwygActionHandler } from './useWysiwygActionHandler';
-import { useSettingValue } from '../../../../hooks/useSettings';
-
-interface WysiwygProps {
- disabled?: boolean;
- onChange: (content: string) => void;
- relation?: IEventRelation;
- replyToEvent?: MatrixEvent;
- permalinkCreator: RoomPermalinkCreator;
- includeReplyLegacyFallback?: boolean;
- children?: (sendMessage: () => void) => void;
-}
-
-export function WysiwygComposer(
- { disabled = false, onChange, children, ...props }: WysiwygProps,
-) {
- const roomContext = useRoomContext();
- const mxClient = useMatrixClientContext();
- const ctrlEnterToSend = useSettingValue("MessageComposerInput.ctrlEnterToSend");
-
- function inputEventProcessor(event: WysiwygInputEvent, wysiwyg: Wysiwyg): WysiwygInputEvent | null {
- if (event instanceof ClipboardEvent) {
- return event;
- }
-
- if (
- (event.inputType === 'insertParagraph' && !ctrlEnterToSend) ||
- event.inputType === 'sendMessage'
- ) {
- sendMessage(content, { mxClient, roomContext, ...props });
- wysiwyg.actions.clear();
- ref.current?.focus();
- return null;
- }
-
- return event;
- }
-
- const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg({ inputEventProcessor });
-
- useEffect(() => {
- if (!disabled && content !== null) {
- onChange(content);
- }
- }, [onChange, content, disabled]);
-
- const memoizedSendMessage = useCallback(() => {
- sendMessage(content, { mxClient, roomContext, ...props });
- wysiwyg.clear();
- ref.current?.focus();
- }, [content, mxClient, roomContext, wysiwyg, props, ref]);
-
- useWysiwygActionHandler(disabled, ref);
-
- return (
-
-
-
- { children?.(memoizedSendMessage) }
-
- );
-}
diff --git a/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx
new file mode 100644
index 0000000000..4fdc99a79c
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx
@@ -0,0 +1,37 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { MouseEventHandler } from 'react';
+
+import { _t } from '../../../../../languageHandler';
+import AccessibleButton from '../../../elements/AccessibleButton';
+
+interface EditionButtonsProps {
+ onCancelClick: MouseEventHandler;
+ onSaveClick: MouseEventHandler;
+ isSaveDisabled?: boolean;
+}
+
+export function EditionButtons({ onCancelClick, onSaveClick, isSaveDisabled = false }: EditionButtonsProps) {
+ return
+
+ { _t("Cancel") }
+
+
+ { _t("Save") }
+
+
;
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/Editor.tsx b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx
similarity index 100%
rename from src/components/views/rooms/wysiwyg_composer/Editor.tsx
rename to src/components/views/rooms/wysiwyg_composer/components/Editor.tsx
diff --git a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx
similarity index 84%
rename from src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx
rename to src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx
index 19941ad3f9..00127e5e43 100644
--- a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx
+++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx
@@ -15,14 +15,14 @@ limitations under the License.
*/
import React, { MouseEventHandler } from "react";
-import { useWysiwyg } from "@matrix-org/matrix-wysiwyg";
+import { FormattingFunctions, FormattingStates } from "@matrix-org/matrix-wysiwyg";
import classNames from "classnames";
-import AccessibleTooltipButton from "../../elements/AccessibleTooltipButton";
-import { Alignment } from "../../elements/Tooltip";
-import { KeyboardShortcut } from "../../settings/KeyboardShortcut";
-import { KeyCombo } from "../../../../KeyBindingsManager";
-import { _td } from "../../../../languageHandler";
+import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton";
+import { Alignment } from "../../../elements/Tooltip";
+import { KeyboardShortcut } from "../../../settings/KeyboardShortcut";
+import { KeyCombo } from "../../../../../KeyBindingsManager";
+import { _td } from "../../../../../languageHandler";
interface TooltipProps {
label: string;
@@ -55,8 +55,8 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps)
}
interface FormattingButtonsProps {
- composer: ReturnType['wysiwyg'];
- formattingStates: ReturnType['formattingStates'];
+ composer: FormattingFunctions;
+ formattingStates: FormattingStates;
}
export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) {
diff --git a/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx
new file mode 100644
index 0000000000..e15b5ef57f
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer.tsx
@@ -0,0 +1,56 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { MutableRefObject, ReactNode } from 'react';
+
+import { useComposerFunctions } from '../hooks/useComposerFunctions';
+import { usePlainTextInitialization } from '../hooks/usePlainTextInitialization';
+import { usePlainTextListeners } from '../hooks/usePlainTextListeners';
+import { useSetCursorPosition } from '../hooks/useSetCursorPosition';
+import { ComposerFunctions } from '../types';
+import { Editor } from "./Editor";
+
+interface PlainTextComposerProps {
+ disabled?: boolean;
+ onChange?: (content: string) => void;
+ onSend: () => void;
+ initialContent?: string;
+ className?: string;
+ children?: (
+ ref: MutableRefObject,
+ composerFunctions: ComposerFunctions,
+ ) => ReactNode;
+}
+
+export function PlainTextComposer({
+ className, disabled, onSend, onChange, children, initialContent }: PlainTextComposerProps,
+) {
+ const { ref, onInput, onPaste, onKeyDown } = usePlainTextListeners(onChange, onSend);
+ const composerFunctions = useComposerFunctions(ref);
+ usePlainTextInitialization(initialContent, ref);
+ useSetCursorPosition(disabled, ref);
+
+ return
+
+ { children?.(ref, composerFunctions) }
+
;
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx
new file mode 100644
index 0000000000..974e89f0ce
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx
@@ -0,0 +1,61 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React, { memo, MutableRefObject, ReactNode, useEffect } from 'react';
+import { useWysiwyg, FormattingFunctions } from "@matrix-org/matrix-wysiwyg";
+
+import { FormattingButtons } from './FormattingButtons';
+import { Editor } from './Editor';
+import { useInputEventProcessor } from '../hooks/useInputEventProcessor';
+import { useSetCursorPosition } from '../hooks/useSetCursorPosition';
+
+interface WysiwygComposerProps {
+ disabled?: boolean;
+ onChange?: (content: string) => void;
+ onSend: () => void;
+ initialContent?: string;
+ className?: string;
+ children?: (
+ ref: MutableRefObject,
+ wysiwyg: FormattingFunctions,
+ ) => ReactNode;
+}
+
+export const WysiwygComposer = memo(function WysiwygComposer(
+ { disabled = false, onChange, onSend, initialContent, className, children }: WysiwygComposerProps,
+) {
+ const inputEventProcessor = useInputEventProcessor(onSend);
+
+ const { ref, isWysiwygReady, content, formattingStates, wysiwyg } =
+ useWysiwyg({ initialContent, inputEventProcessor });
+
+ useEffect(() => {
+ if (!disabled && content !== null) {
+ onChange?.(content);
+ }
+ }, [onChange, content, disabled]);
+
+ const isReady = isWysiwygReady && !disabled;
+ useSetCursorPosition(!isReady, ref);
+
+ return (
+
+
+
+ { children?.(ref, wysiwyg) }
+
+ );
+});
diff --git a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts
similarity index 66%
rename from res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss
rename to src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts
index 13e3104c9a..99a89589ee 100644
--- a/res/css/voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions.ts
@@ -14,16 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-.mx_VoiceBroadcastRecordingBody {
- align-items: flex-start;
- background-color: $quinary-content;
- border-radius: 8px;
- display: inline-flex;
- gap: $spacing-8;
- padding: 12px;
-}
+import { RefObject, useMemo } from "react";
-.mx_VoiceBroadcastRecordingBody_title {
- font-size: $font-12px;
- font-weight: $font-semi-bold;
+export function useComposerFunctions(ref: RefObject) {
+ return useMemo(() => ({
+ clear: () => {
+ if (ref.current) {
+ ref.current.innerHTML = '';
+ }
+ },
+ }), [ref]);
}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts
new file mode 100644
index 0000000000..fcd4471cb1
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts
@@ -0,0 +1,43 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { useCallback, useState } from "react";
+
+import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
+import { useRoomContext } from "../../../../../contexts/RoomContext";
+import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
+import { endEditing } from "../utils/editing";
+import { editMessage } from "../utils/message";
+
+export function useEditing(initialContent: string, editorStateTransfer: EditorStateTransfer) {
+ const roomContext = useRoomContext();
+ const mxClient = useMatrixClientContext();
+
+ const [isSaveDisabled, setIsSaveDisabled] = useState(true);
+ const [content, setContent] = useState(initialContent);
+ const onChange = useCallback((_content: string) => {
+ setContent(_content);
+ setIsSaveDisabled(_isSaveDisabled => _isSaveDisabled && _content === initialContent);
+ }, [initialContent]);
+
+ const editMessageMemoized = useCallback(() =>
+ editMessage(content, { roomContext, mxClient, editorStateTransfer }),
+ [content, roomContext, mxClient, editorStateTransfer],
+ );
+
+ const endEditingMemoized = useCallback(() => endEditing(roomContext), [roomContext]);
+ return { onChange, editMessage: editMessageMemoized, endEditing: endEditingMemoized, isSaveDisabled };
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts
new file mode 100644
index 0000000000..331ea1b6c3
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInitialContent.ts
@@ -0,0 +1,67 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
+import { useMemo } from "react";
+
+import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
+import { useRoomContext } from "../../../../../contexts/RoomContext";
+import { parseEvent } from "../../../../../editor/deserialize";
+import { CommandPartCreator, Part } from "../../../../../editor/parts";
+import SettingsStore from "../../../../../settings/SettingsStore";
+import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
+
+function parseEditorStateTransfer(
+ editorStateTransfer: EditorStateTransfer,
+ room: Room,
+ mxClient: MatrixClient,
+): string {
+ const partCreator = new CommandPartCreator(room, mxClient);
+
+ let parts: Part[];
+ if (editorStateTransfer.hasEditorState()) {
+ // if restoring state from a previous editor,
+ // restore serialized parts from the state
+ parts = editorStateTransfer.getSerializedParts().map(p => partCreator.deserializePart(p));
+ } else {
+ // otherwise, either restore serialized parts from localStorage or parse the body of the event
+ // TODO local storage
+ // const restoredParts = this.restoreStoredEditorState(partCreator);
+
+ if (editorStateTransfer.getEvent().getContent().format === 'org.matrix.custom.html') {
+ return editorStateTransfer.getEvent().getContent().formatted_body || "";
+ }
+
+ parts = parseEvent(editorStateTransfer.getEvent(), partCreator, {
+ shouldEscape: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
+ });
+ }
+
+ return parts.reduce((content, part) => content + part.text, '');
+ // Todo local storage
+ // this.saveStoredEditorState();
+}
+
+export function useInitialContent(editorStateTransfer: EditorStateTransfer) {
+ const roomContext = useRoomContext();
+ const mxClient = useMatrixClientContext();
+
+ return useMemo(() => {
+ if (editorStateTransfer && roomContext.room) {
+ return parseEditorStateTransfer(editorStateTransfer, roomContext.room, mxClient);
+ }
+ }, [editorStateTransfer, roomContext, mxClient]);
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts
new file mode 100644
index 0000000000..06839ab262
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts
@@ -0,0 +1,40 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { WysiwygInputEvent } from "@matrix-org/matrix-wysiwyg";
+import { useCallback } from "react";
+
+import { useSettingValue } from "../../../../../hooks/useSettings";
+
+export function useInputEventProcessor(onSend: () => void) {
+ const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend");
+ return useCallback((event: WysiwygInputEvent) => {
+ if (event instanceof ClipboardEvent) {
+ return event;
+ }
+
+ if (
+ (event.inputType === 'insertParagraph' && !isCtrlEnter) ||
+ event.inputType === 'sendMessage'
+ ) {
+ onSend();
+ return null;
+ }
+
+ return event;
+ }
+ , [isCtrlEnter, onSend]);
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts
new file mode 100644
index 0000000000..abf2a6a6d2
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextInitialization.ts
@@ -0,0 +1,25 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RefObject, useEffect } from "react";
+
+export function usePlainTextInitialization(initialContent: string, ref: RefObject) {
+ useEffect(() => {
+ if (ref.current) {
+ ref.current.innerText = initialContent;
+ }
+ }, [ref, initialContent]);
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts
new file mode 100644
index 0000000000..02063ddcfb
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/usePlainTextListeners.ts
@@ -0,0 +1,50 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { KeyboardEvent, SyntheticEvent, useCallback, useRef } from "react";
+
+import { useSettingValue } from "../../../../../hooks/useSettings";
+
+function isDivElement(target: EventTarget): target is HTMLDivElement {
+ return target instanceof HTMLDivElement;
+}
+
+export function usePlainTextListeners(onChange: (content: string) => void, onSend: () => void) {
+ const ref = useRef();
+ const send = useCallback((() => {
+ if (ref.current) {
+ ref.current.innerHTML = '';
+ }
+ onSend();
+ }), [ref, onSend]);
+
+ const onInput = useCallback((event: SyntheticEvent) => {
+ if (isDivElement(event.target)) {
+ onChange(event.target.innerHTML);
+ }
+ }, [onChange]);
+
+ const isCtrlEnter = useSettingValue("MessageComposerInput.ctrlEnterToSend");
+ const onKeyDown = useCallback((event: KeyboardEvent) => {
+ if (event.key === 'Enter' && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) {
+ event.preventDefault();
+ event.stopPropagation();
+ send();
+ }
+ }, [isCtrlEnter, send]);
+
+ return { ref, onInput, onPaste: onInput, onKeyDown };
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts
new file mode 100644
index 0000000000..ef14d44255
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSetCursorPosition.ts
@@ -0,0 +1,27 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RefObject, useEffect } from "react";
+
+import { setCursorPositionAtTheEnd } from "./utils";
+
+export function useSetCursorPosition(disabled: boolean, ref: RefObject) {
+ useEffect(() => {
+ if (ref.current && !disabled) {
+ setCursorPositionAtTheEnd(ref.current);
+ }
+ }, [ref, disabled]);
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts
new file mode 100644
index 0000000000..b39fe18007
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts
@@ -0,0 +1,48 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RefObject, useCallback, useRef } from "react";
+
+import defaultDispatcher from "../../../../../dispatcher/dispatcher";
+import { Action } from "../../../../../dispatcher/actions";
+import { ActionPayload } from "../../../../../dispatcher/payloads";
+import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext";
+import { useDispatcher } from "../../../../../hooks/useDispatcher";
+import { focusComposer } from "./utils";
+
+export function useWysiwygEditActionHandler(
+ disabled: boolean,
+ composerElement: RefObject,
+) {
+ const roomContext = useRoomContext();
+ const timeoutId = useRef();
+
+ const handler = useCallback((payload: ActionPayload) => {
+ // don't let the user into the composer if it is disabled - all of these branches lead
+ // to the cursor being in the composer
+ if (disabled || !composerElement.current) return;
+
+ const context = payload.context ?? TimelineRenderingType.Room;
+
+ switch (payload.action) {
+ case Action.FocusEditMessageComposer:
+ focusComposer(composerElement, context, roomContext, timeoutId);
+ break;
+ }
+ }, [disabled, composerElement, timeoutId, roomContext]);
+
+ useDispatcher(defaultDispatcher, handler);
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts
new file mode 100644
index 0000000000..49c6302d5b
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts
@@ -0,0 +1,56 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { RefObject, useCallback, useRef } from "react";
+
+import defaultDispatcher from "../../../../../dispatcher/dispatcher";
+import { Action } from "../../../../../dispatcher/actions";
+import { ActionPayload } from "../../../../../dispatcher/payloads";
+import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext";
+import { useDispatcher } from "../../../../../hooks/useDispatcher";
+import { focusComposer } from "./utils";
+import { ComposerFunctions } from "../types";
+
+export function useWysiwygSendActionHandler(
+ disabled: boolean,
+ composerElement: RefObject,
+ composerFunctions: ComposerFunctions,
+) {
+ const roomContext = useRoomContext();
+ const timeoutId = useRef();
+
+ const handler = useCallback((payload: ActionPayload) => {
+ // don't let the user into the composer if it is disabled - all of these branches lead
+ // to the cursor being in the composer
+ if (disabled || !composerElement.current) return;
+
+ const context = payload.context ?? TimelineRenderingType.Room;
+
+ switch (payload.action) {
+ case "reply_to_event":
+ case Action.FocusSendMessageComposer:
+ focusComposer(composerElement, context, roomContext, timeoutId);
+ break;
+ case Action.ClearAndFocusSendMessageComposer:
+ composerFunctions.clear();
+ focusComposer(composerElement, context, roomContext, timeoutId);
+ break;
+ // TODO: case Action.ComposerInsert: - see SendMessageComposer
+ }
+ }, [disabled, composerElement, composerFunctions, timeoutId, roomContext]);
+
+ useDispatcher(defaultDispatcher, handler);
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
similarity index 53%
rename from src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts
rename to src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
index 683498d485..bfaf526f72 100644
--- a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts
+++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts
@@ -14,40 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { useRef } from "react";
+import { TimelineRenderingType } from "../../../../../contexts/RoomContext";
+import { IRoomState } from "../../../../structures/RoomView";
-import defaultDispatcher from "../../../../dispatcher/dispatcher";
-import { Action } from "../../../../dispatcher/actions";
-import { ActionPayload } from "../../../../dispatcher/payloads";
-import { IRoomState } from "../../../structures/RoomView";
-import { TimelineRenderingType, useRoomContext } from "../../../../contexts/RoomContext";
-import { useDispatcher } from "../../../../hooks/useDispatcher";
-
-export function useWysiwygActionHandler(
- disabled: boolean,
- composerElement: React.MutableRefObject,
-) {
- const roomContext = useRoomContext();
- const timeoutId = useRef();
-
- useDispatcher(defaultDispatcher, (payload: ActionPayload) => {
- // don't let the user into the composer if it is disabled - all of these branches lead
- // to the cursor being in the composer
- if (disabled) return;
-
- const context = payload.context ?? TimelineRenderingType.Room;
-
- switch (payload.action) {
- case "reply_to_event":
- case Action.FocusSendMessageComposer:
- focusComposer(composerElement, context, roomContext, timeoutId);
- break;
- // TODO: case Action.ComposerInsert: - see SendMessageComposer
- }
- });
-}
-
-function focusComposer(
+export function focusComposer(
composerElement: React.MutableRefObject,
renderingType: TimelineRenderingType,
roomContext: IRoomState,
@@ -71,3 +41,14 @@ function focusComposer(
);
}
}
+
+export function setCursorPositionAtTheEnd(element: HTMLElement) {
+ const range = document.createRange();
+ range.selectNodeContents(element);
+ range.collapse(false);
+ const selection = document.getSelection();
+ selection.removeAllRanges();
+ selection.addRange(range);
+
+ element.focus();
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/index.ts b/src/components/views/rooms/wysiwyg_composer/index.ts
new file mode 100644
index 0000000000..ec8c9cff23
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/index.ts
@@ -0,0 +1,19 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export { SendWysiwygComposer } from './SendWysiwygComposer';
+export { EditWysiwygComposer } from './EditWysiwygComposer';
+export { sendMessage } from './utils/message';
diff --git a/src/components/views/rooms/wysiwyg_composer/types.ts b/src/components/views/rooms/wysiwyg_composer/types.ts
new file mode 100644
index 0000000000..96095abebf
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/types.ts
@@ -0,0 +1,19 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export type ComposerFunctions = {
+ clear: () => void;
+};
diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts
new file mode 100644
index 0000000000..6d8a9f218e
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts
@@ -0,0 +1,141 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IContent, IEventRelation, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
+
+import { htmlSerializeFromMdIfNeeded } from "../../../../../editor/serialize";
+import SettingsStore from "../../../../../settings/SettingsStore";
+import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
+import { addReplyToMessageContent } from "../../../../../utils/Reply";
+import { htmlToPlainText } from "../../../../../utils/room/htmlToPlaintext";
+
+// Merges favouring the given relation
+function attachRelation(content: IContent, relation?: IEventRelation): void {
+ if (relation) {
+ content['m.relates_to'] = {
+ ...(content['m.relates_to'] || {}),
+ ...relation,
+ };
+ }
+}
+
+function getHtmlReplyFallback(mxEvent: MatrixEvent): string {
+ const html = mxEvent.getContent().formatted_body;
+ if (!html) {
+ return "";
+ }
+ const rootNode = new DOMParser().parseFromString(html, "text/html").body;
+ const mxReply = rootNode.querySelector("mx-reply");
+ return (mxReply && mxReply.outerHTML) || "";
+}
+
+function getTextReplyFallback(mxEvent: MatrixEvent): string {
+ const body = mxEvent.getContent().body;
+ if (typeof body !== 'string') {
+ return "";
+ }
+ const lines = body.split("\n").map(l => l.trim());
+ if (lines.length > 2 && lines[0].startsWith("> ") && lines[1].length === 0) {
+ return `${lines[0]}\n\n`;
+ }
+ return "";
+}
+
+interface CreateMessageContentParams {
+ relation?: IEventRelation;
+ replyToEvent?: MatrixEvent;
+ permalinkCreator?: RoomPermalinkCreator;
+ includeReplyLegacyFallback?: boolean;
+ editedEvent?: MatrixEvent;
+}
+
+export function createMessageContent(
+ message: string,
+ isHTML: boolean,
+ { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true, editedEvent }:
+ CreateMessageContentParams,
+): IContent {
+ // TODO emote ?
+
+ const isEditing = Boolean(editedEvent);
+ const isReply = isEditing ? Boolean(editedEvent?.replyEventId) : Boolean(replyToEvent);
+ const isReplyAndEditing = isEditing && isReply;
+
+ /*const isEmote = containsEmote(model);
+ if (isEmote) {
+ model = stripEmoteCommand(model);
+ }
+ if (startsWith(model, "//")) {
+ model = stripPrefix(model, "/");
+ }
+ model = unescapeMessage(model);*/
+
+ // const body = textSerialize(model);
+
+ // TODO remove this ugly hack for replace br tag
+ const body = isHTML && htmlToPlainText(message) || message.replace(/ /g, '\n');
+ const bodyPrefix = isReplyAndEditing && getTextReplyFallback(editedEvent) || '';
+ const formattedBodyPrefix = isReplyAndEditing && getHtmlReplyFallback(editedEvent) || '';
+
+ const content: IContent = {
+ // TODO emote
+ msgtype: MsgType.Text,
+ // TODO when available, use HTML --> Plain text conversion from wysiwyg rust model
+ body: isEditing ? `${bodyPrefix} * ${body}` : body,
+ };
+
+ // TODO markdown support
+
+ const isMarkdownEnabled = SettingsStore.getValue("MessageComposerInput.useMarkdown");
+ const formattedBody =
+ isHTML ?
+ message :
+ isMarkdownEnabled ?
+ htmlSerializeFromMdIfNeeded(message, { forceHTML: isReply }) :
+ null;
+
+ if (formattedBody) {
+ content.format = "org.matrix.custom.html";
+ content.formatted_body = isEditing ? `${formattedBodyPrefix} * ${formattedBody}` : formattedBody;
+ }
+
+ if (isEditing) {
+ content['m.new_content'] = {
+ "msgtype": content.msgtype,
+ "body": body,
+ };
+
+ if (formattedBody) {
+ content['m.new_content'].format = "org.matrix.custom.html";
+ content['m.new_content']['formatted_body'] = formattedBody;
+ }
+ }
+
+ const newRelation = isEditing ?
+ { ...relation, 'rel_type': 'm.replace', 'event_id': editedEvent.getId() }
+ : relation;
+
+ attachRelation(content, newRelation);
+
+ if (!isEditing && replyToEvent && permalinkCreator) {
+ addReplyToMessageContent(content, replyToEvent, {
+ permalinkCreator,
+ includeLegacyFallback: includeReplyLegacyFallback,
+ });
+ }
+
+ return content;
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/utils/editing.ts b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts
new file mode 100644
index 0000000000..a0cb608383
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts
@@ -0,0 +1,50 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventStatus, MatrixClient } from "matrix-js-sdk/src/matrix";
+
+import { IRoomState } from "../../../../structures/RoomView";
+import dis from '../../../../../dispatcher/dispatcher';
+import { Action } from "../../../../../dispatcher/actions";
+import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
+
+export function endEditing(roomContext: IRoomState) {
+ // todo local storage
+ // localStorage.removeItem(this.editorRoomKey);
+ // localStorage.removeItem(this.editorStateKey);
+
+ // close the event editing and focus composer
+ dis.dispatch({
+ action: Action.EditEvent,
+ event: null,
+ timelineRenderingType: roomContext.timelineRenderingType,
+ });
+ dis.dispatch({
+ action: Action.FocusSendMessageComposer,
+ context: roomContext.timelineRenderingType,
+ });
+}
+
+export function cancelPreviousPendingEdit(mxClient: MatrixClient, editorStateTransfer: EditorStateTransfer) {
+ const originalEvent = editorStateTransfer.getEvent();
+ const previousEdit = originalEvent.replacingEvent();
+ if (previousEdit && (
+ previousEdit.status === EventStatus.QUEUED ||
+ previousEdit.status === EventStatus.NOT_SENT
+ )) {
+ mxClient.cancelPendingEvent(previousEdit);
+ }
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts b/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts
new file mode 100644
index 0000000000..88715dda38
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts
@@ -0,0 +1,30 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { IContent } from "matrix-js-sdk/src/matrix";
+
+import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
+
+export function isContentModified(newContent: IContent, editorStateTransfer: EditorStateTransfer): boolean {
+ // if nothing has changed then bail
+ const oldContent = editorStateTransfer.getEvent().getContent();
+ if (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] &&
+ oldContent["format"] === newContent["format"] &&
+ oldContent["formatted_body"] === newContent["formatted_body"]) {
+ return false;
+ }
+ return true;
+}
diff --git a/src/components/views/rooms/wysiwyg_composer/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts
similarity index 56%
rename from src/components/views/rooms/wysiwyg_composer/message.ts
rename to src/components/views/rooms/wysiwyg_composer/utils/message.ts
index 5569af02a9..d84392c18e 100644
--- a/src/components/views/rooms/wysiwyg_composer/message.ts
+++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts
@@ -16,93 +16,36 @@ limitations under the License.
import { Composer as ComposerEvent } from "@matrix-org/analytics-events/types/typescript/Composer";
import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/event';
-import { MatrixClient } from "matrix-js-sdk/src/matrix";
+import { ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
-import { PosthogAnalytics } from "../../../../PosthogAnalytics";
-import SettingsStore from "../../../../settings/SettingsStore";
-import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../sendTimePerformanceMetrics";
-import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks";
-import { doMaybeLocalRoomAction } from "../../../../utils/local-room";
-import { CHAT_EFFECTS } from "../../../../effects";
-import { containsEmoji } from "../../../../effects/utils";
-import { IRoomState } from "../../../structures/RoomView";
-import dis from '../../../../dispatcher/dispatcher';
-import { addReplyToMessageContent } from "../../../../utils/Reply";
-
-// Merges favouring the given relation
-function attachRelation(content: IContent, relation?: IEventRelation): void {
- if (relation) {
- content['m.relates_to'] = {
- ...(content['m.relates_to'] || {}),
- ...relation,
- };
- }
-}
+import { PosthogAnalytics } from "../../../../../PosthogAnalytics";
+import SettingsStore from "../../../../../settings/SettingsStore";
+import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../../sendTimePerformanceMetrics";
+import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks";
+import { doMaybeLocalRoomAction } from "../../../../../utils/local-room";
+import { CHAT_EFFECTS } from "../../../../../effects";
+import { containsEmoji } from "../../../../../effects/utils";
+import { IRoomState } from "../../../../structures/RoomView";
+import dis from '../../../../../dispatcher/dispatcher';
+import { createRedactEventDialog } from "../../../dialogs/ConfirmRedactDialog";
+import { endEditing, cancelPreviousPendingEdit } from "./editing";
+import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
+import { createMessageContent } from "./createMessageContent";
+import { isContentModified } from "./isContentModified";
interface SendMessageParams {
mxClient: MatrixClient;
relation?: IEventRelation;
replyToEvent?: MatrixEvent;
roomContext: IRoomState;
- permalinkCreator: RoomPermalinkCreator;
+ permalinkCreator?: RoomPermalinkCreator;
includeReplyLegacyFallback?: boolean;
}
-// exported for tests
-export function createMessageContent(
- message: string,
- { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true }:
- Omit,
-): IContent {
- // TODO emote ?
-
- /*const isEmote = containsEmote(model);
- if (isEmote) {
- model = stripEmoteCommand(model);
- }
- if (startsWith(model, "//")) {
- model = stripPrefix(model, "/");
- }
- model = unescapeMessage(model);*/
-
- // const body = textSerialize(model);
- const body = message;
-
- const content: IContent = {
- // TODO emote
- // msgtype: isEmote ? "m.emote" : "m.text",
- msgtype: "m.text",
- body: body,
- };
-
- // TODO markdown support
-
- /*const formattedBody = htmlSerializeIfNeeded(model, {
- forceHTML: !!replyToEvent,
- useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
- });*/
- const formattedBody = message;
-
- if (formattedBody) {
- content.format = "org.matrix.custom.html";
- content.formatted_body = formattedBody;
- }
-
- attachRelation(content, relation);
-
- if (replyToEvent) {
- addReplyToMessageContent(content, replyToEvent, {
- permalinkCreator,
- includeLegacyFallback: includeReplyLegacyFallback,
- });
- }
-
- return content;
-}
-
export function sendMessage(
message: string,
+ isHTML: boolean,
{ roomContext, mxClient, ...params }: SendMessageParams,
) {
const { relation, replyToEvent } = params;
@@ -113,6 +56,7 @@ export function sendMessage(
eventName: "Composer",
isEditing: false,
isReply: Boolean(replyToEvent),
+ // TODO thread
inThread: relation?.rel_type === THREAD_RELATION_TYPE.name,
};
@@ -134,6 +78,7 @@ export function sendMessage(
if (!content) {
content = createMessageContent(
message,
+ isHTML,
params,
);
}
@@ -197,3 +142,68 @@ export function sendMessage(
return prom;
}
+
+interface EditMessageParams {
+ mxClient: MatrixClient;
+ roomContext: IRoomState;
+ editorStateTransfer: EditorStateTransfer;
+}
+
+export function editMessage(
+ html: string,
+ { roomContext, mxClient, editorStateTransfer }: EditMessageParams,
+) {
+ const editedEvent = editorStateTransfer.getEvent();
+
+ PosthogAnalytics.instance.trackEvent({
+ eventName: "Composer",
+ isEditing: true,
+ inThread: Boolean(editedEvent?.getThread()),
+ isReply: Boolean(editedEvent.replyEventId),
+ });
+
+ // TODO emoji
+ // Replace emoticon at the end of the message
+ /* if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
+ const caret = this.editorRef.current?.getCaret();
+ const position = this.model.positionForOffset(caret.offset, caret.atNodeEnd);
+ this.editorRef.current?.replaceEmoticon(position, REGEX_EMOTICON);
+ }*/
+ const editContent = createMessageContent(html, true, { editedEvent });
+ const newContent = editContent["m.new_content"];
+
+ const shouldSend = true;
+
+ if (newContent?.body === '') {
+ cancelPreviousPendingEdit(mxClient, editorStateTransfer);
+ createRedactEventDialog({
+ mxEvent: editedEvent,
+ onCloseDialog: () => {
+ endEditing(roomContext);
+ },
+ });
+ return;
+ }
+
+ let response: Promise | undefined;
+
+ // If content is modified then send an updated event into the room
+ if (isContentModified(newContent, editorStateTransfer)) {
+ const roomId = editedEvent.getRoomId();
+
+ // TODO Slash Commands
+
+ if (shouldSend) {
+ cancelPreviousPendingEdit(mxClient, editorStateTransfer);
+
+ const event = editorStateTransfer.getEvent();
+ const threadId = event.threadRootId || null;
+
+ response = mxClient.sendMessage(roomId, threadId, editContent);
+ dis.dispatch({ action: "message_sent" });
+ }
+ }
+
+ endEditing(roomContext);
+ return response;
+}
diff --git a/src/components/views/settings/devices/DeviceDetailHeading.tsx b/src/components/views/settings/devices/DeviceDetailHeading.tsx
index 2673ef4e89..16c0eb8bad 100644
--- a/src/components/views/settings/devices/DeviceDetailHeading.tsx
+++ b/src/components/views/settings/devices/DeviceDetailHeading.tsx
@@ -19,6 +19,7 @@ import React, { FormEvent, useEffect, useState } from 'react';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import Field from '../../elements/Field';
+import LearnMore from '../../elements/LearnMore';
import Spinner from '../../elements/Spinner';
import { Caption } from '../../typography/Caption';
import Heading from '../../typography/Heading';
@@ -88,7 +89,22 @@ const DeviceNameEditor: React.FC void }> = ({
- { _t('Please be aware that session names are also visible to people you communicate with') }
+ { _t('Please be aware that session names are also visible to people you communicate with.') }
+
+
+ { _t(`Other users in direct messages and rooms that you join ` +
+ `are able to view a full list of your sessions.`,
+ ) }
+
+
+ { _t(`This provides them with confidence that they are really speaking to you, ` +
+ `but it also means they can see the session name you enter here.`,
+ ) }
+
+ >}
+ />
{ !!error &&
{
+ variation: DeviceSecurityVariation;
+}
+
+const securityCardContent: Record = {
+ [DeviceSecurityVariation.Verified]: {
+ title: _t('Verified sessions'),
+ description: <>
+ { _t('Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.') }
+
+
+ { _t(
+ `This means they hold encryption keys for your previous messages, ` +
+ `and confirm to other users you are communicating with that these sessions are really you.`,
+ )
+ }
+
+ >,
+ },
+ [DeviceSecurityVariation.Unverified]: {
+ title: _t('Unverified sessions'),
+ description: <>
+ { _t('Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.') }
+
+
+ { _t(
+ `You should make especially certain that you recognise these sessions ` +
+ `as they could represent an unauthorised use of your account.`,
+ )
+ }
+
+ >,
+ },
+ [DeviceSecurityVariation.Inactive]: {
+ title: _t('Inactive sessions'),
+ description: <>
+ { _t('Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.') }
+
+
+ { _t(
+ `Removing inactive sessions improves security and performance, ` +
+ `and makes it easier for you to identify if a new session is suspicious.`,
+ )
+ }
+
+ >,
+ },
+ };
+
+/**
+ * LearnMore with content for device security warnings
+ */
+export const DeviceSecurityLearnMore: React.FC = ({ variation }) => {
+ const { title, description } = securityCardContent[variation];
+ return ;
+};
diff --git a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx
index 127f5eedf6..0ee37c9bc4 100644
--- a/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx
+++ b/src/components/views/settings/devices/DeviceVerificationStatusCard.tsx
@@ -19,6 +19,7 @@ import React from 'react';
import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import DeviceSecurityCard from './DeviceSecurityCard';
+import { DeviceSecurityLearnMore } from './DeviceSecurityLearnMore';
import {
DeviceSecurityVariation,
ExtendedDevice,
@@ -36,11 +37,17 @@ export const DeviceVerificationStatusCard: React.FC = ({
const securityCardProps = device.isVerified ? {
variation: DeviceSecurityVariation.Verified,
heading: _t('Verified session'),
- description: _t('This session is ready for secure messaging.'),
+ description: <>
+ { _t('This session is ready for secure messaging.') }
+
+ >,
} : {
variation: DeviceSecurityVariation.Unverified,
heading: _t('Unverified session'),
- description: _t('Verify or sign out from this session for best security and reliability.'),
+ description: <>
+ { _t('Verify or sign out from this session for best security and reliability.') }
+
+ >,
};
return = {
+ [DeviceSecurityVariation.Verified]: {
+ title: _t('Verified sessions'),
+ description: _t('For best security, sign out from any session that you don\'t recognize or use anymore.'),
+ },
+ [DeviceSecurityVariation.Unverified]: {
+ title: _t('Unverified sessions'),
+ description: _t(
+ `Verify your sessions for enhanced secure messaging or ` +
+ `sign out from those you don't recognize or use anymore.`,
+ ),
+ },
+ [DeviceSecurityVariation.Inactive]: {
+ title: _t('Inactive sessions'),
+ description: _t(
+ `Consider signing out from old sessions ` +
+ `(%(inactiveAgeDays)s days or older) you don't use anymore.`,
+ { inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS },
+ ),
+ },
+ };
+
+const isSecurityVariation = (filter?: DeviceFilterKey): filter is DeviceSecurityVariation =>
+ Object.values(DeviceSecurityVariation).includes(filter);
+
const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => {
- switch (filter) {
- case DeviceSecurityVariation.Verified:
- return
-
-
- ;
- case DeviceSecurityVariation.Unverified:
- return
-
-
- ;
- case DeviceSecurityVariation.Inactive:
- return
-
-
- ;
- default:
- return null;
+ if (isSecurityVariation(filter)) {
+ const { title, description } = securityCardContent[filter];
+ return
+
+ { description }
+
+ }
+ />
+
+ ;
}
+
+ return null;
};
const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => {
diff --git a/src/components/views/settings/devices/SecurityRecommendations.tsx b/src/components/views/settings/devices/SecurityRecommendations.tsx
index ddeb2f2e2e..7b6381306b 100644
--- a/src/components/views/settings/devices/SecurityRecommendations.tsx
+++ b/src/components/views/settings/devices/SecurityRecommendations.tsx
@@ -20,6 +20,7 @@ import { _t } from '../../../../languageHandler';
import AccessibleButton from '../../elements/AccessibleButton';
import SettingsSubsection from '../shared/SettingsSubsection';
import DeviceSecurityCard from './DeviceSecurityCard';
+import { DeviceSecurityLearnMore } from './DeviceSecurityLearnMore';
import { filterDevicesBySecurityRecommendation, INACTIVE_DEVICE_AGE_DAYS } from './filter';
import {
DeviceSecurityVariation,
@@ -70,10 +71,13 @@ const SecurityRecommendations: React.FC = ({
+ { _t(
+ `Verify your sessions for enhanced secure messaging` +
` or sign out from those you don't recognize or use anymore.`,
- )}
+ ) }
+
+ >}
>
= ({
+ { _t(
+ `Consider signing out from old sessions ` +
+ `(%(inactiveAgeDays)s days or older) you don't use anymore`,
+ { inactiveAgeDays },
+ ) }
+
+ >
+ }
>
=> {
+ const { finished } = Modal.createDialog(QuestionDialog, {
+ title: _t("Sign out"),
+ description: (
+
+
{ _t("Are you sure you want to sign out of %(count)s sessions?", {
+ count: sessionsToSignOutCount,
+ }) }
+
+ ),
+ cancelButton: _t('Cancel'),
+ button: _t("Sign out"),
+ });
+ const [confirmed] = await finished;
+
+ return confirmed;
+};
const useSignOut = (
matrixClient: MatrixClient,
@@ -61,6 +80,11 @@ const useSignOut = (
if (!deviceIds.length) {
return;
}
+ const userConfirmedSignout = await confirmSignOut(deviceIds.length);
+ if (!userConfirmedSignout) {
+ return;
+ }
+
try {
setSigningOutDeviceIds([...signingOutDeviceIds, ...deviceIds]);
await deleteDevicesWithInteractiveAuth(
diff --git a/src/components/views/voip/CallView.tsx b/src/components/views/voip/CallView.tsx
index ea5d3c1e79..f003fdc6ca 100644
--- a/src/components/views/voip/CallView.tsx
+++ b/src/components/views/voip/CallView.tsx
@@ -33,7 +33,7 @@ import MatrixClientContext from "../../../contexts/MatrixClientContext";
import AppTile from "../elements/AppTile";
import { _t } from "../../../languageHandler";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
-import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler";
+import MediaDeviceHandler from "../../../MediaDeviceHandler";
import { CallStore } from "../../../stores/CallStore";
import IconizedContextMenu, {
IconizedContextMenuOption,
@@ -141,36 +141,38 @@ export const Lobby: FC = ({ room, joinCallButtonDisabled, joinCallBu
}, [videoMuted, setVideoMuted]);
const [videoStream, audioInputs, videoInputs] = useAsyncMemo(async () => {
- let previewStream: MediaStream;
+ let devices = await MediaDeviceHandler.getDevices();
+
+ // We get the preview stream before requesting devices: this is because
+ // we need (in some browsers) an active media stream in order to get
+ // non-blank labels for the devices.
+ let stream: MediaStream | null = null;
try {
- // We get the preview stream before requesting devices: this is because
- // we need (in some browsers) an active media stream in order to get
- // non-blank labels for the devices. According to the docs, we
- // need a stream of each type (audio + video) if we want to enumerate
- // audio & video devices, although this didn't seem to be the case
- // in practice for me. We request both anyway.
- // For similar reasons, we also request a stream even if video is muted,
- // which could be a bit strange but allows us to get the device list
- // reliably. One option could be to try & get devices without a stream,
- // then try again with a stream if we get blank deviceids, but... ew.
- previewStream = await navigator.mediaDevices.getUserMedia({
- video: { deviceId: videoInputId },
- audio: { deviceId: MediaDeviceHandler.getAudioInput() },
- });
+ if (devices.audioinput.length > 0) {
+ // Holding just an audio stream will be enough to get us all device labels, so
+ // if video is muted, don't bother requesting video.
+ stream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ video: !videoMuted && devices.videoinput.length > 0 && { deviceId: videoInputId },
+ });
+ } else if (devices.videoinput.length > 0) {
+ // We have to resort to a video stream, even if video is supposed to be muted.
+ stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId: videoInputId } });
+ }
} catch (e) {
logger.error(`Failed to get stream for device ${videoInputId}`, e);
}
- const devices = await MediaDeviceHandler.getDevices();
+ // Refresh the devices now that we hold a stream
+ if (stream !== null) devices = await MediaDeviceHandler.getDevices();
- // If video is muted, we don't actually want the stream, so we can get rid of
- // it now.
+ // If video is muted, we don't actually want the stream, so we can get rid of it now.
if (videoMuted) {
- previewStream.getTracks().forEach(t => t.stop());
- previewStream = undefined;
+ stream?.getTracks().forEach(t => t.stop());
+ stream = null;
}
- return [previewStream, devices[MediaDeviceKindEnum.AudioInput], devices[MediaDeviceKindEnum.VideoInput]];
+ return [stream, devices.audioinput, devices.videoinput];
}, [videoInputId, videoMuted], [null, [], []]);
const setAudioInput = useCallback((device: MediaDeviceInfo) => {
@@ -188,7 +190,7 @@ export const Lobby: FC = ({ room, joinCallButtonDisabled, joinCallBu
videoElement.play();
return () => {
- videoStream?.getTracks().forEach(track => track.stop());
+ videoStream.getTracks().forEach(track => track.stop());
videoElement.srcObject = null;
};
}
@@ -358,7 +360,7 @@ const JoinCallView: FC = ({ room, resizing, call }) => {
lobby =
{ facePile }
diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts
index 2b2e443e81..5a19e225de 100644
--- a/src/dispatcher/actions.ts
+++ b/src/dispatcher/actions.ts
@@ -75,6 +75,11 @@ export enum Action {
*/
FocusSendMessageComposer = "focus_send_message_composer",
+ /**
+ * Clear the to the send message composer. Should be used with a FocusComposerPayload.
+ */
+ ClearAndFocusSendMessageComposer = "clear_focus_send_message_composer",
+
/**
* Focuses the user's cursor to the edit message composer. Should be used with a FocusComposerPayload.
*/
@@ -111,6 +116,11 @@ export enum Action {
*/
ViewRoom = "view_room",
+ /**
+ * Changes thread based on payload parameters. Should be used with ThreadPayload.
+ */
+ ViewThread = "view_thread",
+
/**
* Changes room based on room list order and payload parameters. Should be used with ViewRoomDeltaPayload.
*/
diff --git a/src/dispatcher/payloads/ThreadPayload.ts b/src/dispatcher/payloads/ThreadPayload.ts
new file mode 100644
index 0000000000..653bbba3ae
--- /dev/null
+++ b/src/dispatcher/payloads/ThreadPayload.ts
@@ -0,0 +1,26 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { ActionPayload } from "../payloads";
+import { Action } from "../actions";
+
+/* eslint-disable camelcase */
+export interface ThreadPayload extends Pick {
+ action: Action.ViewThread;
+
+ thread_id: string | null;
+}
+/* eslint-enable camelcase */
diff --git a/src/hooks/useSlidingSyncRoomSearch.ts b/src/hooks/useSlidingSyncRoomSearch.ts
index 6ba08dc1a7..97e43d8b28 100644
--- a/src/hooks/useSlidingSyncRoomSearch.ts
+++ b/src/hooks/useSlidingSyncRoomSearch.ts
@@ -52,7 +52,6 @@ export const useSlidingSyncRoomSearch = () => {
ranges: [[0, limit]],
filters: {
room_name_like: term,
- is_tombstoned: false,
},
});
const rooms = [];
diff --git a/src/hooks/useUnreadNotifications.ts b/src/hooks/useUnreadNotifications.ts
new file mode 100644
index 0000000000..3262137274
--- /dev/null
+++ b/src/hooks/useUnreadNotifications.ts
@@ -0,0 +1,93 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { NotificationCount, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
+import { useCallback, useEffect, useState } from "react";
+
+import { getUnsentMessages } from "../components/structures/RoomStatusBar";
+import { getRoomNotifsState, getUnreadNotificationCount, RoomNotifState } from "../RoomNotifs";
+import { NotificationColor } from "../stores/notifications/NotificationColor";
+import { doesRoomHaveUnreadMessages } from "../Unread";
+import { EffectiveMembership, getEffectiveMembership } from "../utils/membership";
+import { useEventEmitter } from "./useEventEmitter";
+
+export const useUnreadNotifications = (room: Room, threadId?: string): {
+ symbol: string | null;
+ count: number;
+ color: NotificationColor;
+} => {
+ const [symbol, setSymbol] = useState(null);
+ const [count, setCount] = useState(0);
+ const [color, setColor] = useState(0);
+
+ useEventEmitter(room, RoomEvent.UnreadNotifications,
+ (unreadNotifications: NotificationCount, evtThreadId?: string) => {
+ // Discarding all events not related to the thread if one has been setup
+ if (threadId && threadId !== evtThreadId) return;
+ updateNotificationState();
+ },
+ );
+ useEventEmitter(room, RoomEvent.Receipt, () => updateNotificationState());
+ useEventEmitter(room, RoomEvent.Timeline, () => updateNotificationState());
+ useEventEmitter(room, RoomEvent.Redaction, () => updateNotificationState());
+ useEventEmitter(room, RoomEvent.LocalEchoUpdated, () => updateNotificationState());
+ useEventEmitter(room, RoomEvent.MyMembership, () => updateNotificationState());
+
+ const updateNotificationState = useCallback(() => {
+ if (getUnsentMessages(room, threadId).length > 0) {
+ setSymbol("!");
+ setCount(1);
+ setColor(NotificationColor.Unsent);
+ } else if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) {
+ setSymbol("!");
+ setCount(1);
+ setColor(NotificationColor.Red);
+ } else if (getRoomNotifsState(room.roomId) === RoomNotifState.Mute) {
+ setSymbol(null);
+ setCount(0);
+ setColor(NotificationColor.None);
+ } else {
+ const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId);
+ const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId);
+
+ const trueCount = greyNotifs || redNotifs;
+ setCount(trueCount);
+ setSymbol(null);
+ if (redNotifs > 0) {
+ setColor(NotificationColor.Red);
+ } else if (greyNotifs > 0) {
+ setColor(NotificationColor.Grey);
+ } else if (!threadId) {
+ // TODO: No support for `Bold` on threads at the moment
+
+ // We don't have any notified messages, but we might have unread messages. Let's
+ // find out.
+ const hasUnread = doesRoomHaveUnreadMessages(room);
+ setColor(hasUnread ? NotificationColor.Bold : NotificationColor.None);
+ }
+ }
+ }, [room, threadId]);
+
+ useEffect(() => {
+ updateNotificationState();
+ }, [updateNotificationState]);
+
+ return {
+ symbol,
+ count,
+ color,
+ };
+};
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json
index 807737bdda..81252fe433 100644
--- a/src/i18n/strings/en_EN.json
+++ b/src/i18n/strings/en_EN.json
@@ -1596,6 +1596,9 @@
"Sessions": "Sessions",
"Where you're signed in": "Where you're signed in",
"Manage your signed-in devices below. A device's name is visible to people you communicate with.": "Manage your signed-in devices below. A device's name is visible to people you communicate with.",
+ "Sign out": "Sign out",
+ "Are you sure you want to sign out of %(count)s sessions?|other": "Are you sure you want to sign out of %(count)s sessions?",
+ "Are you sure you want to sign out of %(count)s sessions?|one": "Are you sure you want to sign out of %(count)s session?",
"Other sessions": "Other sessions",
"For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.": "For best security, verify your sessions and sign out from any session that you don't recognize or use anymore.",
"Sidebar": "Sidebar",
@@ -1732,7 +1735,6 @@
"Please enter verification code sent via text.": "Please enter verification code sent via text.",
"Verification code": "Verification code",
"Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.",
- "Sign out": "Sign out",
"Sign out all other sessions": "Sign out all other sessions",
"Current session": "Current session",
"Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.",
@@ -1745,7 +1747,10 @@
"Sign out devices|one": "Sign out device",
"Authentication": "Authentication",
"Rename session": "Rename session",
- "Please be aware that session names are also visible to people you communicate with": "Please be aware that session names are also visible to people you communicate with",
+ "Please be aware that session names are also visible to people you communicate with.": "Please be aware that session names are also visible to people you communicate with.",
+ "Renaming sessions": "Renaming sessions",
+ "Other users in direct messages and rooms that you join are able to view a full list of your sessions.": "Other users in direct messages and rooms that you join are able to view a full list of your sessions.",
+ "This provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here.": "This provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here.",
"Session ID": "Session ID",
"Last activity": "Last activity",
"Application": "Application",
@@ -1762,6 +1767,15 @@
"Receive push notifications on this session.": "Receive push notifications on this session.",
"Sign out of this session": "Sign out of this session",
"Toggle device details": "Toggle device details",
+ "Verified sessions": "Verified sessions",
+ "Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.": "Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.",
+ "This means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.": "This means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.",
+ "Unverified sessions": "Unverified sessions",
+ "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.": "Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.",
+ "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.": "You should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.",
+ "Inactive sessions": "Inactive sessions",
+ "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.": "Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.",
+ "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.": "Removing inactive sessions improves security and performance, and makes it easier for you to identify if a new session is suspicious.",
"Inactive for %(inactiveAgeDays)s+ days": "Inactive for %(inactiveAgeDays)s+ days",
"Verified": "Verified",
"Unverified": "Unverified",
@@ -1774,12 +1788,9 @@
"Unverified session": "Unverified session",
"Verify or sign out from this session for best security and reliability.": "Verify or sign out from this session for best security and reliability.",
"Verify session": "Verify session",
- "Verified sessions": "Verified sessions",
"For best security, sign out from any session that you don't recognize or use anymore.": "For best security, sign out from any session that you don't recognize or use anymore.",
- "Unverified sessions": "Unverified sessions",
"Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.": "Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.",
- "Inactive sessions": "Inactive sessions",
- "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore",
+ "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore.": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore.",
"No verified sessions found.": "No verified sessions found.",
"No unverified sessions found.": "No unverified sessions found.",
"No inactive sessions found.": "No inactive sessions found.",
@@ -1799,6 +1810,7 @@
"Security recommendations": "Security recommendations",
"Improve your account security by following these recommendations": "Improve your account security by following these recommendations",
"View all": "View all",
+ "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore": "Consider signing out from old sessions (%(inactiveAgeDays)s days or older) you don't use anymore",
"Failed to set pusher state": "Failed to set pusher state",
"Unable to remove contact information": "Unable to remove contact information",
"Remove %(email)s?": "Remove %(email)s?",
@@ -1872,6 +1884,8 @@
"Voice Message": "Voice Message",
"You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.",
"Poll": "Poll",
+ "Show plain text": "Show plain text",
+ "Show formatting": "Show formatting",
"Bold": "Bold",
"Italics": "Italics",
"Strikethrough": "Strikethrough",
diff --git a/src/models/Call.ts b/src/models/Call.ts
index ed9e227d24..c3ef2e6775 100644
--- a/src/models/Call.ts
+++ b/src/models/Call.ts
@@ -816,7 +816,7 @@ export class ElementCall extends Call {
this.messaging!.on(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.on(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.on(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
- this.messaging!.on(`action:${ElementWidgetActions.Screenshare}`, this.onScreenshare);
+ this.messaging!.on(`action:${ElementWidgetActions.ScreenshareRequest}`, this.onScreenshareRequest);
}
protected async performDisconnection(): Promise {
@@ -832,7 +832,7 @@ export class ElementCall extends Call {
this.messaging!.off(`action:${ElementWidgetActions.HangupCall}`, this.onHangup);
this.messaging!.off(`action:${ElementWidgetActions.TileLayout}`, this.onTileLayout);
this.messaging!.off(`action:${ElementWidgetActions.SpotlightLayout}`, this.onSpotlightLayout);
- this.messaging!.off(`action:${ElementWidgetActions.Screenshare}`, this.onSpotlightLayout);
+ this.messaging!.off(`action:${ElementWidgetActions.ScreenshareRequest}`, this.onScreenshareRequest);
super.setDisconnected();
}
@@ -952,19 +952,24 @@ export class ElementCall extends Call {
await this.messaging!.transport.reply(ev.detail, {}); // ack
};
- private onScreenshare = async (ev: CustomEvent) => {
+ private onScreenshareRequest = async (ev: CustomEvent) => {
ev.preventDefault();
if (PlatformPeg.get().supportsDesktopCapturer()) {
+ await this.messaging!.transport.reply(ev.detail, { pending: true });
+
const { finished } = Modal.createDialog(DesktopCapturerSourcePicker);
const [source] = await finished;
- await this.messaging!.transport.reply(ev.detail, {
- failed: !source,
- desktopCapturerSourceId: source,
- });
+ if (source) {
+ await this.messaging!.transport.send(ElementWidgetActions.ScreenshareStart, {
+ desktopCapturerSourceId: source,
+ });
+ } else {
+ await this.messaging!.transport.send(ElementWidgetActions.ScreenshareStop, {});
+ }
} else {
- await this.messaging!.transport.reply(ev.detail, {});
+ await this.messaging!.transport.reply(ev.detail, { pending: false });
}
};
}
diff --git a/src/stores/RoomViewStore.tsx b/src/stores/RoomViewStore.tsx
index b3814f7a32..13c1e09c76 100644
--- a/src/stores/RoomViewStore.tsx
+++ b/src/stores/RoomViewStore.tsx
@@ -50,6 +50,7 @@ import { awaitRoomDownSync } from "../utils/RoomUpgrade";
import { UPDATE_EVENT } from "./AsyncStore";
import { SdkContextClass } from "../contexts/SDKContext";
import { CallStore } from "./CallStore";
+import { ThreadPayload } from "../dispatcher/payloads/ThreadPayload";
const NUM_JOIN_RETRY = 5;
@@ -66,6 +67,10 @@ interface State {
* The ID of the room currently being viewed
*/
roomId: string | null;
+ /**
+ * The ID of the thread currently being viewed
+ */
+ threadId: string | null;
/**
* The ID of the room being subscribed to (in Sliding Sync)
*/
@@ -109,6 +114,7 @@ const INITIAL_STATE: State = {
joining: false,
joinError: null,
roomId: null,
+ threadId: null,
subscribingRoomId: null,
initialEventId: null,
initialEventPixelOffset: null,
@@ -200,6 +206,9 @@ export class RoomViewStore extends EventEmitter {
case Action.ViewRoom:
this.viewRoom(payload);
break;
+ case Action.ViewThread:
+ this.viewThread(payload);
+ break;
// for these events blank out the roomId as we are no longer in the RoomView
case 'view_welcome_page':
case Action.ViewHomePage:
@@ -430,6 +439,12 @@ export class RoomViewStore extends EventEmitter {
}
}
+ private viewThread(payload: ThreadPayload): void {
+ this.setState({
+ threadId: payload.thread_id,
+ });
+ }
+
private viewRoomError(payload: ViewRoomErrorPayload): void {
this.setState({
roomId: payload.room_id,
@@ -550,6 +565,10 @@ export class RoomViewStore extends EventEmitter {
return this.state.roomId;
}
+ public getThreadId(): Optional {
+ return this.state.threadId;
+ }
+
// The event to scroll to when the room is first viewed
public getInitialEventId(): Optional {
return this.state.initialEventId;
diff --git a/src/stores/ThreepidInviteStore.ts b/src/stores/ThreepidInviteStore.ts
index 9b597ba877..fe2df8f66e 100644
--- a/src/stores/ThreepidInviteStore.ts
+++ b/src/stores/ThreepidInviteStore.ts
@@ -56,7 +56,7 @@ export interface IOOBData {
inviterName?: string; // The display name of the person who invited us to the room
// eslint-disable-next-line camelcase
room_name?: string; // The name of the room, to be used until we are told better by the server
- roomType?: RoomType; // The type of the room, to be used until we are told better by the server
+ roomType?: RoomType | string; // The type of the room, to be used until we are told better by the server
}
const STORAGE_PREFIX = "mx_threepid_invite_";
diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts
index 9c64b7ec42..92dc4e6a53 100644
--- a/src/stores/notifications/RoomNotificationState.ts
+++ b/src/stores/notifications/RoomNotificationState.ts
@@ -17,6 +17,7 @@ limitations under the License.
import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ClientEvent } from "matrix-js-sdk/src/client";
+import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { NotificationColor } from "./NotificationColor";
import { IDestroyable } from "../../utils/IDestroyable";
@@ -32,15 +33,19 @@ import { ThreadsRoomNotificationState } from "./ThreadsRoomNotificationState";
export class RoomNotificationState extends NotificationState implements IDestroyable {
constructor(public readonly room: Room, private readonly threadsState?: ThreadsRoomNotificationState) {
super();
- this.room.on(RoomEvent.Receipt, this.handleReadReceipt); // for unread indicators
- this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate); // for redness on invites
- this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated); // for redness on unsent messages
+ const cli = this.room.client;
+ this.room.on(RoomEvent.Receipt, this.handleReadReceipt);
+ this.room.on(RoomEvent.MyMembership, this.handleMembershipUpdate);
+ this.room.on(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
+ this.room.on(RoomEvent.Timeline, this.handleRoomEventUpdate);
+ this.room.on(RoomEvent.Redaction, this.handleRoomEventUpdate);
+
this.room.on(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate); // for server-sent counts
- if (threadsState) {
- threadsState.on(NotificationStateEvents.Update, this.handleThreadsUpdate);
+ if (cli.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) {
+ this.threadsState?.on(NotificationStateEvents.Update, this.handleThreadsUpdate);
}
- MatrixClientPeg.get().on(MatrixEventEvent.Decrypted, this.onEventDecrypted); // for local count calculation
- MatrixClientPeg.get().on(ClientEvent.AccountData, this.handleAccountDataUpdate); // for push rules
+ cli.on(MatrixEventEvent.Decrypted, this.onEventDecrypted);
+ cli.on(ClientEvent.AccountData, this.handleAccountDataUpdate);
this.updateNotificationState();
}
@@ -50,17 +55,19 @@ export class RoomNotificationState extends NotificationState implements IDestroy
public destroy(): void {
super.destroy();
+ const cli = this.room.client;
this.room.removeListener(RoomEvent.Receipt, this.handleReadReceipt);
this.room.removeListener(RoomEvent.MyMembership, this.handleMembershipUpdate);
this.room.removeListener(RoomEvent.LocalEchoUpdated, this.handleLocalEchoUpdated);
- this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate);
- if (this.threadsState) {
+ this.room.removeListener(RoomEvent.Timeline, this.handleRoomEventUpdate);
+ this.room.removeListener(RoomEvent.Redaction, this.handleRoomEventUpdate);
+ if (cli.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) {
+ this.room.removeListener(RoomEvent.UnreadNotifications, this.handleNotificationCountUpdate);
+ } else if (this.threadsState) {
this.threadsState.removeListener(NotificationStateEvents.Update, this.handleThreadsUpdate);
}
- if (MatrixClientPeg.get()) {
- MatrixClientPeg.get().removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
- MatrixClientPeg.get().removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate);
- }
+ cli.removeListener(MatrixEventEvent.Decrypted, this.onEventDecrypted);
+ cli.removeListener(ClientEvent.AccountData, this.handleAccountDataUpdate);
}
private handleThreadsUpdate = () => {
@@ -91,6 +98,11 @@ export class RoomNotificationState extends NotificationState implements IDestroy
this.updateNotificationState();
};
+ private handleRoomEventUpdate = (event: MatrixEvent, room: Room | null) => {
+ if (room?.roomId !== this.room.roomId) return; // ignore - not for us or notifications timeline
+ this.updateNotificationState();
+ };
+
private handleAccountDataUpdate = (ev: MatrixEvent) => {
if (ev.getType() === "m.push_rules") {
this.updateNotificationState();
diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts
index 48aa7e7c20..ad9bd9f98d 100644
--- a/src/stores/notifications/RoomNotificationStateStore.ts
+++ b/src/stores/notifications/RoomNotificationStateStore.ts
@@ -17,6 +17,7 @@ limitations under the License.
import { Room } from "matrix-js-sdk/src/models/room";
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
import { ClientEvent } from "matrix-js-sdk/src/client";
+import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { ActionPayload } from "../../dispatcher/payloads";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
@@ -39,9 +40,9 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient {
instance.start();
return instance;
})();
-
private roomMap = new Map();
- private roomThreadsMap = new Map();
+
+ private roomThreadsMap: Map = new Map();
private listMap = new Map();
private _globalState = new SummarizedNotificationState();
@@ -86,18 +87,25 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient {
*/
public getRoomState(room: Room): RoomNotificationState {
if (!this.roomMap.has(room)) {
- // Not very elegant, but that way we ensure that we start tracking
- // threads notification at the same time at rooms.
- // There are multiple entry points, and it's unclear which one gets
- // called first
- const threadState = new ThreadsRoomNotificationState(room);
- this.roomThreadsMap.set(room, threadState);
+ let threadState;
+ if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) {
+ // Not very elegant, but that way we ensure that we start tracking
+ // threads notification at the same time at rooms.
+ // There are multiple entry points, and it's unclear which one gets
+ // called first
+ const threadState = new ThreadsRoomNotificationState(room);
+ this.roomThreadsMap.set(room, threadState);
+ }
this.roomMap.set(room, new RoomNotificationState(room, threadState));
}
return this.roomMap.get(room);
}
- public getThreadsRoomState(room: Room): ThreadsRoomNotificationState {
+ public getThreadsRoomState(room: Room): ThreadsRoomNotificationState | null {
+ if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) {
+ return null;
+ }
+
if (!this.roomThreadsMap.has(room)) {
this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room));
}
diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts
index d6f9de79c3..73d6bdbd51 100644
--- a/src/stores/room-list/RoomListStore.ts
+++ b/src/stores/room-list/RoomListStore.ts
@@ -24,7 +24,7 @@ import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
import { IListOrderingMap, ITagMap, ITagSortingMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { ActionPayload } from "../../dispatcher/payloads";
-import defaultDispatcher from "../../dispatcher/dispatcher";
+import defaultDispatcher, { MatrixDispatcher } from "../../dispatcher/dispatcher";
import { readReceiptChangeIsFor } from "../../utils/read-receipts";
import { FILTER_CHANGED, IFilterCondition } from "./filters/IFilterCondition";
import { Algorithm, LIST_UPDATED_EVENT } from "./algorithms/Algorithm";
@@ -65,8 +65,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements
this.emit(LISTS_UPDATE_EVENT);
});
- constructor() {
- super(defaultDispatcher);
+ constructor(dis: MatrixDispatcher) {
+ super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
this.algorithm.start();
}
@@ -613,11 +613,11 @@ export default class RoomListStore {
if (!RoomListStore.internalInstance) {
if (SettingsStore.getValue("feature_sliding_sync")) {
logger.info("using SlidingRoomListStoreClass");
- const instance = new SlidingRoomListStoreClass();
+ const instance = new SlidingRoomListStoreClass(defaultDispatcher, SdkContextClass.instance);
instance.start();
RoomListStore.internalInstance = instance;
} else {
- const instance = new RoomListStoreClass();
+ const instance = new RoomListStoreClass(defaultDispatcher);
instance.start();
RoomListStore.internalInstance = instance;
}
diff --git a/src/stores/room-list/SlidingRoomListStore.ts b/src/stores/room-list/SlidingRoomListStore.ts
index 35550d04f1..1c5fd1adea 100644
--- a/src/stores/room-list/SlidingRoomListStore.ts
+++ b/src/stores/room-list/SlidingRoomListStore.ts
@@ -21,12 +21,10 @@ import { MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync"
import { RoomUpdateCause, TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models";
import { ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { ActionPayload } from "../../dispatcher/payloads";
-import defaultDispatcher from "../../dispatcher/dispatcher";
+import { MatrixDispatcher } from "../../dispatcher/dispatcher";
import { IFilterCondition } from "./filters/IFilterCondition";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
-import { SlidingSyncManager } from "../../SlidingSyncManager";
-import SpaceStore from "../spaces/SpaceStore";
import { MetaSpace, SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces";
import { LISTS_LOADING_EVENT } from "./RoomListStore";
import { UPDATE_EVENT } from "../AsyncStore";
@@ -38,7 +36,7 @@ interface IState {
export const SlidingSyncSortToFilter: Record = {
[SortAlgorithm.Alphabetic]: ["by_name", "by_recency"],
- [SortAlgorithm.Recent]: ["by_highlight_count", "by_notification_count", "by_recency"],
+ [SortAlgorithm.Recent]: ["by_notification_level", "by_recency"],
[SortAlgorithm.Manual]: ["by_recency"],
};
@@ -48,21 +46,18 @@ const filterConditions: Record = {
},
[DefaultTagID.Favourite]: {
tags: ["m.favourite"],
- is_tombstoned: false,
},
// TODO https://github.com/vector-im/element-web/issues/23207
// DefaultTagID.SavedItems,
[DefaultTagID.DM]: {
is_dm: true,
is_invite: false,
- is_tombstoned: false,
// If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead
not_tags: ["m.favourite", "m.lowpriority"],
},
[DefaultTagID.Untagged]: {
is_dm: false,
is_invite: false,
- is_tombstoned: false,
not_room_types: ["m.space"],
not_tags: ["m.favourite", "m.lowpriority"],
// spaces filter added dynamically
@@ -71,7 +66,6 @@ const filterConditions: Record = {
tags: ["m.lowpriority"],
// If a room has both Favourite & Low Prio tags then it'll be shown under Favourites
not_tags: ["m.favourite"],
- is_tombstoned: false,
},
// TODO https://github.com/vector-im/element-web/issues/23207
// DefaultTagID.ServerNotice,
@@ -87,25 +81,25 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
private counts: Record = {};
private stickyRoomId: string | null;
- public constructor() {
- super(defaultDispatcher);
+ public constructor(dis: MatrixDispatcher, private readonly context: SdkContextClass) {
+ super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
}
public async setTagSorting(tagId: TagID, sort: SortAlgorithm) {
logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort);
this.tagIdToSortAlgo[tagId] = sort;
- const slidingSyncIndex = SlidingSyncManager.instance.getOrAllocateListIndex(tagId);
+ const slidingSyncIndex = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
switch (sort) {
case SortAlgorithm.Alphabetic:
- await SlidingSyncManager.instance.ensureListRegistered(
+ await this.context.slidingSyncManager.ensureListRegistered(
slidingSyncIndex, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
},
);
break;
case SortAlgorithm.Recent:
- await SlidingSyncManager.instance.ensureListRegistered(
+ await this.context.slidingSyncManager.ensureListRegistered(
slidingSyncIndex, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
},
@@ -174,10 +168,13 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
// check all lists for each tag we know about and see if the room is there
const tags: TagID[] = [];
for (const tagId in this.tagIdToSortAlgo) {
- const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId);
- const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index);
- for (const roomIndex in roomIndexToRoomId) {
- const roomId = roomIndexToRoomId[roomIndex];
+ const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
+ const listData = this.context.slidingSyncManager.slidingSync.getListData(index);
+ if (!listData) {
+ continue;
+ }
+ for (const roomIndex in listData.roomIndexToRoomId) {
+ const roomId = listData.roomIndexToRoomId[roomIndex];
if (roomId === room.roomId) {
tags.push(tagId);
break;
@@ -207,7 +204,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
// this room will not move due to it being viewed: it is sticky. This can be null to indicate
// no sticky room if you aren't viewing a room.
- this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
+ this.stickyRoomId = this.context.roomViewStore.getRoomId();
let stickyRoomNewIndex = -1;
const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room) => {
return room.roomId === this.stickyRoomId;
@@ -264,7 +261,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
}
private onSlidingSyncListUpdate(listIndex: number, joinCount: number, roomIndexToRoomId: Record) {
- const tagId = SlidingSyncManager.instance.listIdForIndex(listIndex);
+ const tagId = this.context.slidingSyncManager.listIdForIndex(listIndex);
this.counts[tagId]= joinCount;
this.refreshOrderedLists(tagId, roomIndexToRoomId);
// let the UI update
@@ -273,7 +270,7 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
private onRoomViewStoreUpdated() {
// we only care about this to know when the user has clicked on a room to set the stickiness value
- if (SdkContextClass.instance.roomViewStore.getRoomId() === this.stickyRoomId) {
+ if (this.context.roomViewStore.getRoomId() === this.stickyRoomId) {
return;
}
@@ -296,14 +293,17 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
if (room) {
// resort it based on the slidingSync view of the list. This may cause this old sticky
// room to cease to exist.
- const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId);
- const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync.getListData(index);
- this.refreshOrderedLists(tagId, roomIndexToRoomId);
+ const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
+ const listData = this.context.slidingSyncManager.slidingSync.getListData(index);
+ if (!listData) {
+ continue;
+ }
+ this.refreshOrderedLists(tagId, listData.roomIndexToRoomId);
hasUpdatedAnyList = true;
}
}
// in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID.
- this.stickyRoomId = SdkContextClass.instance.roomViewStore.getRoomId();
+ this.stickyRoomId = this.context.roomViewStore.getRoomId();
if (hasUpdatedAnyList) {
this.emit(LISTS_UPDATE_EVENT);
@@ -313,11 +313,11 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
protected async onReady(): Promise {
logger.info("SlidingRoomListStore.onReady");
// permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation.
- SlidingSyncManager.instance.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this));
- SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this));
- SpaceStore.instance.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this));
- if (SpaceStore.instance.activeSpace) {
- this.onSelectedSpaceUpdated(SpaceStore.instance.activeSpace, false);
+ this.context.slidingSyncManager.slidingSync.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this));
+ this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this));
+ this.context.spaceStore.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this));
+ if (this.context.spaceStore.activeSpace) {
+ this.onSelectedSpaceUpdated(this.context.spaceStore.activeSpace, false);
}
// sliding sync has an initial response for spaces. Now request all the lists.
@@ -332,8 +332,8 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config
this.tagIdToSortAlgo[tagId] = sort;
this.emit(LISTS_LOADING_EVENT, tagId, true);
- const index = SlidingSyncManager.instance.getOrAllocateListIndex(tagId);
- SlidingSyncManager.instance.ensureListRegistered(index, {
+ const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
+ this.context.slidingSyncManager.ensureListRegistered(index, {
filters: filter,
sort: SlidingSyncSortToFilter[sort],
}).then(() => {
@@ -350,9 +350,18 @@ export class SlidingRoomListStoreClass extends AsyncStoreWithClient impl
const oldSpace = filters.spaces?.[0];
filters.spaces = (activeSpace && activeSpace != MetaSpace.Home) ? [activeSpace] : undefined;
if (oldSpace !== activeSpace) {
+ // include subspaces in this list
+ this.context.spaceStore.traverseSpace(activeSpace, (roomId: string) => {
+ if (roomId === activeSpace) {
+ return;
+ }
+ filters.spaces.push(roomId); // add subspace
+ }, false);
+
this.emit(LISTS_LOADING_EVENT, tagId, true);
- SlidingSyncManager.instance.ensureListRegistered(
- SlidingSyncManager.instance.getOrAllocateListIndex(tagId),
+ const index = this.context.slidingSyncManager.getOrAllocateListIndex(tagId);
+ this.context.slidingSyncManager.ensureListRegistered(
+ index,
{
filters: filters,
},
diff --git a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
index 9466a35940..1066ee38a1 100644
--- a/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
+++ b/src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm.ts
@@ -73,7 +73,7 @@ export const sortRooms = (rooms: Room[]): Room[] => {
};
const getLastTs = (r: Room, userId: string) => {
- const ts = (() => {
+ const mainTimelineLastTs = (() => {
// Apparently we can have rooms without timelines, at least under testing
// environments. Just return MAX_INT when this happens.
if (!r?.timeline) {
@@ -108,7 +108,13 @@ const getLastTs = (r: Room, userId: string) => {
// This is better than just assuming the last event was forever ago.
return r.timeline[0]?.getTs() ?? Number.MAX_SAFE_INTEGER;
})();
- return ts;
+
+ const threadLastEventTimestamps = r.getThreads().map(thread => {
+ const event = thread.replyToEvent ?? thread.rootEvent;
+ return event?.getTs() ?? 0;
+ });
+
+ return Math.max(mainTimelineLastTs, ...threadLastEventTimestamps);
};
/**
diff --git a/src/stores/widgets/ElementWidgetActions.ts b/src/stores/widgets/ElementWidgetActions.ts
index fa60b9ea82..1d0437a2ce 100644
--- a/src/stores/widgets/ElementWidgetActions.ts
+++ b/src/stores/widgets/ElementWidgetActions.ts
@@ -26,10 +26,23 @@ export enum ElementWidgetActions {
MuteVideo = "io.element.mute_video",
UnmuteVideo = "io.element.unmute_video",
StartLiveStream = "im.vector.start_live_stream",
+
+ // Element Call -> host requesting to start a screenshare
+ // (ie. expects a ScreenshareStart once the user has picked a source)
+ // replies with { pending } where pending is true if the host has asked
+ // the user to choose a window and false if not (ie. if the host isn't
+ // running within Electron)
+ ScreenshareRequest = "io.element.screenshare_request",
+ // host -> Element Call telling EC to start screen sharing with
+ // the given source
+ ScreenshareStart = "io.element.screenshare_start",
+ // host -> Element Call telling EC to stop screen sharing, or that
+ // the user cancelled when selecting a source after a ScreenshareRequest
+ ScreenshareStop = "io.element.screenshare_stop",
+
// Actions for switching layouts
TileLayout = "io.element.tile_layout",
SpotlightLayout = "io.element.spotlight_layout",
- Screenshare = "io.element.screenshare",
OpenIntegrationManager = "integration_manager_open",
diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts
index aa1ad2c393..59653bf384 100644
--- a/src/stores/widgets/StopGapWidget.ts
+++ b/src/stores/widgets/StopGapWidget.ts
@@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room";
import {
ClientWidgetApi,
IModalWidgetOpenRequest,
+ IRoomEvent,
IStickerActionRequest,
IStickyActionRequest,
ITemplateParams,
@@ -465,7 +466,7 @@ export class StopGapWidget extends EventEmitter {
private onToDeviceEvent = async (ev: MatrixEvent) => {
await this.client.decryptEventIfNeeded(ev);
if (ev.isDecryptionFailure()) return;
- await this.messaging.feedToDevice(ev.getEffectiveEvent(), ev.isEncrypted());
+ await this.messaging.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted());
};
private feedEvent(ev: MatrixEvent) {
@@ -509,7 +510,7 @@ export class StopGapWidget extends EventEmitter {
this.readUpToMap[ev.getRoomId()] = ev.getId();
const raw = ev.getEffectiveEvent();
- this.messaging.feedEvent(raw, this.eventListenerRoomId).catch(e => {
+ this.messaging.feedEvent(raw as IRoomEvent, this.eventListenerRoomId).catch(e => {
logger.error("Error sending event to widget: ", e);
});
}
diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts
index ff2619ad59..7bc85e02df 100644
--- a/src/stores/widgets/StopGapWidgetDriver.ts
+++ b/src/stores/widgets/StopGapWidgetDriver.ts
@@ -34,7 +34,7 @@ import {
} from "matrix-widget-api";
import { ClientEvent, ITurnServer as IClientTurnServer } from "matrix-js-sdk/src/client";
import { EventType } from "matrix-js-sdk/src/@types/event";
-import { IContent, IEvent, MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { IContent, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
@@ -113,6 +113,12 @@ export class StopGapWidgetDriver extends WidgetDriver {
this.allowedCapabilities.add(MatrixCapabilities.MSC3846TurnServers);
this.allowedCapabilities.add(`org.matrix.msc2762.timeline:${inRoomId}`);
+ this.allowedCapabilities.add(
+ WidgetEventCapability.forRoomEvent(EventDirection.Send, "org.matrix.rageshake_request").raw,
+ );
+ this.allowedCapabilities.add(
+ WidgetEventCapability.forRoomEvent(EventDirection.Receive, "org.matrix.rageshake_request").raw,
+ );
this.allowedCapabilities.add(
WidgetEventCapability.forStateEvent(EventDirection.Receive, EventType.RoomMember).raw,
);
@@ -304,7 +310,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const rooms = this.pickRooms(roomIds);
- const allResults: IEvent[] = [];
+ const allResults: IRoomEvent[] = [];
for (const room of rooms) {
const results: MatrixEvent[] = [];
const events = room.getLiveTimeline().getEvents(); // timelines are most recent last
@@ -317,7 +323,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
results.push(ev);
}
- results.forEach(e => allResults.push(e.getEffectiveEvent()));
+ results.forEach(e => allResults.push(e.getEffectiveEvent() as IRoomEvent));
}
return allResults;
}
@@ -331,7 +337,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
limitPerRoom = limitPerRoom > 0 ? Math.min(limitPerRoom, Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER; // relatively arbitrary
const rooms = this.pickRooms(roomIds);
- const allResults: IEvent[] = [];
+ const allResults: IRoomEvent[] = [];
for (const room of rooms) {
const results: MatrixEvent[] = [];
const state: Map = room.currentState.events.get(eventType);
@@ -344,7 +350,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
}
}
- results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent()));
+ results.slice(0, limitPerRoom).forEach(e => allResults.push(e.getEffectiveEvent() as IRoomEvent));
}
return allResults;
}
@@ -445,15 +451,11 @@ export class StopGapWidgetDriver extends WidgetDriver {
eventId,
relationType ?? null,
eventType ?? null,
- {
- from,
- to,
- limit,
- dir,
- });
+ { from, to, limit, dir },
+ );
return {
- chunk: events.map(e => e.getEffectiveEvent()),
+ chunk: events.map(e => e.getEffectiveEvent() as IRoomEvent),
nextBatch,
prevBatch,
};
diff --git a/src/utils/EventUtils.ts b/src/utils/EventUtils.ts
index d8cf66e557..69e322ac6d 100644
--- a/src/utils/EventUtils.ts
+++ b/src/utils/EventUtils.ts
@@ -238,8 +238,11 @@ export async function fetchInitialEvent(
) {
const threadId = initialEvent.threadRootId;
const room = client.getRoom(roomId);
+ const mapper = client.getEventMapper();
+ const rootEvent = room.findEventById(threadId)
+ ?? mapper(await client.fetchRoomEvent(roomId, threadId));
try {
- room.createThread(threadId, room.findEventById(threadId), [initialEvent], true);
+ room.createThread(threadId, rootEvent, [initialEvent], true);
} catch (e) {
logger.warn("Could not find root event: " + threadId);
}
diff --git a/src/utils/room/htmlToPlaintext.ts b/src/utils/room/htmlToPlaintext.ts
new file mode 100644
index 0000000000..883db8d360
--- /dev/null
+++ b/src/utils/room/htmlToPlaintext.ts
@@ -0,0 +1,19 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+export function htmlToPlainText(html: string) {
+ return new DOMParser().parseFromString(html, 'text/html').documentElement.textContent;
+}
diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts
index ff1d22a41c..df7ae362d9 100644
--- a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts
+++ b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts
@@ -139,5 +139,7 @@ export class VoiceBroadcastRecorder
}
export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => {
- return new VoiceBroadcastRecorder(new VoiceRecording(), getChunkLength());
+ const voiceRecording = new VoiceRecording();
+ voiceRecording.disableMaxLength();
+ return new VoiceBroadcastRecorder(voiceRecording, getChunkLength());
};
diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx
index e0634636a7..e6f2e343cb 100644
--- a/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx
+++ b/src/voice-broadcast/components/molecules/VoiceBroadcastPlaybackBody.tsx
@@ -74,14 +74,14 @@ export const VoiceBroadcastPlaybackBody: React.FC
+
-
diff --git a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx
index b9721170eb..1b13377da9 100644
--- a/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx
+++ b/src/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody.tsx
@@ -27,7 +27,7 @@ export const VoiceBroadcastRecordingBody: React.FC
+
;
return
-
-
+
+
{ toggleControl }
void;
- [VoiceBroadcastPlaybackEvent.StateChanged]: (state: VoiceBroadcastPlaybackState) => void;
+ [VoiceBroadcastPlaybackEvent.StateChanged]: (
+ state: VoiceBroadcastPlaybackState,
+ playback: VoiceBroadcastPlayback
+ ) => void;
[VoiceBroadcastPlaybackEvent.InfoStateChanged]: (state: VoiceBroadcastInfoState) => void;
}
@@ -217,14 +220,20 @@ export class VoiceBroadcastPlayback
}
public pause(): void {
- if (!this.currentlyPlaying) return;
+ // stopped voice broadcasts cannot be paused
+ if (this.getState() === VoiceBroadcastPlaybackState.Stopped) return;
this.setState(VoiceBroadcastPlaybackState.Paused);
+ if (!this.currentlyPlaying) return;
this.currentlyPlaying.pause();
}
public resume(): void {
- if (!this.currentlyPlaying) return;
+ if (!this.currentlyPlaying) {
+ // no playback to resume, start from the beginning
+ this.start();
+ return;
+ }
this.setState(VoiceBroadcastPlaybackState.Playing);
this.currentlyPlaying.play();
@@ -260,7 +269,7 @@ export class VoiceBroadcastPlayback
}
this.state = state;
- this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state);
+ this.emit(VoiceBroadcastPlaybackEvent.StateChanged, state, this);
}
public getInfoState(): VoiceBroadcastInfoState {
diff --git a/src/voice-broadcast/models/VoiceBroadcastRecording.ts b/src/voice-broadcast/models/VoiceBroadcastRecording.ts
index 28cdd72301..dbab9fb6b8 100644
--- a/src/voice-broadcast/models/VoiceBroadcastRecording.ts
+++ b/src/voice-broadcast/models/VoiceBroadcastRecording.ts
@@ -21,6 +21,7 @@ import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"
import {
ChunkRecordedPayload,
createVoiceBroadcastRecorder,
+ VoiceBroadcastInfoEventContent,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
VoiceBroadcastRecorder,
@@ -105,15 +106,15 @@ export class VoiceBroadcastRecording
public async resume(): Promise {
if (this.state !== VoiceBroadcastInfoState.Paused) return;
- this.setState(VoiceBroadcastInfoState.Running);
+ this.setState(VoiceBroadcastInfoState.Resumed);
await this.getRecorder().start();
- await this.sendInfoStateEvent(VoiceBroadcastInfoState.Running);
+ await this.sendInfoStateEvent(VoiceBroadcastInfoState.Resumed);
}
public toggle = async (): Promise => {
if (this.getState() === VoiceBroadcastInfoState.Paused) return this.resume();
- if ([VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Running].includes(this.getState())) {
+ if ([VoiceBroadcastInfoState.Started, VoiceBroadcastInfoState.Resumed].includes(this.getState())) {
return this.pause();
}
};
@@ -207,11 +208,12 @@ export class VoiceBroadcastRecording
{
device_id: this.client.getDeviceId(),
state,
+ last_chunk_sequence: this.sequence,
["m.relates_to"]: {
rel_type: RelationType.Reference,
event_id: this.infoEvent.getId(),
},
- },
+ } as VoiceBroadcastInfoEventContent,
this.client.getUserId(),
);
}
diff --git a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts
index 38d774e088..03378d9492 100644
--- a/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts
+++ b/src/voice-broadcast/stores/VoiceBroadcastPlaybacksStore.ts
@@ -17,7 +17,8 @@ limitations under the License.
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
-import { VoiceBroadcastPlayback } from "..";
+import { VoiceBroadcastPlayback, VoiceBroadcastPlaybackEvent, VoiceBroadcastPlaybackState } from "..";
+import { IDestroyable } from "../../utils/IDestroyable";
export enum VoiceBroadcastPlaybacksStoreEvent {
CurrentChanged = "current_changed",
@@ -28,10 +29,16 @@ interface EventMap {
}
/**
- * This store provides access to the current and specific Voice Broadcast playbacks.
+ * This store manages VoiceBroadcastPlaybacks:
+ * - access the currently playing voice broadcast
+ * - ensures that only once broadcast is playing at a time
*/
-export class VoiceBroadcastPlaybacksStore extends TypedEventEmitter {
+export class VoiceBroadcastPlaybacksStore
+ extends TypedEventEmitter
+ implements IDestroyable {
private current: VoiceBroadcastPlayback | null;
+
+ /** Playbacks indexed by their info event id. */
private playbacks = new Map();
public constructor() {
@@ -42,7 +49,7 @@ export class VoiceBroadcastPlaybacksStore extends TypedEventEmitter {
+ if ([
+ VoiceBroadcastPlaybackState.Buffering,
+ VoiceBroadcastPlaybackState.Playing,
+ ].includes(state)) {
+ this.pauseExcept(playback);
+ }
+ };
+
+ private pauseExcept(playbackNotToPause: VoiceBroadcastPlayback): void {
+ for (const playback of this.playbacks.values()) {
+ if (playback !== playbackNotToPause) {
+ playback.pause();
+ }
+ }
+ }
+
+ public destroy(): void {
+ this.removeAllListeners();
+
+ for (const playback of this.playbacks.values()) {
+ playback.off(VoiceBroadcastPlaybackEvent.StateChanged, this.onPlaybackStateChanged);
+ }
+
+ this.playbacks = new Map();
+ }
+
public static readonly _instance = new VoiceBroadcastPlaybacksStore();
/**
diff --git a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts
index c8b3407451..be949d0eab 100644
--- a/src/voice-broadcast/utils/VoiceBroadcastResumer.ts
+++ b/src/voice-broadcast/utils/VoiceBroadcastResumer.ts
@@ -14,43 +14,87 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { ClientEvent, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
+import { ClientEvent, MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
+import { SyncState } from "matrix-js-sdk/src/sync";
+import { VoiceBroadcastInfoEventContent, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "..";
import { IDestroyable } from "../../utils/IDestroyable";
import { findRoomLiveVoiceBroadcastFromUserAndDevice } from "./findRoomLiveVoiceBroadcastFromUserAndDevice";
-import { resumeVoiceBroadcastInRoom } from "./resumeVoiceBroadcastInRoom";
+/**
+ * Handles voice broadcasts on app resume (after logging in, reload, crash…).
+ */
export class VoiceBroadcastResumer implements IDestroyable {
- private seenRooms = new Set();
- private userId: string;
- private deviceId: string;
-
public constructor(
private client: MatrixClient,
) {
- this.client.on(ClientEvent.Room, this.onRoom);
- this.userId = this.client.getUserId();
- this.deviceId = this.client.getDeviceId();
+ if (client.isInitialSyncComplete()) {
+ this.resume();
+ } else {
+ // wait for initial sync
+ client.on(ClientEvent.Sync, this.onClientSync);
+ }
}
- private onRoom = (room: Room): void => {
- if (this.seenRooms.has(room.roomId)) return;
-
- this.seenRooms.add(room.roomId);
-
- const infoEvent = findRoomLiveVoiceBroadcastFromUserAndDevice(
- room,
- this.userId,
- this.deviceId,
- );
-
- if (infoEvent) {
- resumeVoiceBroadcastInRoom(infoEvent, room, this.client);
+ private onClientSync = () => {
+ if (this.client.getSyncState() === SyncState.Syncing) {
+ this.client.off(ClientEvent.Sync, this.onClientSync);
+ this.resume();
}
};
+ private resume(): void {
+ const userId = this.client.getUserId();
+ const deviceId = this.client.getDeviceId();
+
+ if (!userId || !deviceId) {
+ // Resuming a voice broadcast only makes sense if there is a user.
+ return;
+ }
+
+ this.client.getRooms().forEach((room: Room) => {
+ const infoEvent = findRoomLiveVoiceBroadcastFromUserAndDevice(room, userId, deviceId);
+
+ if (infoEvent) {
+ // Found a live broadcast event from current device; stop it.
+ // Stopping it is a temporary solution (see PSF-1669).
+ this.sendStopVoiceBroadcastStateEvent(infoEvent);
+ return false;
+ }
+ });
+ }
+
+ private sendStopVoiceBroadcastStateEvent(infoEvent: MatrixEvent): void {
+ const userId = this.client.getUserId();
+ const deviceId = this.client.getDeviceId();
+ const roomId = infoEvent.getRoomId();
+
+ if (!userId || !deviceId || !roomId) {
+ // We can only send a state event if we know all the IDs.
+ return;
+ }
+
+ const content: VoiceBroadcastInfoEventContent = {
+ device_id: deviceId,
+ state: VoiceBroadcastInfoState.Stopped,
+ };
+
+ // all events should reference the started event
+ const referencedEventId = infoEvent.getContent()?.state === VoiceBroadcastInfoState.Started
+ ? infoEvent.getId()
+ : infoEvent.getContent()?.["m.relates_to"]?.event_id;
+
+ if (referencedEventId) {
+ content["m.relates_to"] = {
+ rel_type: RelationType.Reference,
+ event_id: referencedEventId,
+ };
+ }
+
+ this.client.sendStateEvent(roomId, VoiceBroadcastInfoEventType, content, userId);
+ }
+
destroy(): void {
- this.client.off(ClientEvent.Room, this.onRoom);
- this.seenRooms = new Set();
+ this.client.off(ClientEvent.Sync, this.onClientSync);
}
}
diff --git a/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts b/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts
deleted file mode 100644
index f365fce226..0000000000
--- a/src/voice-broadcast/utils/resumeVoiceBroadcastInRoom.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
-
-import { VoiceBroadcastInfoState, VoiceBroadcastRecording } from "..";
-import { VoiceBroadcastRecordingsStore } from "../stores/VoiceBroadcastRecordingsStore";
-
-export const resumeVoiceBroadcastInRoom = (latestInfoEvent: MatrixEvent, room: Room, client: MatrixClient) => {
- // voice broadcasts are based on their started event, try to find it
- const infoEvent = latestInfoEvent.getContent()?.state === VoiceBroadcastInfoState.Started
- ? latestInfoEvent
- : room.findEventById(latestInfoEvent.getRelation()?.event_id);
-
- if (!infoEvent) {
- return;
- }
-
- const recording = new VoiceBroadcastRecording(infoEvent, client, VoiceBroadcastInfoState.Paused);
- VoiceBroadcastRecordingsStore.instance().setCurrent(recording);
-};
diff --git a/test/Avatar-test.ts b/test/Avatar-test.ts
index 214ada9486..0ff064ed57 100644
--- a/test/Avatar-test.ts
+++ b/test/Avatar-test.ts
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
-import { Room, RoomMember } from "matrix-js-sdk/src/matrix";
+import { Room, RoomMember, RoomType } from "matrix-js-sdk/src/matrix";
import { avatarUrlForRoom } from "../src/Avatar";
import { Media, mediaFromMxc } from "../src/customisations/Media";
@@ -46,6 +46,7 @@ describe("avatarUrlForRoom", () => {
roomId,
getMxcAvatarUrl: jest.fn(),
isSpaceRoom: jest.fn(),
+ getType: jest.fn(),
getAvatarFallbackMember: jest.fn(),
} as unknown as Room;
dmRoomMap = {
@@ -70,6 +71,7 @@ describe("avatarUrlForRoom", () => {
it("should return null for a space room", () => {
mocked(room.isSpaceRoom).mockReturnValue(true);
+ mocked(room.getType).mockReturnValue(RoomType.Space);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
diff --git a/test/Notifier-test.ts b/test/Notifier-test.ts
index 224c1fec77..30a1691c2e 100644
--- a/test/Notifier-test.ts
+++ b/test/Notifier-test.ts
@@ -19,6 +19,7 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { SyncState } from "matrix-js-sdk/src/sync";
+import { waitFor } from "@testing-library/react";
import BasePlatform from "../src/BasePlatform";
import { ElementCall } from "../src/models/Call";
@@ -29,8 +30,15 @@ import {
createLocalNotificationSettingsIfNeeded,
getLocalNotificationAccountDataEventType,
} from "../src/utils/notifications";
-import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
+import { getMockClientWithEventEmitter, mkEvent, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
import { IncomingCallToast } from "../src/toasts/IncomingCallToast";
+import { SdkContextClass } from "../src/contexts/SDKContext";
+import UserActivity from "../src/UserActivity";
+import Modal from "../src/Modal";
+import { mkThread } from "./test-utils/threads";
+import dis from "../src/dispatcher/dispatcher";
+import { ThreadPayload } from "../src/dispatcher/payloads/ThreadPayload";
+import { Action } from "../src/dispatcher/actions";
jest.mock("../src/utils/notifications", () => ({
// @ts-ignore
@@ -50,10 +58,12 @@ describe("Notifier", () => {
let MockPlatform: MockedObject;
let mockClient: MockedObject;
- let testRoom: MockedObject;
+ let testRoom: Room;
let accountDataEventKey: string;
let accountDataStore = {};
+ let mockSettings: Record = {};
+
const userId = "@bob:example.org";
beforeEach(() => {
@@ -78,7 +88,7 @@ describe("Notifier", () => {
};
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
- testRoom = mkRoom(mockClient, roomId);
+ testRoom = new Room(roomId, mockClient, mockClient.getUserId());
MockPlatform = mockPlatformPeg({
supportsNotifications: jest.fn().mockReturnValue(true),
@@ -89,7 +99,9 @@ describe("Notifier", () => {
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
- mockClient.getRoom.mockReturnValue(testRoom);
+ mockClient.getRoom.mockImplementation(id => {
+ return id === roomId ? testRoom : new Room(id, mockClient, mockClient.getUserId());
+ });
});
describe('triggering notification from events', () => {
@@ -121,13 +133,14 @@ describe("Notifier", () => {
},
});
- const enabledSettings = [
- 'notificationsEnabled',
- 'audioNotificationsEnabled',
- ];
+ mockSettings = {
+ 'notificationsEnabled': true,
+ 'audioNotificationsEnabled': true,
+ };
+
// enable notifications by default
- jest.spyOn(SettingsStore, "getValue").mockImplementation(
- settingName => enabledSettings.includes(settingName),
+ jest.spyOn(SettingsStore, "getValue").mockReset().mockImplementation(
+ settingName => mockSettings[settingName] ?? false,
);
});
@@ -253,16 +266,13 @@ describe("Notifier", () => {
});
const callOnEvent = (type?: string) => {
- const callEvent = {
- getContent: () => { },
- getRoomId: () => roomId,
- isBeingDecrypted: () => false,
- isDecryptionFailure: () => false,
- getSender: () => "@alice:foo",
- getType: () => type ?? ElementCall.CALL_EVENT_TYPE.name,
- getStateKey: () => "state_key",
- } as unknown as MatrixEvent;
-
+ const callEvent = mkEvent({
+ type: type ?? ElementCall.CALL_EVENT_TYPE.name,
+ user: "@alice:foo",
+ room: roomId,
+ content: {},
+ event: true,
+ });
Notifier.onEvent(callEvent);
return callEvent;
};
@@ -345,4 +355,72 @@ describe("Notifier", () => {
expect(createLocalNotificationSettingsIfNeededMock).toHaveBeenCalled();
});
});
+
+ describe('_evaluateEvent', () => {
+ beforeEach(() => {
+ jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId")
+ .mockReturnValue(testRoom.roomId);
+
+ jest.spyOn(UserActivity.sharedInstance(), "userActiveRecently")
+ .mockReturnValue(true);
+
+ jest.spyOn(Modal, "hasDialogs").mockReturnValue(false);
+
+ jest.spyOn(Notifier, "_displayPopupNotification").mockReset();
+ jest.spyOn(Notifier, "isEnabled").mockReturnValue(true);
+
+ mockClient.getPushActionsForEvent.mockReturnValue({
+ notify: true,
+ tweaks: {
+ sound: true,
+ },
+ });
+ });
+
+ it("should show a pop-up", () => {
+ expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0);
+ Notifier._evaluateEvent(testEvent);
+ expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0);
+
+ const eventFromOtherRoom = mkEvent({
+ event: true,
+ type: "m.room.message",
+ user: "@user1:server",
+ room: "!otherroom:example.org",
+ content: {},
+ });
+
+ Notifier._evaluateEvent(eventFromOtherRoom);
+ expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1);
+ });
+
+ it("should a pop-up for thread event", async () => {
+ const { events, rootEvent } = mkThread({
+ room: testRoom,
+ client: mockClient,
+ authorId: "@bob:example.org",
+ participantUserIds: ["@bob:example.org"],
+ });
+
+ expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0);
+
+ Notifier._evaluateEvent(rootEvent);
+ expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(0);
+
+ Notifier._evaluateEvent(events[1]);
+ expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1);
+
+ dis.dispatch({
+ action: Action.ViewThread,
+ thread_id: rootEvent.getId(),
+ });
+
+ await waitFor(() =>
+ expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId()),
+ );
+
+ Notifier._evaluateEvent(events[1]);
+ expect(Notifier._displayPopupNotification).toHaveBeenCalledTimes(1);
+ });
+ });
});
diff --git a/test/RoomNotifs-test.ts b/test/RoomNotifs-test.ts
index 3f486205df..8ab37e6945 100644
--- a/test/RoomNotifs-test.ts
+++ b/test/RoomNotifs-test.ts
@@ -16,10 +16,15 @@ limitations under the License.
import { mocked } from 'jest-mock';
import { ConditionKind, PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules";
+import { NotificationCountType, Room } from 'matrix-js-sdk/src/models/room';
-import { stubClient } from "./test-utils";
+import { mkEvent, stubClient } from "./test-utils";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
-import { getRoomNotifsState, RoomNotifState } from "../src/RoomNotifs";
+import {
+ getRoomNotifsState,
+ RoomNotifState,
+ getUnreadNotificationCount,
+} from "../src/RoomNotifs";
describe("RoomNotifs test", () => {
beforeEach(() => {
@@ -83,4 +88,74 @@ describe("RoomNotifs test", () => {
});
expect(getRoomNotifsState("!roomId:server")).toBe(RoomNotifState.AllMessagesLoud);
});
+
+ describe("getUnreadNotificationCount", () => {
+ const ROOM_ID = "!roomId:example.org";
+ const THREAD_ID = "$threadId";
+
+ let cli;
+ let room: Room;
+ beforeEach(() => {
+ cli = MatrixClientPeg.get();
+ room = new Room(ROOM_ID, cli, cli.getUserId());
+ });
+
+ it("counts room notification type", () => {
+ expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(0);
+ expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(0);
+ });
+
+ it("counts notifications type", () => {
+ room.setUnreadNotificationCount(NotificationCountType.Total, 2);
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
+
+ expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(2);
+ expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(1);
+ });
+
+ it("counts predecessor highlight", () => {
+ room.setUnreadNotificationCount(NotificationCountType.Total, 2);
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
+
+ const OLD_ROOM_ID = "!oldRoomId:example.org";
+ const oldRoom = new Room(OLD_ROOM_ID, cli, cli.getUserId());
+ oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10);
+ oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6);
+
+ cli.getRoom.mockReset().mockReturnValue(oldRoom);
+
+ const predecessorEvent = mkEvent({
+ event: true,
+ type: "m.room.create",
+ room: ROOM_ID,
+ user: cli.getUserId(),
+ content: {
+ creator: cli.getUserId(),
+ room_version: "5",
+ predecessor: {
+ room_id: OLD_ROOM_ID,
+ event_id: "$someevent",
+ },
+ },
+ ts: Date.now(),
+ });
+ room.addLiveEvents([predecessorEvent]);
+
+ expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(8);
+ expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(7);
+ });
+
+ it("counts thread notification type", () => {
+ expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(0);
+ expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(0);
+ });
+
+ it("counts notifications type", () => {
+ room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 2);
+ room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
+
+ expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(2);
+ expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(1);
+ });
+ });
});
diff --git a/test/audio/VoiceRecording-test.ts b/test/audio/VoiceRecording-test.ts
new file mode 100644
index 0000000000..ac4f52eabe
--- /dev/null
+++ b/test/audio/VoiceRecording-test.ts
@@ -0,0 +1,105 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { VoiceRecording } from "../../src/audio/VoiceRecording";
+
+/**
+ * The tests here are heavily using access to private props.
+ * While this is not so great, we can at lest test some behaviour easily this way.
+ */
+describe("VoiceRecording", () => {
+ let recording: VoiceRecording;
+ let recorderSecondsSpy: jest.SpyInstance;
+
+ const itShouldNotCallStop = () => {
+ it("should not call stop", () => {
+ expect(recording.stop).not.toHaveBeenCalled();
+ });
+ };
+
+ const simulateUpdate = (recorderSeconds: number) => {
+ beforeEach(() => {
+ recorderSecondsSpy.mockReturnValue(recorderSeconds);
+ // @ts-ignore
+ recording.processAudioUpdate(recorderSeconds);
+ });
+ };
+
+ beforeEach(() => {
+ recording = new VoiceRecording();
+ // @ts-ignore
+ recording.observable = {
+ update: jest.fn(),
+ };
+ jest.spyOn(recording, "stop").mockImplementation();
+ recorderSecondsSpy = jest.spyOn(recording, "recorderSeconds", "get");
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe("when recording", () => {
+ beforeEach(() => {
+ // @ts-ignore
+ recording.recording = true;
+ });
+
+ describe("and there is an audio update and time left", () => {
+ simulateUpdate(42);
+ itShouldNotCallStop();
+ });
+
+ describe("and there is an audio update and time is up", () => {
+ // one second above the limit
+ simulateUpdate(901);
+
+ it("should call stop", () => {
+ expect(recording.stop).toHaveBeenCalled();
+ });
+ });
+
+ describe("and the max length limit has been disabled", () => {
+ beforeEach(() => {
+ recording.disableMaxLength();
+ });
+
+ describe("and there is an audio update and time left", () => {
+ simulateUpdate(42);
+ itShouldNotCallStop();
+ });
+
+ describe("and there is an audio update and time is up", () => {
+ // one second above the limit
+ simulateUpdate(901);
+ itShouldNotCallStop();
+ });
+ });
+ });
+
+ describe("when not recording", () => {
+ describe("and there is an audio update and time left", () => {
+ simulateUpdate(42);
+ itShouldNotCallStop();
+ });
+
+ describe("and there is an audio update and time is up", () => {
+ // one second above the limit
+ simulateUpdate(901);
+ itShouldNotCallStop();
+ });
+ });
+});
diff --git a/test/components/structures/RoomStatusBar-test.tsx b/test/components/structures/RoomStatusBar-test.tsx
new file mode 100644
index 0000000000..db8b0e03ff
--- /dev/null
+++ b/test/components/structures/RoomStatusBar-test.tsx
@@ -0,0 +1,91 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
+import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { Room } from "matrix-js-sdk/src/models/room";
+
+import { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar";
+import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
+import { mkEvent, stubClient } from "../../test-utils/test-utils";
+import { mkThread } from "../../test-utils/threads";
+
+describe("RoomStatusBar", () => {
+ const ROOM_ID = "!roomId:example.org";
+ let room: Room;
+ let client: MatrixClient;
+ let event: MatrixEvent;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ stubClient();
+ client = MatrixClientPeg.get();
+ room = new Room(ROOM_ID, client, client.getUserId(), {
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ });
+ event = mkEvent({
+ event: true,
+ type: "m.room.message",
+ user: "@user1:server",
+ room: "!room1:server",
+ content: {},
+ });
+ event.status = EventStatus.NOT_SENT;
+ });
+
+ describe("getUnsentMessages", () => {
+ it("returns no unsent messages", () => {
+ expect(getUnsentMessages(room)).toHaveLength(0);
+ });
+
+ it("checks the event status", () => {
+ room.addPendingEvent(event, "123");
+
+ expect(getUnsentMessages(room)).toHaveLength(1);
+ event.status = EventStatus.SENT;
+
+ expect(getUnsentMessages(room)).toHaveLength(0);
+ });
+
+ it("only returns events related to a thread", () => {
+ room.addPendingEvent(event, "123");
+
+ const { rootEvent, events } = mkThread({
+ room,
+ client,
+ authorId: "@alice:example.org",
+ participantUserIds: ["@alice:example.org"],
+ length: 2,
+ });
+ rootEvent.status = EventStatus.NOT_SENT;
+ room.addPendingEvent(rootEvent, rootEvent.getId());
+ for (const event of events) {
+ event.status = EventStatus.NOT_SENT;
+ room.addPendingEvent(event, Date.now() + Math.random() + "");
+ }
+
+ const pendingEvents = getUnsentMessages(room, rootEvent.getId());
+
+ expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId());
+ expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId());
+ expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId());
+
+ // Filters out the non thread events
+ expect(pendingEvents.every(ev => ev.getId() !== event.getId())).toBe(true);
+ });
+ });
+});
diff --git a/test/components/structures/ThreadView-test.tsx b/test/components/structures/ThreadView-test.tsx
index 2516aad082..2893958a8f 100644
--- a/test/components/structures/ThreadView-test.tsx
+++ b/test/components/structures/ThreadView-test.tsx
@@ -28,6 +28,7 @@ import { act } from "react-dom/test-utils";
import ThreadView from "../../../src/components/structures/ThreadView";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
+import { SdkContextClass } from "../../../src/contexts/SDKContext";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
@@ -155,4 +156,13 @@ describe("ThreadView", () => {
ROOM_ID, rootEvent2.getId(), expectedMessageBody(rootEvent2, "yolo"),
);
});
+
+ it("sets the correct thread in the room view store", async () => {
+ // expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBeNull();
+ const { unmount } = await getComponent();
+ expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBe(rootEvent.getId());
+
+ unmount();
+ await waitFor(() => expect(SdkContextClass.instance.roomViewStore.getThreadId()).toBeNull());
+ });
});
diff --git a/test/components/views/beacon/BeaconListItem-test.tsx b/test/components/views/beacon/BeaconListItem-test.tsx
index d95deed045..bbfd7e753b 100644
--- a/test/components/views/beacon/BeaconListItem-test.tsx
+++ b/test/components/views/beacon/BeaconListItem-test.tsx
@@ -15,8 +15,7 @@ limitations under the License.
*/
import React from 'react';
-// eslint-disable-next-line deprecate/import
-import { mount } from 'enzyme';
+import { fireEvent, render } from "@testing-library/react";
import {
Beacon,
RoomMember,
@@ -28,7 +27,6 @@ import { act } from 'react-dom/test-utils';
import BeaconListItem from '../../../../src/components/views/beacon/BeaconListItem';
import MatrixClientContext from '../../../../src/contexts/MatrixClientContext';
import {
- findByTestId,
getMockClientWithEventEmitter,
makeBeaconEvent,
makeBeaconInfoEvent,
@@ -76,11 +74,9 @@ describe(' ', () => {
beacon: new Beacon(aliceBeaconEvent),
};
- const getComponent = (props = {}) =>
- mount( , {
- wrappingComponent: MatrixClientContext.Provider,
- wrappingComponentProps: { value: mockClient },
- });
+ const getComponent = (props = {}) => render(
+
+ );
const setupRoomWithBeacons = (beaconInfoEvents: MatrixEvent[], locationEvents?: MatrixEvent[]): Beacon[] => {
const beacons = makeRoomWithBeacons(roomId, mockClient, beaconInfoEvents, locationEvents);
@@ -104,71 +100,72 @@ describe(' ', () => {
{ isLive: false },
);
const [beacon] = setupRoomWithBeacons([notLiveBeacon]);
- const component = getComponent({ beacon });
- expect(component.html()).toBeNull();
+ const { container } = getComponent({ beacon });
+ expect(container.innerHTML).toBeFalsy();
});
it('renders null when beacon has no location', () => {
const [beacon] = setupRoomWithBeacons([aliceBeaconEvent]);
- const component = getComponent({ beacon });
- expect(component.html()).toBeNull();
+ const { container } = getComponent({ beacon });
+ expect(container.innerHTML).toBeFalsy();
});
describe('when a beacon is live and has locations', () => {
it('renders beacon info', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
- const component = getComponent({ beacon });
- expect(component.html()).toMatchSnapshot();
+ const { asFragment } = getComponent({ beacon });
+ expect(asFragment()).toMatchSnapshot();
});
describe('non-self beacons', () => {
it('uses beacon description as beacon name', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
- const component = getComponent({ beacon });
- expect(component.find('BeaconStatus').props().label).toEqual("Alice's car");
+ const { container } = getComponent({ beacon });
+ expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent("Alice's car");
});
it('uses beacon owner mxid as beacon name for a beacon without description', () => {
const [beacon] = setupRoomWithBeacons([pinBeaconWithoutDescription], [aliceLocation1]);
- const component = getComponent({ beacon });
- expect(component.find('BeaconStatus').props().label).toEqual(aliceId);
+ const { container } = getComponent({ beacon });
+ expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent(aliceId);
});
it('renders location icon', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
- const component = getComponent({ beacon });
- expect(component.find('StyledLiveBeaconIcon').length).toBeTruthy();
+ const { container } = getComponent({ beacon });
+ expect(container.querySelector('.mx_StyledLiveBeaconIcon')).toBeTruthy();
});
});
describe('self locations', () => {
it('renders beacon owner avatar', () => {
const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]);
- const component = getComponent({ beacon });
- expect(component.find('MemberAvatar').length).toBeTruthy();
+ const { container } = getComponent({ beacon });
+ expect(container.querySelector('.mx_BaseAvatar')).toBeTruthy();
});
it('uses beacon owner name as beacon name', () => {
const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]);
- const component = getComponent({ beacon });
- expect(component.find('BeaconStatus').props().label).toEqual('Alice');
+ const { container } = getComponent({ beacon });
+ expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent("Alice");
});
});
describe('on location updates', () => {
it('updates last updated time on location updated', () => {
const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation2]);
- const component = getComponent({ beacon });
+ const { container } = getComponent({ beacon });
- expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated 9 minutes ago');
+ expect(container.querySelector('.mx_BeaconListItem_lastUpdated'))
+ .toHaveTextContent('Updated 9 minutes ago');
// update to a newer location
act(() => {
beacon.addLocations([aliceLocation1]);
- component.setProps({});
});
- expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago');
+ expect(container.querySelector('.mx_BeaconListItem_lastUpdated'))
+ .toHaveTextContent('Updated a few seconds ago');
});
});
@@ -176,23 +173,19 @@ describe(' ', () => {
it('does not call onClick handler when clicking share button', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
- const component = getComponent({ beacon, onClick });
+ const { getByTestId } = getComponent({ beacon, onClick });
- act(() => {
- findByTestId(component, 'open-location-in-osm').at(0).simulate('click');
- });
+ fireEvent.click(getByTestId('open-location-in-osm'));
expect(onClick).not.toHaveBeenCalled();
});
it('calls onClick handler when clicking outside of share buttons', () => {
const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]);
const onClick = jest.fn();
- const component = getComponent({ beacon, onClick });
+ const { container } = getComponent({ beacon, onClick });
- act(() => {
- // click the beacon name
- component.find('.mx_BeaconStatus_description').simulate('click');
- });
+ // click the beacon name
+ fireEvent.click(container.querySelector(".mx_BeaconStatus_description"));
expect(onClick).toHaveBeenCalled();
});
});
diff --git a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx
index 0369fa5cc1..b8ab33b044 100644
--- a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx
+++ b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx
@@ -16,8 +16,7 @@ limitations under the License.
import React from 'react';
import { mocked } from 'jest-mock';
-// eslint-disable-next-line deprecate/import
-import { mount } from 'enzyme';
+import { fireEvent, render } from "@testing-library/react";
import { act } from 'react-dom/test-utils';
import { Beacon, BeaconIdentifier } from 'matrix-js-sdk/src/matrix';
@@ -48,9 +47,7 @@ jest.mock('../../../../src/stores/OwnBeaconStore', () => {
);
describe(' ', () => {
- const defaultProps = {};
- const getComponent = (props = {}) =>
- mount( );
+ const getComponent = (props = {}) => render( );
const roomId1 = '!room1:server';
const roomId2 = '!room2:server';
@@ -85,8 +82,8 @@ describe(' ', () => {
));
it('renders nothing when user has no live beacons', () => {
- const component = getComponent();
- expect(component.html()).toBe(null);
+ const { container } = getComponent();
+ expect(container.innerHTML).toBeFalsy();
});
describe('when user has live location monitor', () => {
@@ -110,17 +107,15 @@ describe(' ', () => {
});
it('renders correctly when not minimized', () => {
- const component = getComponent();
- expect(component).toMatchSnapshot();
+ const { asFragment } = getComponent();
+ expect(asFragment()).toMatchSnapshot();
});
it('goes to room of latest beacon when clicked', () => {
- const component = getComponent();
+ const { container } = getComponent();
const dispatchSpy = jest.spyOn(dispatcher, 'dispatch');
- act(() => {
- component.simulate('click');
- });
+ fireEvent.click(container.querySelector("[role=button]"));
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
@@ -134,28 +129,26 @@ describe(' ', () => {
});
it('renders correctly when minimized', () => {
- const component = getComponent({ isMinimized: true });
- expect(component).toMatchSnapshot();
+ const { asFragment } = getComponent({ isMinimized: true });
+ expect(asFragment()).toMatchSnapshot();
});
it('renders location publish error', () => {
mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue(
[beacon1.identifier],
);
- const component = getComponent();
- expect(component).toMatchSnapshot();
+ const { asFragment } = getComponent();
+ expect(asFragment()).toMatchSnapshot();
});
it('goes to room of latest beacon with location publish error when clicked', () => {
mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue(
[beacon1.identifier],
);
- const component = getComponent();
+ const { container } = getComponent();
const dispatchSpy = jest.spyOn(dispatcher, 'dispatch');
- act(() => {
- component.simulate('click');
- });
+ fireEvent.click(container.querySelector("[role=button]"));
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
@@ -172,9 +165,9 @@ describe(' ', () => {
mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue(
[beacon1.identifier],
);
- const component = getComponent();
+ const { container, rerender } = getComponent();
// error mode
- expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual(
+ expect(container.querySelector('.mx_LeftPanelLiveShareWarning').textContent).toEqual(
'An error occurred whilst sharing your live location',
);
@@ -183,18 +176,18 @@ describe(' ', () => {
OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.LocationPublishError, 'abc');
});
- component.setProps({});
+ rerender( );
// default mode
- expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual(
+ expect(container.querySelector('.mx_LeftPanelLiveShareWarning').textContent).toEqual(
'You are sharing your live location',
);
});
it('removes itself when user stops having live beacons', async () => {
- const component = getComponent({ isMinimized: true });
+ const { container, rerender } = getComponent({ isMinimized: true });
// started out rendered
- expect(component.html()).toBeTruthy();
+ expect(container.innerHTML).toBeTruthy();
act(() => {
mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false;
@@ -202,9 +195,9 @@ describe(' ', () => {
});
await flushPromises();
- component.setProps({});
+ rerender( );
- expect(component.html()).toBe(null);
+ expect(container.innerHTML).toBeFalsy();
});
it('refreshes beacon liveness monitors when pagevisibilty changes to visible', () => {
@@ -228,21 +221,21 @@ describe(' ', () => {
describe('stopping errors', () => {
it('renders stopping error', () => {
OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error'));
- const component = getComponent();
- expect(component.text()).toEqual('An error occurred while stopping your live location');
+ const { container } = getComponent();
+ expect(container.textContent).toEqual('An error occurred while stopping your live location');
});
it('starts rendering stopping error on beaconUpdateError emit', () => {
- const component = getComponent();
+ const { container } = getComponent();
// no error
- expect(component.text()).toEqual('You are sharing your live location');
+ expect(container.textContent).toEqual('You are sharing your live location');
act(() => {
OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error'));
OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.BeaconUpdateError, beacon2.identifier, true);
});
- expect(component.text()).toEqual('An error occurred while stopping your live location');
+ expect(container.textContent).toEqual('An error occurred while stopping your live location');
});
it('renders stopping error when beacons have stopping and location errors', () => {
@@ -250,8 +243,8 @@ describe(' ', () => {
[beacon1.identifier],
);
OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error'));
- const component = getComponent();
- expect(component.text()).toEqual('An error occurred while stopping your live location');
+ const { container } = getComponent();
+ expect(container.textContent).toEqual('An error occurred while stopping your live location');
});
it('goes to room of latest beacon with stopping error when clicked', () => {
@@ -259,12 +252,10 @@ describe(' ', () => {
[beacon1.identifier],
);
OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error'));
- const component = getComponent();
+ const { container } = getComponent();
const dispatchSpy = jest.spyOn(dispatcher, 'dispatch');
- act(() => {
- component.simulate('click');
- });
+ fireEvent.click(container.querySelector("[role=button]"));
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewRoom,
diff --git a/test/components/views/beacon/ShareLatestLocation-test.tsx b/test/components/views/beacon/ShareLatestLocation-test.tsx
index 767c712042..1712d7d57c 100644
--- a/test/components/views/beacon/ShareLatestLocation-test.tsx
+++ b/test/components/views/beacon/ShareLatestLocation-test.tsx
@@ -15,9 +15,7 @@ limitations under the License.
*/
import React from 'react';
-// eslint-disable-next-line deprecate/import
-import { mount } from 'enzyme';
-import { act } from 'react-dom/test-utils';
+import { fireEvent, render } from "@testing-library/react";
import ShareLatestLocation from '../../../../src/components/views/beacon/ShareLatestLocation';
import { copyPlaintext } from '../../../../src/utils/strings';
@@ -34,26 +32,23 @@ describe(' ', () => {
timestamp: 123,
},
};
- const getComponent = (props = {}) =>
- mount( );
+ const getComponent = (props = {}) => render( );
beforeEach(() => {
jest.clearAllMocks();
});
it('renders null when no location', () => {
- const component = getComponent({ latestLocationState: undefined });
- expect(component.html()).toBeNull();
+ const { container } = getComponent({ latestLocationState: undefined });
+ expect(container.innerHTML).toBeFalsy();
});
it('renders share buttons when there is a location', async () => {
- const component = getComponent();
- expect(component).toMatchSnapshot();
+ const { container, asFragment } = getComponent();
+ expect(asFragment()).toMatchSnapshot();
- await act(async () => {
- component.find('.mx_CopyableText_copyButton').at(0).simulate('click');
- await flushPromises();
- });
+ fireEvent.click(container.querySelector('.mx_CopyableText_copyButton'));
+ await flushPromises();
expect(copyPlaintext).toHaveBeenCalledWith('51,42');
});
diff --git a/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx b/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx
index d6be878a25..e04289c7db 100644
--- a/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx
+++ b/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx
@@ -15,18 +15,16 @@ limitations under the License.
*/
import React from 'react';
-// eslint-disable-next-line deprecate/import
-import { mount } from 'enzyme';
+import { render } from "@testing-library/react";
import StyledLiveBeaconIcon from '../../../../src/components/views/beacon/StyledLiveBeaconIcon';
describe(' ', () => {
const defaultProps = {};
- const getComponent = (props = {}) =>
- mount( );
+ const getComponent = (props = {}) => render( );
it('renders', () => {
- const component = getComponent();
- expect(component).toBeTruthy();
+ const { asFragment } = getComponent();
+ expect(asFragment()).toMatchSnapshot();
});
});
diff --git a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap
index 9ddc5dd44c..dd1d607dd4 100644
--- a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap
+++ b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap
@@ -1,3 +1,68 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[` when a beacon is live and has locations renders beacon info 1`] = `"
Alice's car Live until 16:04
Updated a few seconds ago "`;
+exports[` when a beacon is live and has locations renders beacon info 1`] = `
+
+
+
+
+
+
+
+ Alice's car
+
+
+ Live until 16:04
+
+
+
+
+
+ Updated a few seconds ago
+
+
+
+
+`;
diff --git a/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap b/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap
index a92079d2c8..22199fbc91 100644
--- a/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap
+++ b/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap
@@ -75,7 +75,7 @@ exports[` renders sidebar correctly with beacons 1`] = `
tabindex="0"
>
when user has live location monitor renders correctly when minimized 1`] = `
-
-
+
+
`;
exports[` when user has live location monitor renders correctly when not minimized 1`] = `
-
-
+
+
`;
exports[` when user has live location monitor renders location publish error 1`] = `
-
-
+
+
`;
diff --git a/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap b/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap
index 5f55d3103d..1162786e30 100644
--- a/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap
+++ b/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap
@@ -1,79 +1,30 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` renders share buttons when there is a location 1`] = `
-
-
+
+
-
-
-
-
-
+ aria-label="Copy"
+ class="mx_AccessibleButton mx_CopyableText_copyButton"
+ role="button"
+ tabindex="0"
+ />
+
+
`;
diff --git a/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap b/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap
new file mode 100644
index 0000000000..e1e2bf1faa
--- /dev/null
+++ b/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap
@@ -0,0 +1,9 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders 1`] = `
+
+
+
+`;
diff --git a/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx b/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx
index 5503db537b..686e5d558b 100644
--- a/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx
+++ b/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx
@@ -20,7 +20,7 @@ import { mount, ReactWrapper } from 'enzyme';
import { act } from 'react-dom/test-utils';
import { IPassphraseInfo } from 'matrix-js-sdk/src/crypto/api';
-import { findByTestId, getMockClientWithEventEmitter, unmockClientPeg } from '../../../test-utils';
+import { findByAttr, getMockClientWithEventEmitter, unmockClientPeg } from '../../../test-utils';
import { findById, flushPromises } from '../../../test-utils';
import AccessSecretStorageDialog from "../../../../src/components/views/dialogs/security/AccessSecretStorageDialog";
@@ -91,7 +91,7 @@ describe("AccessSecretStorageDialog", () => {
wrapper.setProps({});
});
- const submitButton = findByTestId(wrapper, 'dialog-primary-button').at(0);
+ const submitButton = findByAttr('data-testid')(wrapper, 'dialog-primary-button').at(0);
// submit button is enabled when key is valid
expect(submitButton.props().disabled).toBeFalsy();
expect(wrapper.find('.mx_AccessSecretStorageDialog_recoveryKeyFeedback').text()).toEqual('Looks good!');
@@ -112,7 +112,7 @@ describe("AccessSecretStorageDialog", () => {
// @ts-ignore private
await wrapper.instance().validateRecoveryKey();
- const submitButton = findByTestId(wrapper, 'dialog-primary-button').at(0);
+ const submitButton = findByAttr('data-testid')(wrapper, 'dialog-primary-button').at(0);
// submit button is disabled when recovery key is invalid
expect(submitButton.props().disabled).toBeTruthy();
expect(
diff --git a/test/components/views/dialogs/ExportDialog-test.tsx b/test/components/views/dialogs/ExportDialog-test.tsx
index c6ddda04ff..ae4f517399 100644
--- a/test/components/views/dialogs/ExportDialog-test.tsx
+++ b/test/components/views/dialogs/ExportDialog-test.tsx
@@ -65,8 +65,8 @@ describe(' ', () => {
const getAttachmentsCheckbox = (component) => component.find('input[id="include-attachments"]');
const getMessageCountInput = (component) => component.find('input[id="message-count"]');
const getExportFormatInput = (component, format) => component.find(`input[id="exportFormat-${format}"]`);
- const getPrimaryButton = (component) => component.find('[data-test-id="dialog-primary-button"]');
- const getSecondaryButton = (component) => component.find('[data-test-id="dialog-cancel-button"]');
+ const getPrimaryButton = (component) => component.find('[data-testid="dialog-primary-button"]');
+ const getSecondaryButton = (component) => component.find('[data-testid="dialog-cancel-button"]');
const submitForm = async (component) => act(async () => {
getPrimaryButton(component).simulate('click');
diff --git a/test/components/views/dialogs/InviteDialog-test.tsx b/test/components/views/dialogs/InviteDialog-test.tsx
index 469cbde96b..6a82506e76 100644
--- a/test/components/views/dialogs/InviteDialog-test.tsx
+++ b/test/components/views/dialogs/InviteDialog-test.tsx
@@ -16,6 +16,7 @@ limitations under the License.
import React from "react";
import { render, screen } from "@testing-library/react";
+import { RoomType } from "matrix-js-sdk/src/@types/event";
import InviteDialog from "../../../../src/components/views/dialogs/InviteDialog";
import { KIND_INVITE } from "../../../../src/components/views/dialogs/InviteDialogTypes";
@@ -74,6 +75,7 @@ describe("InviteDialog", () => {
it("should label with space name", () => {
mockClient.getRoom(roomId).isSpaceRoom = jest.fn().mockReturnValue(true);
+ mockClient.getRoom(roomId).getType = jest.fn().mockReturnValue(RoomType.Space);
mockClient.getRoom(roomId).name = "Space";
render((
should fetch github proxy url for each repo with ol
class="mx_Dialog_buttons_row"
>
Cancel
Update
diff --git a/test/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap
index b922d8b8ba..590f85072e 100644
--- a/test/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap
+++ b/test/components/views/dialogs/__snapshots__/ExportDialog-test.tsx.snap
@@ -249,14 +249,14 @@ Array [
class="mx_Dialog_buttons_row"
>
Cancel
Export
@@ -474,14 +474,14 @@ Array [
class="mx_Dialog_buttons_row"
>
Cancel
Export
@@ -827,7 +827,7 @@ Array [
className="mx_Dialog_buttons_row"
>
@@ -1102,14 +1102,14 @@ Array [
class="mx_Dialog_buttons_row"
>
Cancel
Export
@@ -1327,14 +1327,14 @@ Array [
class="mx_Dialog_buttons_row"
>
Cancel
Export
@@ -1680,7 +1680,7 @@ Array [
className="mx_Dialog_buttons_row"
>
@@ -1942,14 +1942,14 @@ Array [
class="mx_Dialog_buttons_row"
>
Cancel
Export
@@ -2167,14 +2167,14 @@ Array [
class="mx_Dialog_buttons_row"
>
Cancel
Export
@@ -2520,7 +2520,7 @@ Array [
className="mx_Dialog_buttons_row"
>
@@ -2873,7 +2873,7 @@ Array [
className="mx_Dialog_buttons_row"
>
diff --git a/test/components/views/elements/LearnMore-test.tsx b/test/components/views/elements/LearnMore-test.tsx
new file mode 100644
index 0000000000..6ae577543c
--- /dev/null
+++ b/test/components/views/elements/LearnMore-test.tsx
@@ -0,0 +1,57 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import { fireEvent, render } from '@testing-library/react';
+
+import LearnMore from '../../../../src/components/views/elements/LearnMore';
+import Modal from '../../../../src/Modal';
+import InfoDialog from '../../../../src/components/views/dialogs/InfoDialog';
+
+describe(' ', () => {
+ const defaultProps = {
+ title: 'Test',
+ description: 'test test test',
+ ['data-testid']: 'testid',
+ };
+ const getComponent = (props = {}) =>
+ ( );
+
+ const modalSpy = jest.spyOn(Modal, 'createDialog').mockReturnValue(undefined);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders button', () => {
+ const { container } = render(getComponent());
+ expect(container).toMatchSnapshot();
+ });
+
+ it('opens modal on click', async () => {
+ const { getByTestId } = render(getComponent());
+ fireEvent.click(getByTestId('testid'));
+
+ expect(modalSpy).toHaveBeenCalledWith(
+ InfoDialog,
+ {
+ button: 'Got it',
+ description: defaultProps.description,
+ hasCloseButton: true,
+ title: defaultProps.title,
+ });
+ });
+});
diff --git a/test/components/views/elements/PowerSelector-test.tsx b/test/components/views/elements/PowerSelector-test.tsx
new file mode 100644
index 0000000000..9367d0b089
--- /dev/null
+++ b/test/components/views/elements/PowerSelector-test.tsx
@@ -0,0 +1,62 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import { fireEvent, render, screen } from "@testing-library/react";
+
+import PowerSelector from "../../../../src/components/views/elements/PowerSelector";
+
+describe(' ', () => {
+ it("should reset back to custom value when custom input is blurred blank", async () => {
+ const fn = jest.fn();
+ render( );
+
+ const input = screen.getByLabelText("Power level");
+ fireEvent.change(input, { target: { value: "" } });
+ fireEvent.blur(input);
+
+ await screen.findByDisplayValue(25);
+ expect(fn).not.toHaveBeenCalled();
+ });
+
+ it("should reset back to preset value when custom input is blurred blank", async () => {
+ const fn = jest.fn();
+ render( );
+
+ const select = screen.getByLabelText("Power level");
+ fireEvent.change(select, { target: { value: "SELECT_VALUE_CUSTOM" } });
+
+ const input = screen.getByLabelText("Power level");
+ fireEvent.change(input, { target: { value: "" } });
+ fireEvent.blur(input);
+
+ const option = await screen.findByText("Moderator");
+ expect(option.selected).toBeTruthy();
+ expect(fn).not.toHaveBeenCalled();
+ });
+
+ it("should call onChange when custom input is blurred with a number in it", async () => {
+ const fn = jest.fn();
+ render( );
+
+ const input = screen.getByLabelText("Power level");
+ fireEvent.change(input, { target: { value: 40 } });
+ fireEvent.blur(input);
+
+ await screen.findByDisplayValue(40);
+ expect(fn).toHaveBeenCalledWith(40, "key");
+ });
+});
diff --git a/test/components/views/elements/StyledRadioGroup-test.tsx b/test/components/views/elements/StyledRadioGroup-test.tsx
index 3fa5dd9c53..8868b741bd 100644
--- a/test/components/views/elements/StyledRadioGroup-test.tsx
+++ b/test/components/views/elements/StyledRadioGroup-test.tsx
@@ -15,9 +15,7 @@ limitations under the License.
*/
import React from 'react';
-// eslint-disable-next-line deprecate/import
-import { mount } from 'enzyme';
-import { act } from "react-dom/test-utils";
+import { fireEvent, render } from "@testing-library/react";
import StyledRadioGroup from "../../../../src/components/views/elements/StyledRadioGroup";
@@ -44,16 +42,16 @@ describe(' ', () => {
definitions: defaultDefinitions,
onChange: jest.fn(),
};
- const getComponent = (props = {}) => mount( );
+ const getComponent = (props = {}) => render( );
- const getInputByValue = (component, value) => component.find(`input[value="${value}"]`);
- const getCheckedInput = component => component.find('input[checked=true]');
+ const getInputByValue = (component, value) => component.container.querySelector(`input[value="${value}"]`);
+ const getCheckedInput = component => component.container.querySelector('input[checked]');
it('renders radios correctly when no value is provided', () => {
const component = getComponent();
- expect(component).toMatchSnapshot();
- expect(getCheckedInput(component).length).toBeFalsy();
+ expect(component.asFragment()).toMatchSnapshot();
+ expect(getCheckedInput(component)).toBeFalsy();
});
it('selects correct button when value is provided', () => {
@@ -61,7 +59,7 @@ describe(' ', () => {
value: optionC.value,
});
- expect(getCheckedInput(component).at(0).props().value).toEqual(optionC.value);
+ expect(getCheckedInput(component).value).toEqual(optionC.value);
});
it('selects correct buttons when definitions have checked prop', () => {
@@ -74,10 +72,10 @@ describe(' ', () => {
value: optionC.value, definitions,
});
- expect(getInputByValue(component, optionA.value).props().checked).toBeTruthy();
- expect(getInputByValue(component, optionB.value).props().checked).toBeFalsy();
+ expect(getInputByValue(component, optionA.value)).toBeChecked();
+ expect(getInputByValue(component, optionB.value)).not.toBeChecked();
// optionC.checked = false overrides value matching
- expect(getInputByValue(component, optionC.value).props().checked).toBeFalsy();
+ expect(getInputByValue(component, optionC.value)).not.toBeChecked();
});
it('disables individual buttons based on definition.disabled', () => {
@@ -87,16 +85,16 @@ describe(' ', () => {
{ ...optionC, disabled: true },
];
const component = getComponent({ definitions });
- expect(getInputByValue(component, optionA.value).props().disabled).toBeFalsy();
- expect(getInputByValue(component, optionB.value).props().disabled).toBeTruthy();
- expect(getInputByValue(component, optionC.value).props().disabled).toBeTruthy();
+ expect(getInputByValue(component, optionA.value)).not.toBeDisabled();
+ expect(getInputByValue(component, optionB.value)).toBeDisabled();
+ expect(getInputByValue(component, optionC.value)).toBeDisabled();
});
it('disables all buttons with disabled prop', () => {
const component = getComponent({ disabled: true });
- expect(getInputByValue(component, optionA.value).props().disabled).toBeTruthy();
- expect(getInputByValue(component, optionB.value).props().disabled).toBeTruthy();
- expect(getInputByValue(component, optionC.value).props().disabled).toBeTruthy();
+ expect(getInputByValue(component, optionA.value)).toBeDisabled();
+ expect(getInputByValue(component, optionB.value)).toBeDisabled();
+ expect(getInputByValue(component, optionC.value)).toBeDisabled();
});
it('calls onChange on click', () => {
@@ -106,9 +104,7 @@ describe(' ', () => {
onChange,
});
- act(() => {
- getInputByValue(component, optionB.value).simulate('change');
- });
+ fireEvent.click(getInputByValue(component, optionB.value));
expect(onChange).toHaveBeenCalledWith(optionB.value);
});
diff --git a/test/components/views/elements/__snapshots__/LearnMore-test.tsx.snap b/test/components/views/elements/__snapshots__/LearnMore-test.tsx.snap
new file mode 100644
index 0000000000..41904877c8
--- /dev/null
+++ b/test/components/views/elements/__snapshots__/LearnMore-test.tsx.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` renders button 1`] = `
+
+`;
diff --git a/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap b/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap
index 423c006a72..cb3c3374fd 100644
--- a/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap
+++ b/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap
@@ -1,152 +1,83 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[` renders radios correctly when no value is provided 1`] = `
-
- Anteater label
- ,
- "value": "Anteater",
- },
- Object {
- "label":
- Badger label
- ,
- "value": "Badger",
- },
- Object {
- "description":
- Canary description
- ,
- "label":
- Canary label
- ,
- "value": "Canary",
- },
- ]
- }
- name="test"
- onChange={[MockFunction]}
->
-
+
-
+
+
-
-
-
-
- Anteater label
-
-
-
-
-
+
+ Anteater label
+
+
+
+
anteater description
-
-
+
+
-
-
-
-
- Badger label
-
-
-
-
-
-
+ Badger label
+
+
+
+
+
-
+
+
-
-
-
-
- Canary label
-
-
-
-
-
+
+ Canary label
+
+
+
+
@@ -154,5 +85,5 @@ exports[` renders radios correctly when no value is provided
Canary description
-
+
`;
diff --git a/test/components/views/right_panel/RoomHeaderButtons-test.tsx b/test/components/views/right_panel/RoomHeaderButtons-test.tsx
new file mode 100644
index 0000000000..5d873f4b86
--- /dev/null
+++ b/test/components/views/right_panel/RoomHeaderButtons-test.tsx
@@ -0,0 +1,97 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { render } from "@testing-library/react";
+import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
+import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
+import React from "react";
+
+import RoomHeaderButtons from "../../../../src/components/views/right_panel/RoomHeaderButtons";
+import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
+import SettingsStore from "../../../../src/settings/SettingsStore";
+import { stubClient } from "../../../test-utils";
+
+describe("RoomHeaderButtons-test.tsx", function() {
+ const ROOM_ID = "!roomId:example.org";
+ let room: Room;
+ let client: MatrixClient;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ stubClient();
+ client = MatrixClientPeg.get();
+ room = new Room(ROOM_ID, client, client.getUserId(), {
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ });
+
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
+ if (name === "feature_thread") return true;
+ });
+ });
+
+ function getComponent(room: Room) {
+ return render( );
+ }
+
+ function getThreadButton(container) {
+ return container.querySelector(".mx_RightPanel_threadsButton");
+ }
+
+ function isIndicatorOfType(container, type: "red" | "gray") {
+ return container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")
+ .className
+ .includes(type);
+ }
+
+ it("shows the thread button", () => {
+ const { container } = getComponent(room);
+ expect(getThreadButton(container)).not.toBeNull();
+ });
+
+ it("hides the thread button", () => {
+ jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false);
+ const { container } = getComponent(room);
+ expect(getThreadButton(container)).toBeNull();
+ });
+
+ it("room wide notification does not change the thread button", () => {
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
+ room.setUnreadNotificationCount(NotificationCountType.Total, 1);
+
+ const { container } = getComponent(room);
+
+ expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull();
+ });
+
+ it("room wide notification does not change the thread button", () => {
+ const { container } = getComponent(room);
+
+ room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1);
+ expect(isIndicatorOfType(container, "gray")).toBe(true);
+
+ room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1);
+ expect(isIndicatorOfType(container, "red")).toBe(true);
+
+ room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 0);
+ room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 0);
+
+ expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull();
+ });
+});
diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx
index 6ec36cb096..f76661fc68 100644
--- a/test/components/views/right_panel/UserInfo-test.tsx
+++ b/test/components/views/right_panel/UserInfo-test.tsx
@@ -140,6 +140,7 @@ describe(' ', () => {
describe('with a room', () => {
const room = {
roomId: '!fkfk',
+ getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue('mock-avatar-url'),
diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx
new file mode 100644
index 0000000000..dd0cda23b4
--- /dev/null
+++ b/test/components/views/rooms/EventTile-test.tsx
@@ -0,0 +1,153 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import { act, render, screen, waitFor } from "@testing-library/react";
+import { EventType } from "matrix-js-sdk/src/@types/event";
+import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
+import { MatrixEvent } from "matrix-js-sdk/src/models/event";
+import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
+
+import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile";
+import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
+import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext";
+import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
+import SettingsStore from "../../../../src/settings/SettingsStore";
+import { getRoomContext, mkEvent, mkMessage, stubClient } from "../../../test-utils";
+import { mkThread } from "../../../test-utils/threads";
+
+describe("EventTile", () => {
+ const ROOM_ID = "!roomId:example.org";
+ let mxEvent: MatrixEvent;
+ let room: Room;
+ let client: MatrixClient;
+ // let changeEvent: (event: MatrixEvent) => void;
+
+ function TestEventTile(props: Partial) {
+ // const [event] = useState(mxEvent);
+ // Give a way for a test to update the event prop.
+ // changeEvent = setEvent;
+
+ return ;
+ }
+
+ function getComponent(
+ overrides: Partial = {},
+ renderingType: TimelineRenderingType = TimelineRenderingType.Room,
+ ) {
+ const context = getRoomContext(room, {
+ timelineRenderingType: renderingType,
+ });
+ return render(
+
+
+
+ ,
+ ,
+ );
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ stubClient();
+ client = MatrixClientPeg.get();
+
+ room = new Room(ROOM_ID, client, client.getUserId(), {
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ });
+
+ jest.spyOn(client, "getRoom").mockReturnValue(room);
+ jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue();
+ jest.spyOn(SettingsStore, "getValue").mockImplementation(name => name === "feature_thread");
+
+ mxEvent = mkMessage({
+ room: room.roomId,
+ user: "@alice:example.org",
+ msg: "Hello world!",
+ event: true,
+ });
+ });
+
+ describe("EventTile thread summary", () => {
+ beforeEach(() => {
+ jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
+ });
+
+ it("removes the thread summary when thread is deleted", async () => {
+ const { rootEvent, events: [, reply] } = mkThread({
+ room,
+ client,
+ authorId: "@alice:example.org",
+ participantUserIds: ["@alice:example.org"],
+ length: 2, // root + 1 answer
+ });
+ getComponent({
+ mxEvent: rootEvent,
+ }, TimelineRenderingType.Room);
+
+ await waitFor(() => expect(screen.queryByTestId("thread-summary")).not.toBeNull());
+
+ const redaction = mkEvent({
+ event: true,
+ type: EventType.RoomRedaction,
+ user: "@alice:example.org",
+ room: room.roomId,
+ redacts: reply.getId(),
+ content: {},
+ });
+
+ act(() => room.processThreadedEvents([redaction], false));
+
+ await waitFor(() => expect(screen.queryByTestId("thread-summary")).toBeNull());
+ });
+ });
+
+ describe("EventTile renderingType: ThreadsList", () => {
+ beforeEach(() => {
+ const { rootEvent } = mkThread({
+ room,
+ client,
+ authorId: "@alice:example.org",
+ participantUserIds: ["@alice:example.org"],
+ });
+ mxEvent = rootEvent;
+ });
+
+ it("shows an unread notification bage", () => {
+ const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
+
+ expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0);
+
+ act(() => {
+ room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Total, 3);
+ });
+
+ expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
+ expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(0);
+
+ act(() => {
+ room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Highlight, 1);
+ });
+
+ expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1);
+ expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(1);
+ });
+ });
+});
diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx
index 8ebeda676a..debeb7b5e6 100644
--- a/test/components/views/rooms/MessageComposer-test.tsx
+++ b/test/components/views/rooms/MessageComposer-test.tsx
@@ -21,7 +21,8 @@ import { MatrixEvent, MsgType, RoomMember } from "matrix-js-sdk/src/matrix";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../test-utils";
-import MessageComposer from "../../../../src/components/views/rooms/MessageComposer";
+import MessageComposer, { MessageComposer as MessageComposerClass }
+ from "../../../../src/components/views/rooms/MessageComposer";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import RoomContext from "../../../../src/contexts/RoomContext";
@@ -39,7 +40,7 @@ import { SendMessageComposer } from "../../../../src/components/views/rooms/Send
import { E2EStatus } from "../../../../src/utils/ShieldUtils";
import { addTextToComposer } from "../../../test-utils/composer";
import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore";
-import { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer";
+import { SendWysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer";
// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement
// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts
@@ -106,7 +107,7 @@ describe("MessageComposer", () => {
it("should call notifyTimelineHeightChanged() for the same context", () => {
dis.dispatch({
action: "reply_to_event",
- context: (wrapper.instance as unknown as MessageComposer).context,
+ context: (wrapper.instance as unknown as MessageComposerClass).context,
});
wrapper.update();
@@ -207,7 +208,7 @@ describe("MessageComposer", () => {
let stateBefore: any;
beforeEach(() => {
- wrapper = wrapAndRender({ room });
+ wrapper = wrapAndRender({ room }).children();
stateBefore = { ...wrapper.instance().state };
resizeCallback("test", {});
wrapper.update();
@@ -220,7 +221,8 @@ describe("MessageComposer", () => {
describe("when a resize to narrow event occurred in UIStore", () => {
beforeEach(() => {
- wrapper = wrapAndRender({ room }, true, true);
+ wrapper = wrapAndRender({ room }, true, true).children();
+
wrapper.setState({
isMenuOpen: true,
isStickerPickerOpen: true,
@@ -240,7 +242,7 @@ describe("MessageComposer", () => {
describe("when a resize to non-narrow event occurred in UIStore", () => {
beforeEach(() => {
- wrapper = wrapAndRender({ room }, true, false);
+ wrapper = wrapAndRender({ room }, true, false).children();
wrapper.setState({
isMenuOpen: true,
isStickerPickerOpen: true,
@@ -346,14 +348,14 @@ describe("MessageComposer", () => {
});
});
- it('should render WysiwygComposer', () => {
+ it('should render SendWysiwygComposer', () => {
const room = mkStubRoom("!roomId:server", "Room 1", cli);
SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true);
const wrapper = wrapAndRender({ room });
SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, false);
- expect(wrapper.find(WysiwygComposer)).toBeTruthy();
+ expect(wrapper.find(SendWysiwygComposer)).toBeTruthy();
});
});
diff --git a/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx
new file mode 100644
index 0000000000..95d598a704
--- /dev/null
+++ b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx
@@ -0,0 +1,49 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { fireEvent, render } from "@testing-library/react";
+import React from "react";
+
+import {
+ StatelessNotificationBadge,
+} from "../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge";
+import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor";
+
+describe("NotificationBadge", () => {
+ describe("StatelessNotificationBadge", () => {
+ it("lets you click it", () => {
+ const cb = jest.fn();
+
+ const { container } = render( );
+
+ fireEvent.click(container.firstChild);
+ expect(cb).toHaveBeenCalledTimes(1);
+
+ fireEvent.mouseEnter(container.firstChild);
+ expect(cb).toHaveBeenCalledTimes(2);
+
+ fireEvent.mouseLeave(container.firstChild);
+ expect(cb).toHaveBeenCalledTimes(3);
+ });
+ });
+});
diff --git a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx
new file mode 100644
index 0000000000..20289dc6b9
--- /dev/null
+++ b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx
@@ -0,0 +1,132 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from "react";
+import "jest-mock";
+import { screen, act, render } from "@testing-library/react";
+import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
+import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
+import { mocked } from "jest-mock";
+import { EventStatus } from "matrix-js-sdk/src/models/event-status";
+
+import {
+ UnreadNotificationBadge,
+} from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge";
+import { mkMessage, stubClient } from "../../../../test-utils/test-utils";
+import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
+import * as RoomNotifs from "../../../../../src/RoomNotifs";
+
+jest.mock("../../../../../src/RoomNotifs");
+jest.mock('../../../../../src/RoomNotifs', () => ({
+ ...(jest.requireActual('../../../../../src/RoomNotifs') as Object),
+ getRoomNotifsState: jest.fn(),
+}));
+
+const ROOM_ID = "!roomId:example.org";
+let THREAD_ID;
+
+describe("UnreadNotificationBadge", () => {
+ let mockClient: MatrixClient;
+ let room: Room;
+
+ function getComponent(threadId?: string) {
+ return ;
+ }
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ stubClient();
+ mockClient = mocked(MatrixClientPeg.get());
+
+ room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ });
+ room.setUnreadNotificationCount(NotificationCountType.Total, 1);
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, 0);
+
+ room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1);
+ room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
+
+ jest.spyOn(RoomNotifs, "getRoomNotifsState").mockReturnValue(RoomNotifs.RoomNotifState.AllMessages);
+ });
+
+ it("renders unread notification badge", () => {
+ const { container } = render(getComponent());
+
+ expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
+ expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy();
+
+ act(() => {
+ room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
+ });
+
+ expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeTruthy();
+ });
+
+ it("renders unread thread notification badge", () => {
+ const { container } = render(getComponent(THREAD_ID));
+
+ expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy();
+ expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy();
+
+ act(() => {
+ room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1);
+ });
+
+ expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeTruthy();
+ });
+
+ it("hides unread notification badge", () => {
+ act(() => {
+ room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
+ room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
+ const { container } = render(getComponent(THREAD_ID));
+ expect(container.querySelector(".mx_NotificationBadge_visible")).toBeFalsy();
+ });
+ });
+
+ it("adds a warning for unsent messages", () => {
+ const evt = mkMessage({
+ room: room.roomId,
+ user: "@alice:example.org",
+ msg: "Hello world!",
+ event: true,
+ });
+ evt.status = EventStatus.NOT_SENT;
+
+ room.addPendingEvent(evt, "123");
+
+ render(getComponent());
+
+ expect(screen.queryByText("!")).not.toBeNull();
+ });
+
+ it("adds a warning for invites", () => {
+ jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
+ render(getComponent());
+ expect(screen.queryByText("!")).not.toBeNull();
+ });
+
+ it("hides counter for muted rooms", () => {
+ jest.spyOn(RoomNotifs, "getRoomNotifsState")
+ .mockReset()
+ .mockReturnValue(RoomNotifs.RoomNotifState.Mute);
+
+ const { container } = render(getComponent());
+ expect(container.querySelector(".mx_NotificationBadge")).toBeNull();
+ });
+});
diff --git a/test/components/views/rooms/RoomPreviewBar-test.tsx b/test/components/views/rooms/RoomPreviewBar-test.tsx
index d785f5b024..e7c8fa7281 100644
--- a/test/components/views/rooms/RoomPreviewBar-test.tsx
+++ b/test/components/views/rooms/RoomPreviewBar-test.tsx
@@ -15,18 +15,14 @@ limitations under the License.
*/
import React from 'react';
-import {
- renderIntoDocument,
- Simulate,
- findRenderedDOMComponentWithClass,
- act,
-} from 'react-dom/test-utils';
+import { render, fireEvent, RenderResult, waitFor } from "@testing-library/react";
import { Room, RoomMember, MatrixError, IContent } from 'matrix-js-sdk/src/matrix';
import { stubClient } from '../../../test-utils';
import { MatrixClientPeg } from '../../../../src/MatrixClientPeg';
import DMRoomMap from '../../../../src/utils/DMRoomMap';
import RoomPreviewBar from '../../../../src/components/views/rooms/RoomPreviewBar';
+import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
jest.mock('../../../../src/IdentityAuthClient', () => {
return jest.fn().mockImplementation(() => {
@@ -79,19 +75,18 @@ describe(' ', () => {
const defaultProps = {
room: createRoom(roomId, userId),
};
- const wrapper = renderIntoDocument(
- ,
- ) as React.Component;
- return findRenderedDOMComponentWithClass(wrapper, 'mx_RoomPreviewBar') as HTMLDivElement;
+ return render( );
};
- const isSpinnerRendered = (element: Element) => !!element.querySelector('.mx_Spinner');
- const getMessage = (element: Element) => element.querySelector('.mx_RoomPreviewBar_message');
- const getActions = (element: Element) => element.querySelector('.mx_RoomPreviewBar_actions');
- const getPrimaryActionButton = (element: Element) =>
- getActions(element).querySelector('.mx_AccessibleButton_kind_primary');
- const getSecondaryActionButton = (element: Element) =>
- getActions(element).querySelector('.mx_AccessibleButton_kind_secondary');
+ const isSpinnerRendered = (wrapper: RenderResult) => !!wrapper.container.querySelector('.mx_Spinner');
+ const getMessage = (wrapper: RenderResult) =>
+ wrapper.container.querySelector('.mx_RoomPreviewBar_message');
+ const getActions = (wrapper: RenderResult) =>
+ wrapper.container.querySelector('.mx_RoomPreviewBar_actions');
+ const getPrimaryActionButton = (wrapper: RenderResult) =>
+ getActions(wrapper).querySelector('.mx_AccessibleButton_kind_primary');
+ const getSecondaryActionButton = (wrapper: RenderResult) =>
+ getActions(wrapper).querySelector('.mx_AccessibleButton_kind_secondary');
beforeEach(() => {
stubClient();
@@ -128,6 +123,36 @@ describe(' ', () => {
expect(getMessage(component).textContent).toEqual('Join the conversation with an account');
});
+ it("should send room oob data to start login", async () => {
+ MatrixClientPeg.get().isGuest = jest.fn().mockReturnValue(true);
+ const component = getComponent({
+ oobData: {
+ name: "Room Name",
+ avatarUrl: "mxc://foo/bar",
+ inviterName: "Charlie",
+ },
+ });
+
+ const dispatcherSpy = jest.fn();
+ const dispatcherRef = defaultDispatcher.register(dispatcherSpy);
+
+ expect(getMessage(component).textContent).toEqual('Join the conversation with an account');
+ fireEvent.click(getPrimaryActionButton(component));
+
+ await waitFor(() => expect(dispatcherSpy).toHaveBeenCalledWith(expect.objectContaining({
+ screenAfterLogin: {
+ screen: 'room',
+ params: expect.objectContaining({
+ room_name: "Room Name",
+ room_avatar_url: "mxc://foo/bar",
+ inviter_name: "Charlie",
+ }),
+ },
+ })));
+
+ defaultDispatcher.unregister(dispatcherRef);
+ });
+
it('renders kicked message', () => {
const room = createRoom(roomId, otherUserId);
jest.spyOn(room, 'getMember').mockReturnValue(makeMockRoomMember({ isKicked: true }));
@@ -233,18 +258,14 @@ describe(' ', () => {
it('joins room on primary button click', () => {
const component = getComponent({ inviterName, room, onJoinClick, onRejectClick });
- act(() => {
- Simulate.click(getPrimaryActionButton(component));
- });
+ fireEvent.click(getPrimaryActionButton(component));
expect(onJoinClick).toHaveBeenCalled();
});
it('rejects invite on secondary button click', () => {
const component = getComponent({ inviterName, room, onJoinClick, onRejectClick });
- act(() => {
- Simulate.click(getSecondaryActionButton(component));
- });
+ fireEvent.click(getSecondaryActionButton(component));
expect(onRejectClick).toHaveBeenCalled();
});
@@ -296,9 +317,7 @@ describe(' ', () => {
await new Promise(setImmediate);
expect(getPrimaryActionButton(component)).toBeTruthy();
expect(getSecondaryActionButton(component)).toBeFalsy();
- act(() => {
- Simulate.click(getPrimaryActionButton(component));
- });
+ fireEvent.click(getPrimaryActionButton(component));
expect(onJoinClick).toHaveBeenCalled();
};
diff --git a/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx
new file mode 100644
index 0000000000..00d6a43f97
--- /dev/null
+++ b/test/components/views/rooms/wysiwyg_composer/EditWysiwygComposer-test.tsx
@@ -0,0 +1,226 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import "@testing-library/jest-dom";
+import React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import { WysiwygProps } from "@matrix-org/matrix-wysiwyg";
+
+import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
+import RoomContext from "../../../../../src/contexts/RoomContext";
+import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
+import { Action } from "../../../../../src/dispatcher/actions";
+import { IRoomState } from "../../../../../src/components/structures/RoomView";
+import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils";
+import { EditWysiwygComposer }
+ from "../../../../../src/components/views/rooms/wysiwyg_composer";
+import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
+
+const mockClear = jest.fn();
+
+let initialContent: string;
+const defaultContent = 'html ';
+let mockContent = defaultContent;
+
+// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement
+// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts
+jest.mock("@matrix-org/matrix-wysiwyg", () => ({
+ useWysiwyg: (props: WysiwygProps) => {
+ initialContent = props.initialContent;
+ return {
+ ref: { current: null },
+ content: mockContent,
+ isWysiwygReady: true,
+ wysiwyg: { clear: mockClear },
+ formattingStates: {
+ bold: 'enabled',
+ italic: 'enabled',
+ underline: 'enabled',
+ strikeThrough: 'enabled',
+ },
+ };
+ },
+}));
+
+describe('EditWysiwygComposer', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ mockContent = defaultContent;
+ });
+
+ const mockClient = createTestClient();
+ const mockEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser',
+ content: {
+ "msgtype": "m.text",
+ "body": "Replying to this",
+ "format": "org.matrix.custom.html",
+ "formatted_body": "Replying to this new content",
+ },
+ event: true,
+ });
+ const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any;
+ mockRoom.findEventById = jest.fn(eventId => {
+ return eventId === mockEvent.getId() ? mockEvent : null;
+ });
+
+ const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
+
+ const editorStateTransfer = new EditorStateTransfer(mockEvent);
+
+ const customRender = (disabled = false, _editorStateTransfer = editorStateTransfer) => {
+ return render(
+
+
+
+
+ ,
+ );
+ };
+
+ describe('Initialize with content', () => {
+ it('Should initialize useWysiwyg with html content', async () => {
+ // When
+ customRender(true);
+
+ // Then
+ expect(initialContent).toBe(mockEvent.getContent()['formatted_body']);
+ });
+
+ it('Should initialize useWysiwyg with plain text content', async () => {
+ // When
+ const mockEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser',
+ content: {
+ "msgtype": "m.text",
+ "body": "Replying to this",
+ },
+ event: true,
+ });
+ const editorStateTransfer = new EditorStateTransfer(mockEvent);
+
+ customRender(true, editorStateTransfer);
+
+ // Then
+ expect(initialContent).toBe(mockEvent.getContent().body);
+ });
+ });
+
+ describe('Edit and save actions', () => {
+ const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
+ afterEach(() => {
+ spyDispatcher.mockRestore();
+ });
+
+ it('Should cancel edit on cancel button click', async () => {
+ // When
+ customRender(true);
+ (await screen.findByText('Cancel')).click();
+
+ // Then
+ expect(spyDispatcher).toBeCalledWith({
+ action: Action.EditEvent,
+ event: null,
+ timelineRenderingType: defaultRoomContext.timelineRenderingType,
+ });
+ expect(spyDispatcher).toBeCalledWith({
+ action: Action.FocusSendMessageComposer,
+ context: defaultRoomContext.timelineRenderingType,
+ });
+ });
+
+ it('Should send message on save button click', async () => {
+ // When
+ const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
+
+ const renderer = customRender(true);
+
+ mockContent = 'my new content';
+ renderer.rerender(
+
+
+
+ );
+
+ (await screen.findByText('Save')).click();
+
+ // Then
+ const expectedContent = {
+ "body": ` * ${mockContent}`,
+ "format": "org.matrix.custom.html",
+ "formatted_body": ` * ${mockContent}`,
+ "m.new_content": {
+ "body": mockContent,
+ "format": "org.matrix.custom.html",
+ "formatted_body": mockContent,
+ "msgtype": "m.text",
+ },
+ "m.relates_to": {
+ "event_id": mockEvent.getId(),
+ "rel_type": "m.replace",
+ },
+ "msgtype": "m.text",
+ };
+ expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent);
+ expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' });
+ });
+ });
+
+ it('Should focus when receiving an Action.FocusEditMessageComposer action', async () => {
+ // Given we don't have focus
+ customRender();
+ screen.getByLabelText('Bold').focus();
+ expect(screen.getByRole('textbox')).not.toHaveFocus();
+
+ // When we send the right action
+ defaultDispatcher.dispatch({
+ action: Action.FocusEditMessageComposer,
+ context: null,
+ });
+
+ // Then the component gets the focus
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus());
+ });
+
+ it('Should not focus when disabled', async () => {
+ // Given we don't have focus and we are disabled
+ customRender(true);
+ screen.getByLabelText('Bold').focus();
+ expect(screen.getByRole('textbox')).not.toHaveFocus();
+
+ // When we send an action that would cause us to get focus
+ defaultDispatcher.dispatch({
+ action: Action.FocusEditMessageComposer,
+ context: null,
+ });
+ // (Send a second event to exercise the clearTimeout logic)
+ defaultDispatcher.dispatch({
+ action: Action.FocusEditMessageComposer,
+ context: null,
+ });
+
+ // Wait for event dispatch to happen
+ await new Promise((r) => setTimeout(r, 200));
+
+ // Then we don't get it because we are disabled
+ expect(screen.getByRole('textbox')).not.toHaveFocus();
+ });
+});
+
diff --git a/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx
new file mode 100644
index 0000000000..c85692d221
--- /dev/null
+++ b/test/components/views/rooms/wysiwyg_composer/SendWysiwygComposer-test.tsx
@@ -0,0 +1,181 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import "@testing-library/jest-dom";
+import React from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import { WysiwygProps } from "@matrix-org/matrix-wysiwyg";
+
+import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
+import RoomContext from "../../../../../src/contexts/RoomContext";
+import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
+import { Action } from "../../../../../src/dispatcher/actions";
+import { IRoomState } from "../../../../../src/components/structures/RoomView";
+import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils";
+import { SendWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer";
+import * as useComposerFunctions
+ from "../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useComposerFunctions";
+
+const mockClear = jest.fn();
+
+// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement
+// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts
+jest.mock("@matrix-org/matrix-wysiwyg", () => ({
+ useWysiwyg: (props: WysiwygProps) => {
+ return {
+ ref: { current: null },
+ content: 'html ',
+ isWysiwygReady: true,
+ wysiwyg: { clear: mockClear },
+ formattingStates: {
+ bold: 'enabled',
+ italic: 'enabled',
+ underline: 'enabled',
+ strikeThrough: 'enabled',
+ },
+ };
+ },
+}));
+
+describe('SendWysiwygComposer', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ const mockClient = createTestClient();
+ const mockEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser',
+ content: { "msgtype": "m.text", "body": "Replying to this" },
+ event: true,
+ });
+ const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any;
+ mockRoom.findEventById = jest.fn(eventId => {
+ return eventId === mockEvent.getId() ? mockEvent : null;
+ });
+
+ const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
+
+ const customRender = (
+ onChange = (_content: string) => void 0,
+ onSend = () => void 0,
+ disabled = false,
+ isRichTextEnabled = true) => {
+ return render(
+
+
+
+
+ ,
+ );
+ };
+
+ it('Should render WysiwygComposer when isRichTextEnabled is at true', () => {
+ // When
+ customRender(jest.fn(), jest.fn(), false, true);
+
+ // Then
+ expect(screen.getByTestId('WysiwygComposer')).toBeTruthy();
+ });
+
+ it('Should render PlainTextComposer when isRichTextEnabled is at false', () => {
+ // When
+ customRender(jest.fn(), jest.fn(), false, false);
+
+ // Then
+ expect(screen.getByTestId('PlainTextComposer')).toBeTruthy();
+ });
+
+ describe.each([{ isRichTextEnabled: true }, { isRichTextEnabled: false }])(
+ 'Should focus when receiving an Action.FocusSendMessageComposer action',
+ ({ isRichTextEnabled }) => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('Should focus when receiving an Action.FocusSendMessageComposer action', async () => {
+ // Given we don't have focus
+ customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
+
+ // When we send the right action
+ defaultDispatcher.dispatch({
+ action: Action.FocusSendMessageComposer,
+ context: null,
+ });
+
+ // Then the component gets the focus
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus());
+ });
+
+ it('Should focus and clear when receiving an Action.ClearAndFocusSendMessageComposer', async () => {
+ // Given we don't have focus
+ const mock = jest.spyOn(useComposerFunctions, 'useComposerFunctions');
+ mock.mockReturnValue({ clear: mockClear });
+ customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
+
+ // When we send the right action
+ defaultDispatcher.dispatch({
+ action: Action.ClearAndFocusSendMessageComposer,
+ context: null,
+ });
+
+ // Then the component gets the focus
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus());
+ expect(mockClear).toBeCalledTimes(1);
+
+ mock.mockRestore();
+ });
+
+ it('Should focus when receiving a reply_to_event action', async () => {
+ // Given we don't have focus
+ customRender(jest.fn(), jest.fn(), false, isRichTextEnabled);
+
+ // When we send the right action
+ defaultDispatcher.dispatch({
+ action: "reply_to_event",
+ context: null,
+ });
+
+ // Then the component gets the focus
+ await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus());
+ });
+
+ it('Should not focus when disabled', async () => {
+ // Given we don't have focus and we are disabled
+ customRender(jest.fn(), jest.fn(), true, isRichTextEnabled);
+ expect(screen.getByRole('textbox')).not.toHaveFocus();
+
+ // When we send an action that would cause us to get focus
+ defaultDispatcher.dispatch({
+ action: Action.FocusSendMessageComposer,
+ context: null,
+ });
+ // (Send a second event to exercise the clearTimeout logic)
+ defaultDispatcher.dispatch({
+ action: Action.FocusSendMessageComposer,
+ context: null,
+ });
+
+ // Wait for event dispatch to happen
+ await flushPromises();
+
+ // Then we don't get it because we are disabled
+ expect(screen.getByRole('textbox')).not.toHaveFocus();
+ });
+ });
+});
+
diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx
deleted file mode 100644
index 583bf1d36d..0000000000
--- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx
+++ /dev/null
@@ -1,238 +0,0 @@
-/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import "@testing-library/jest-dom";
-import React from "react";
-import { act, render, screen, waitFor } from "@testing-library/react";
-import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg";
-
-import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
-import RoomContext from "../../../../../src/contexts/RoomContext";
-import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
-import { Action } from "../../../../../src/dispatcher/actions";
-import { IRoomState } from "../../../../../src/components/structures/RoomView";
-import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer";
-import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils";
-import SettingsStore from "../../../../../src/settings/SettingsStore";
-
-// Work around missing ClipboardEvent type
-class MyClipbardEvent {}
-window.ClipboardEvent = MyClipbardEvent as any;
-
-let inputEventProcessor: InputEventProcessor | null = null;
-
-// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement
-// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts
-jest.mock("@matrix-org/matrix-wysiwyg", () => ({
- useWysiwyg: (props: WysiwygProps) => {
- inputEventProcessor = props.inputEventProcessor ?? null;
- return {
- ref: { current: null },
- content: 'html ',
- isWysiwygReady: true,
- wysiwyg: { clear: () => void 0 },
- formattingStates: {
- bold: 'enabled',
- italic: 'enabled',
- underline: 'enabled',
- strikeThrough: 'enabled',
- },
- };
- },
-}));
-
-describe('WysiwygComposer', () => {
- afterEach(() => {
- jest.resetAllMocks();
- });
-
- const permalinkCreator = jest.fn() as any;
- const mockClient = createTestClient();
- const mockEvent = mkEvent({
- type: "m.room.message",
- room: 'myfakeroom',
- user: 'myfakeuser',
- content: { "msgtype": "m.text", "body": "Replying to this" },
- event: true,
- });
- const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any;
- mockRoom.findEventById = jest.fn(eventId => {
- return eventId === mockEvent.getId() ? mockEvent : null;
- });
-
- const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
-
- let sendMessage: () => void;
- const customRender = (onChange = (_content: string) => void 0, disabled = false) => {
- return render(
-
-
-
- { (_sendMessage) => {
- sendMessage = _sendMessage;
- } }
-
- ,
- );
- };
-
- it('Should have contentEditable at false when disabled', () => {
- // When
- customRender(null, true);
-
- // Then
- expect(screen.getByRole('textbox')).toHaveAttribute('contentEditable', "false");
- });
-
- it('Should call onChange handler', (done) => {
- const html = 'html ';
- customRender((content) => {
- expect(content).toBe((html));
- done();
- });
- // act(() => callOnChange(html));
- });
-
- it('Should send message, call clear and focus the textbox', async () => {
- // When
- const html = 'html ';
- await new Promise((resolve) => {
- customRender(() => resolve(null));
- });
- act(() => sendMessage());
-
- // Then
- const expectedContent = {
- "body": html,
- "format": "org.matrix.custom.html",
- "formatted_body": html,
- "msgtype": "m.text",
- };
- expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent);
- expect(screen.getByRole('textbox')).toHaveFocus();
- });
-
- it('Should focus when receiving an Action.FocusSendMessageComposer action', async () => {
- // Given we don't have focus
- customRender(() => {}, false);
- expect(screen.getByRole('textbox')).not.toHaveFocus();
-
- // When we send the right action
- defaultDispatcher.dispatch({
- action: Action.FocusSendMessageComposer,
- context: null,
- });
-
- // Then the component gets the focus
- await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus());
- });
-
- it('Should focus when receiving a reply_to_event action', async () => {
- // Given we don't have focus
- customRender(() => {}, false);
- expect(screen.getByRole('textbox')).not.toHaveFocus();
-
- // When we send the right action
- defaultDispatcher.dispatch({
- action: "reply_to_event",
- context: null,
- });
-
- // Then the component gets the focus
- await waitFor(() => expect(screen.getByRole('textbox')).toHaveFocus());
- });
-
- it('Should not focus when disabled', async () => {
- // Given we don't have focus and we are disabled
- customRender(() => {}, true);
- expect(screen.getByRole('textbox')).not.toHaveFocus();
-
- // When we send an action that would cause us to get focus
- defaultDispatcher.dispatch({
- action: Action.FocusSendMessageComposer,
- context: null,
- });
- // (Send a second event to exercise the clearTimeout logic)
- defaultDispatcher.dispatch({
- action: Action.FocusSendMessageComposer,
- context: null,
- });
-
- // Wait for event dispatch to happen
- await new Promise((r) => setTimeout(r, 200));
-
- // Then we don't get it because we are disabled
- expect(screen.getByRole('textbox')).not.toHaveFocus();
- });
-
- it('sends a message when Enter is pressed', async () => {
- // Given a composer
- customRender(() => {}, false);
-
- // When we tell its inputEventProcesser that the user pressed Enter
- const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" });
- const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
- inputEventProcessor(event, wysiwyg);
-
- // Then it sends a message
- expect(mockClient.sendMessage).toBeCalledWith(
- "myfakeroom",
- null,
- {
- "body": "html ",
- "format": "org.matrix.custom.html",
- "formatted_body": "html ",
- "msgtype": "m.text",
- },
- );
- // TODO: plain text body above is wrong - will be fixed when we provide markdown for it
- });
-
- describe('when settings require Ctrl+Enter to send', () => {
- beforeEach(() => {
- jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
- if (name === "MessageComposerInput.ctrlEnterToSend") return true;
- });
- });
-
- it('does not send a message when Enter is pressed', async () => {
- // Given a composer
- customRender(() => {}, false);
-
- // When we tell its inputEventProcesser that the user pressed Enter
- const event = new InputEvent("input", { inputType: "insertParagraph" });
- const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
- inputEventProcessor(event, wysiwyg);
-
- // Then it does not send a message
- expect(mockClient.sendMessage).toBeCalledTimes(0);
- });
-
- it('sends a message when Ctrl+Enter is pressed', async () => {
- // Given a composer
- customRender(() => {}, false);
-
- // When we tell its inputEventProcesser that the user pressed Ctrl+Enter
- const event = new InputEvent("input", { inputType: "sendMessage" });
- const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
- inputEventProcessor(event, wysiwyg);
-
- // Then it sends a message
- expect(mockClient.sendMessage).toBeCalledTimes(1);
- });
- });
-});
-
diff --git a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx
similarity index 95%
rename from test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx
rename to test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx
index 6c3e8573ae..e935b62ae5 100644
--- a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx
+++ b/test/components/views/rooms/wysiwyg_composer/components/FormattingButtons-test.tsx
@@ -18,7 +18,8 @@ import React from 'react';
import { render, screen } from "@testing-library/react";
import userEvent from '@testing-library/user-event';
-import { FormattingButtons } from "../../../../../src/components/views/rooms/wysiwyg_composer/FormattingButtons";
+import { FormattingButtons }
+ from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons";
describe('FormattingButtons', () => {
const wysiwyg = {
diff --git a/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx
new file mode 100644
index 0000000000..5d1b03020c
--- /dev/null
+++ b/test/components/views/rooms/wysiwyg_composer/components/PlainTextComposer-test.tsx
@@ -0,0 +1,94 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import React from 'react';
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { PlainTextComposer }
+ from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/PlainTextComposer";
+
+// Work around missing ClipboardEvent type
+class MyClipboardEvent {}
+window.ClipboardEvent = MyClipboardEvent as any;
+
+describe('PlainTextComposer', () => {
+ const customRender = (
+ onChange = (_content: string) => void 0,
+ onSend = () => void 0,
+ disabled = false,
+ initialContent?: string) => {
+ return render(
+ ,
+ );
+ };
+
+ it('Should have contentEditable at false when disabled', () => {
+ // When
+ customRender(jest.fn(), jest.fn(), true);
+
+ // Then
+ expect(screen.getByRole('textbox')).toHaveAttribute('contentEditable', "false");
+ });
+
+ it('Should have focus', () => {
+ // When
+ customRender(jest.fn(), jest.fn(), false);
+
+ // Then
+ expect(screen.getByRole('textbox')).toHaveFocus();
+ });
+
+ it('Should call onChange handler', async () => {
+ // When
+ const content = 'content';
+ const onChange = jest.fn();
+ customRender(onChange, jest.fn());
+ await userEvent.type(screen.getByRole('textbox'), content);
+
+ // Then
+ expect(onChange).toBeCalledWith(content);
+ });
+
+ it('Should call onSend when Enter is pressed', async () => {
+ //When
+ const onSend = jest.fn();
+ customRender(jest.fn(), onSend);
+ await userEvent.type(screen.getByRole('textbox'), '{enter}');
+
+ // Then it sends a message
+ expect(onSend).toBeCalledTimes(1);
+ });
+
+ it('Should clear textbox content when clear is called', async () => {
+ //When
+ let composer;
+ render(
+
+ { (ref, composerFunctions) => {
+ composer = composerFunctions;
+ return null;
+ } }
+ ,
+ );
+ await userEvent.type(screen.getByRole('textbox'), 'content');
+ expect(screen.getByRole('textbox').innerHTML).toBe('content');
+ composer.clear();
+
+ // Then
+ expect(screen.getByRole('textbox').innerHTML).toBeFalsy();
+ });
+});
diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx
new file mode 100644
index 0000000000..7e3db04abc
--- /dev/null
+++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx
@@ -0,0 +1,138 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import "@testing-library/jest-dom";
+import React from "react";
+import { render, screen } from "@testing-library/react";
+import { InputEventProcessor, Wysiwyg, WysiwygProps } from "@matrix-org/matrix-wysiwyg";
+
+import { WysiwygComposer }
+ from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
+import SettingsStore from "../../../../../../src/settings/SettingsStore";
+
+// Work around missing ClipboardEvent type
+class MyClipboardEvent {}
+window.ClipboardEvent = MyClipboardEvent as any;
+
+let inputEventProcessor: InputEventProcessor | null = null;
+
+// The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement
+// See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts
+jest.mock("@matrix-org/matrix-wysiwyg", () => ({
+ useWysiwyg: (props: WysiwygProps) => {
+ inputEventProcessor = props.inputEventProcessor ?? null;
+ return {
+ ref: { current: null },
+ content: 'html ',
+ isWysiwygReady: true,
+ wysiwyg: { clear: () => void 0 },
+ formattingStates: {
+ bold: 'enabled',
+ italic: 'enabled',
+ underline: 'enabled',
+ strikeThrough: 'enabled',
+ },
+ };
+ },
+}));
+
+describe('WysiwygComposer', () => {
+ const customRender = (
+ onChange = (_content: string) => void 0,
+ onSend = () => void 0,
+ disabled = false,
+ initialContent?: string) => {
+ return render(
+ ,
+
+ );
+ };
+
+ it('Should have contentEditable at false when disabled', () => {
+ // When
+ customRender(jest.fn(), jest.fn(), true);
+
+ // Then
+ expect(screen.getByRole('textbox')).toHaveAttribute('contentEditable', "false");
+ });
+
+ it('Should have focus', () => {
+ // When
+ customRender(jest.fn(), jest.fn(), false);
+
+ // Then
+ expect(screen.getByRole('textbox')).toHaveFocus();
+ });
+
+ it('Should call onChange handler', (done) => {
+ const html = 'html ';
+ customRender((content) => {
+ expect(content).toBe((html));
+ done();
+ }, jest.fn());
+ });
+
+ it('Should call onSend when Enter is pressed ', () => {
+ //When
+ const onSend = jest.fn();
+ customRender(jest.fn(), onSend);
+
+ // When we tell its inputEventProcessor that the user pressed Enter
+ const event = new InputEvent("insertParagraph", { inputType: "insertParagraph" });
+ const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
+ inputEventProcessor(event, wysiwyg);
+
+ // Then it sends a message
+ expect(onSend).toBeCalledTimes(1);
+ });
+
+ describe('When settings require Ctrl+Enter to send', () => {
+ beforeEach(() => {
+ jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => {
+ if (name === "MessageComposerInput.ctrlEnterToSend") return true;
+ });
+ });
+
+ it('Should not call onSend when Enter is pressed', async () => {
+ // Given a composer
+ const onSend = jest.fn();
+ customRender(() => {}, onSend, false);
+
+ // When we tell its inputEventProcesser that the user pressed Enter
+ const event = new InputEvent("input", { inputType: "insertParagraph" });
+ const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
+ inputEventProcessor(event, wysiwyg);
+
+ // Then it does not send a message
+ expect(onSend).toBeCalledTimes(0);
+ });
+
+ it('Should send a message when Ctrl+Enter is pressed', async () => {
+ // Given a composer
+ const onSend = jest.fn();
+ customRender(() => {}, onSend, false);
+
+ // When we tell its inputEventProcesser that the user pressed Ctrl+Enter
+ const event = new InputEvent("input", { inputType: "sendMessage" });
+ const wysiwyg = { actions: { clear: () => {} } } as Wysiwyg;
+ inputEventProcessor(event, wysiwyg);
+
+ // Then it sends a message
+ expect(onSend).toBeCalledTimes(1);
+ });
+ });
+});
+
diff --git a/test/components/views/rooms/wysiwyg_composer/message-test.ts b/test/components/views/rooms/wysiwyg_composer/message-test.ts
deleted file mode 100644
index 79197a3188..0000000000
--- a/test/components/views/rooms/wysiwyg_composer/message-test.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-/*
-Copyright 2022 The Matrix.org Foundation C.I.C.
-
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-import { IRoomState } from "../../../../../src/components/structures/RoomView";
-import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/message";
-import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
-import { Layout } from "../../../../../src/settings/enums/Layout";
-import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils";
-import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
-import SettingsStore from "../../../../../src/settings/SettingsStore";
-import { SettingLevel } from "../../../../../src/settings/SettingLevel";
-import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
-
-describe('message', () => {
- const permalinkCreator = {
- forEvent(eventId: string): string {
- return "$$permalink$$";
- },
- } as RoomPermalinkCreator;
- const message = 'hello world ';
- const mockEvent = mkEvent({
- type: "m.room.message",
- room: 'myfakeroom',
- user: 'myfakeuser',
- content: { "msgtype": "m.text", "body": "Replying to this" },
- event: true,
- });
-
- afterEach(() => {
- jest.resetAllMocks();
- });
-
- describe('createMessageContent', () => {
- it("Should create html message", () => {
- // When
- const content = createMessageContent(message, { permalinkCreator });
-
- // Then
- expect(content).toEqual({
- "body": message,
- "format": "org.matrix.custom.html",
- "formatted_body": message,
- "msgtype": "m.text",
- });
- });
-
- it('Should add reply to message content', () => {
- // When
- const content = createMessageContent(message, { permalinkCreator, replyToEvent: mockEvent });
-
- // Then
- expect(content).toEqual({
- "body": "> Replying to this\n\nhello world ",
- "format": "org.matrix.custom.html",
- "formatted_body": "In reply to " +
- " myfakeuser "+
- " Replying to thishello world ",
- "msgtype": "m.text",
- "m.relates_to": {
- "m.in_reply_to": {
- "event_id": mockEvent.getId(),
- },
- },
- });
- });
-
- it("Should add relation to message", () => {
- // When
- const relation = {
- rel_type: "m.thread",
- event_id: "myFakeThreadId",
- };
- const content = createMessageContent(message, { permalinkCreator, relation });
-
- // Then
- expect(content).toEqual({
- "body": message,
- "format": "org.matrix.custom.html",
- "formatted_body": message,
- "msgtype": "m.text",
- "m.relates_to": {
- "event_id": "myFakeThreadId",
- "rel_type": "m.thread",
- },
- });
- });
- });
-
- describe('sendMessage', () => {
- const mockClient = createTestClient();
- const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any;
- mockRoom.findEventById = jest.fn(eventId => {
- return eventId === mockEvent.getId() ? mockEvent : null;
- });
-
- const defaultRoomContext: IRoomState = {
- room: mockRoom,
- roomLoading: true,
- peekLoading: false,
- shouldPeek: true,
- membersLoaded: false,
- numUnreadMessages: 0,
- canPeek: false,
- showApps: false,
- isPeeking: false,
- showRightPanel: true,
- joining: false,
- atEndOfLiveTimeline: true,
- showTopUnreadMessagesBar: false,
- statusBarVisible: false,
- canReact: false,
- canSendMessages: false,
- layout: Layout.Group,
- lowBandwidth: false,
- alwaysShowTimestamps: false,
- showTwelveHourTimestamps: false,
- readMarkerInViewThresholdMs: 3000,
- readMarkerOutOfViewThresholdMs: 30000,
- showHiddenEvents: false,
- showReadReceipts: true,
- showRedactions: true,
- showJoinLeaves: true,
- showAvatarChanges: true,
- showDisplaynameChanges: true,
- matrixClientIsReady: false,
- timelineRenderingType: TimelineRenderingType.Room,
- liveTimeline: undefined,
- canSelfRedact: false,
- resizing: false,
- narrow: false,
- activeCall: null,
- };
-
- const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
-
- it('Should not send empty html message', async () => {
- // When
- await sendMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
-
- // Then
- expect(mockClient.sendMessage).toBeCalledTimes(0);
- expect(spyDispatcher).toBeCalledTimes(0);
- });
-
- it('Should send html message', async () => {
- // When
- await sendMessage(message, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
-
- // Then
- const expectedContent = {
- "body": "hello world ",
- "format": "org.matrix.custom.html",
- "formatted_body": "hello world ",
- "msgtype": "m.text",
- };
- expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent);
- expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' });
- });
-
- it('Should send reply to html message', async () => {
- const mockReplyEvent = mkEvent({
- type: "m.room.message",
- room: 'myfakeroom',
- user: 'myfakeuser2',
- content: { "msgtype": "m.text", "body": "My reply" },
- event: true,
- });
-
- // When
- await sendMessage(message, {
- roomContext: defaultRoomContext,
- mxClient: mockClient,
- permalinkCreator,
- replyToEvent: mockReplyEvent,
- });
-
- // Then
- expect(spyDispatcher).toBeCalledWith({
- action: 'reply_to_event',
- event: null,
- context: defaultRoomContext.timelineRenderingType,
- });
-
- const expectedContent = {
- "body": "> My reply\n\nhello world ",
- "format": "org.matrix.custom.html",
- "formatted_body": "In reply to " +
- " myfakeuser2 " +
- " My replyhello world ",
- "msgtype": "m.text",
- "m.relates_to": {
- "m.in_reply_to": {
- "event_id": mockReplyEvent.getId(),
- },
- },
- };
- expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent);
- });
-
- it('Should scroll to bottom after sending a html message', async () => {
- // When
- SettingsStore.setValue("scrollToBottomOnMessageSent", null, SettingLevel.DEVICE, true);
- await sendMessage(message, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
-
- // Then
- expect(spyDispatcher).toBeCalledWith(
- { action: 'scroll_to_bottom', timelineRenderingType: defaultRoomContext.timelineRenderingType },
- );
- });
-
- it('Should handle emojis', async () => {
- // When
- await sendMessage('🎉', { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
-
- // Then
- expect(spyDispatcher).toBeCalledWith(
- { action: 'effects.confetti' },
- );
- });
- });
-});
diff --git a/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts
new file mode 100644
index 0000000000..4c7028749c
--- /dev/null
+++ b/test/components/views/rooms/wysiwyg_composer/utils/createMessageContent-test.ts
@@ -0,0 +1,133 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { mkEvent } from "../../../../../test-utils";
+import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks";
+import { createMessageContent }
+ from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/createMessageContent";
+
+describe('createMessageContent', () => {
+ const permalinkCreator = {
+ forEvent(eventId: string): string {
+ return "$$permalink$$";
+ },
+ } as RoomPermalinkCreator;
+ const message = 'hello world ';
+ const mockEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser',
+ content: { "msgtype": "m.text", "body": "Replying to this" },
+ event: true,
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it("Should create html message", () => {
+ // When
+ const content = createMessageContent(message, true, { permalinkCreator });
+
+ // Then
+ expect(content).toEqual({
+ "body": "hello world",
+ "format": "org.matrix.custom.html",
+ "formatted_body": message,
+ "msgtype": "m.text",
+ });
+ });
+
+ it('Should add reply to message content', () => {
+ // When
+ const content = createMessageContent(message, true, { permalinkCreator, replyToEvent: mockEvent });
+
+ // Then
+ expect(content).toEqual({
+ "body": "> Replying to this\n\nhello world",
+ "format": "org.matrix.custom.html",
+ "formatted_body": "In reply to " +
+ " myfakeuser "+
+ " Replying to thishello world ",
+ "msgtype": "m.text",
+ "m.relates_to": {
+ "m.in_reply_to": {
+ "event_id": mockEvent.getId(),
+ },
+ },
+ });
+ });
+
+ it("Should add relation to message", () => {
+ // When
+ const relation = {
+ rel_type: "m.thread",
+ event_id: "myFakeThreadId",
+ };
+ const content = createMessageContent(message, true, { permalinkCreator, relation });
+
+ // Then
+ expect(content).toEqual({
+ "body": "hello world",
+ "format": "org.matrix.custom.html",
+ "formatted_body": message,
+ "msgtype": "m.text",
+ "m.relates_to": {
+ "event_id": "myFakeThreadId",
+ "rel_type": "m.thread",
+ },
+ });
+ });
+
+ it('Should add fields related to edition', () => {
+ // When
+ const editedEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser2',
+ content: {
+ "msgtype": "m.text",
+ "body": "First message",
+ "formatted_body": "First Message ",
+ "m.relates_to": {
+ "m.in_reply_to": {
+ "event_id": 'eventId',
+ },
+ } },
+ event: true,
+ });
+ const content =
+ createMessageContent(message, true, { permalinkCreator, editedEvent });
+
+ // Then
+ expect(content).toEqual({
+ "body": " * hello world",
+ "format": "org.matrix.custom.html",
+ "formatted_body": ` * ${message}`,
+ "msgtype": "m.text",
+ "m.new_content": {
+ "body": "hello world",
+ "format": "org.matrix.custom.html",
+ "formatted_body": message,
+ "msgtype": "m.text",
+ },
+ "m.relates_to": {
+ "event_id": editedEvent.getId(),
+ "rel_type": "m.replace",
+ },
+ });
+ });
+});
diff --git a/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts
new file mode 100644
index 0000000000..0829b19adb
--- /dev/null
+++ b/test/components/views/rooms/wysiwyg_composer/utils/message-test.ts
@@ -0,0 +1,237 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { EventStatus } from "matrix-js-sdk/src/matrix";
+
+import { IRoomState } from "../../../../../../src/components/structures/RoomView";
+import { editMessage, sendMessage }
+ from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/message";
+import { createTestClient, getRoomContext, mkEvent, mkStubRoom } from "../../../../../test-utils";
+import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
+import SettingsStore from "../../../../../../src/settings/SettingsStore";
+import { SettingLevel } from "../../../../../../src/settings/SettingLevel";
+import { RoomPermalinkCreator } from "../../../../../../src/utils/permalinks/Permalinks";
+import EditorStateTransfer from "../../../../../../src/utils/EditorStateTransfer";
+import * as ConfirmRedactDialog
+ from "../../../../../../src/components/views/dialogs/ConfirmRedactDialog";
+
+describe('message', () => {
+ const permalinkCreator = {
+ forEvent(eventId: string): string {
+ return "$$permalink$$";
+ },
+ } as RoomPermalinkCreator;
+ const message = 'hello world ';
+ const mockEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser',
+ content: {
+ "msgtype": "m.text",
+ "body": "Replying to this",
+ "format": 'org.matrix.custom.html',
+ "formatted_body": 'Replying to this',
+ },
+ event: true,
+ });
+
+ const mockClient = createTestClient();
+ const mockRoom = mkStubRoom('myfakeroom', 'myfakeroom', mockClient) as any;
+ mockRoom.findEventById = jest.fn(eventId => {
+ return eventId === mockEvent.getId() ? mockEvent : null;
+ });
+
+ const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
+
+ const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('sendMessage', () => {
+ it('Should not send empty html message', async () => {
+ // When
+ await sendMessage('', true, { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator });
+
+ // Then
+ expect(mockClient.sendMessage).toBeCalledTimes(0);
+ expect(spyDispatcher).toBeCalledTimes(0);
+ });
+
+ it('Should send html message', async () => {
+ // When
+ await sendMessage(
+ message,
+ true,
+ { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator },
+ );
+
+ // Then
+ const expectedContent = {
+ "body": "hello world",
+ "format": "org.matrix.custom.html",
+ "formatted_body": "hello world ",
+ "msgtype": "m.text",
+ };
+ expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent);
+ expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' });
+ });
+
+ it('Should send reply to html message', async () => {
+ const mockReplyEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser2',
+ content: { "msgtype": "m.text", "body": "My reply" },
+ event: true,
+ });
+
+ // When
+ await sendMessage(message, true, {
+ roomContext: defaultRoomContext,
+ mxClient: mockClient,
+ permalinkCreator,
+ replyToEvent: mockReplyEvent,
+ });
+
+ // Then
+ expect(spyDispatcher).toBeCalledWith({
+ action: 'reply_to_event',
+ event: null,
+ context: defaultRoomContext.timelineRenderingType,
+ });
+
+ const expectedContent = {
+ "body": "> My reply\n\nhello world",
+ "format": "org.matrix.custom.html",
+ "formatted_body": "In reply to " +
+ " myfakeuser2 " +
+ " My replyhello world ",
+ "msgtype": "m.text",
+ "m.relates_to": {
+ "m.in_reply_to": {
+ "event_id": mockReplyEvent.getId(),
+ },
+ },
+ };
+ expect(mockClient.sendMessage).toBeCalledWith('myfakeroom', null, expectedContent);
+ });
+
+ it('Should scroll to bottom after sending a html message', async () => {
+ // When
+ SettingsStore.setValue("scrollToBottomOnMessageSent", null, SettingLevel.DEVICE, true);
+ await sendMessage(
+ message,
+ true,
+ { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator },
+ );
+
+ // Then
+ expect(spyDispatcher).toBeCalledWith(
+ { action: 'scroll_to_bottom', timelineRenderingType: defaultRoomContext.timelineRenderingType },
+ );
+ });
+
+ it('Should handle emojis', async () => {
+ // When
+ await sendMessage(
+ '🎉',
+ false,
+ { roomContext: defaultRoomContext, mxClient: mockClient, permalinkCreator },
+ );
+
+ // Then
+ expect(spyDispatcher).toBeCalledWith(
+ { action: 'effects.confetti' },
+ );
+ });
+ });
+
+ describe('editMessage', () => {
+ const editorStateTransfer = new EditorStateTransfer(mockEvent);
+
+ it('Should cancel editing and ask for event removal when message is empty', async () => {
+ // When
+ const mockCreateRedactEventDialog = jest.spyOn(ConfirmRedactDialog, 'createRedactEventDialog');
+
+ const mockEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser',
+ content: { "msgtype": "m.text", "body": "Replying to this" },
+ event: true,
+ });
+ const replacingEvent = mkEvent({
+ type: "m.room.message",
+ room: 'myfakeroom',
+ user: 'myfakeuser',
+ content: { "msgtype": "m.text", "body": "ReplacingEvent" },
+ event: true,
+ });
+ replacingEvent.setStatus(EventStatus.QUEUED);
+ mockEvent.makeReplaced(replacingEvent);
+ const editorStateTransfer = new EditorStateTransfer(mockEvent);
+
+ await editMessage('', { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer });
+
+ // Then
+ expect(mockClient.sendMessage).toBeCalledTimes(0);
+ expect(mockClient.cancelPendingEvent).toBeCalledTimes(1);
+ expect(mockCreateRedactEventDialog).toBeCalledTimes(1);
+ expect(spyDispatcher).toBeCalledTimes(0);
+ });
+
+ it('Should do nothing if the content is unmodified', async () => {
+ // When
+ await editMessage(
+ mockEvent.getContent().body,
+ { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer });
+
+ // Then
+ expect(mockClient.sendMessage).toBeCalledTimes(0);
+ });
+
+ it('Should send a message when the content is modified', async () => {
+ // When
+ const newMessage = `${mockEvent.getContent().body} new content`;
+ await editMessage(
+ newMessage,
+ { roomContext: defaultRoomContext, mxClient: mockClient, editorStateTransfer });
+
+ // Then
+ const { msgtype, format } = mockEvent.getContent();
+ const expectedContent = {
+ "body": ` * ${newMessage}`,
+ "formatted_body": ` * ${newMessage}`,
+ "m.new_content": {
+ "body": "Replying to this new content",
+ "format": "org.matrix.custom.html",
+ "formatted_body": "Replying to this new content",
+ "msgtype": "m.text",
+ },
+ "m.relates_to": {
+ "event_id": mockEvent.getId(),
+ "rel_type": "m.replace",
+ },
+ msgtype,
+ format,
+ };
+ expect(mockClient.sendMessage).toBeCalledWith(mockEvent.getRoomId(), null, expectedContent);
+ expect(spyDispatcher).toBeCalledWith({ action: 'message_sent' });
+ });
+ });
+});
diff --git a/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap
index 0edf12289d..6651a91b69 100644
--- a/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap
+++ b/test/components/views/settings/devices/__snapshots__/CurrentDeviceSection-test.tsx.snap
@@ -50,6 +50,13 @@ HTMLCollection [
class="mx_DeviceSecurityCard_description"
>
Verify or sign out from this session for best security and reliability.
+
+ Learn more
+
renders device and correct security card when
class="mx_DeviceSecurityCard_description"
>
Verify or sign out from this session for best security and reliability.
+
+ Learn more
+
renders device and correct security card when
class="mx_DeviceSecurityCard_description"
>
Verify or sign out from this session for best security and reliability.
+
+ Learn more
+
- Please be aware that session names are also visible to people you communicate with
+ Please be aware that session names are also visible to people you communicate with.
+
+ Learn more
+
renders a verified device 1`] = `
class="mx_DeviceSecurityCard_description"
>
This session is ready for secure messaging.
+
+ Learn more
+
@@ -152,6 +159,13 @@ exports[`
renders device with metadata 1`] = `
class="mx_DeviceSecurityCard_description"
>
Verify or sign out from this session for best security and reliability.
+
+ Learn more
+
@@ -354,6 +368,13 @@ exports[` renders device without metadata 1`] = `
class="mx_DeviceSecurityCard_description"
>
Verify or sign out from this session for best security and reliability.
+
+ Learn more
+
diff --git a/test/components/views/settings/devices/__snapshots__/FilteredDeviceList-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/FilteredDeviceList-test.tsx.snap
index c0f5b9af98..62a6cd94d1 100644
--- a/test/components/views/settings/devices/__snapshots__/FilteredDeviceList-test.tsx.snap
+++ b/test/components/views/settings/devices/__snapshots__/FilteredDeviceList-test.tsx.snap
@@ -37,7 +37,16 @@ HTMLCollection [
- Consider signing out from old sessions (90 days or older) you don't use anymore
+
+ Consider signing out from old sessions (90 days or older) you don't use anymore.
+
+ Learn more
+
+
@@ -72,7 +81,16 @@ HTMLCollection [
- Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
+
+ Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
+
+ Learn more
+
+
@@ -107,7 +125,16 @@ HTMLCollection [
- For best security, sign out from any session that you don't recognize or use anymore.
+
+ For best security, sign out from any session that you don't recognize or use anymore.
+
+ Learn more
+
+
diff --git a/test/components/views/settings/devices/__snapshots__/SecurityRecommendations-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/SecurityRecommendations-test.tsx.snap
index a854601344..f1c0f12cf5 100644
--- a/test/components/views/settings/devices/__snapshots__/SecurityRecommendations-test.tsx.snap
+++ b/test/components/views/settings/devices/__snapshots__/SecurityRecommendations-test.tsx.snap
@@ -46,6 +46,13 @@ exports[` renders both cards when user has both unver
class="mx_DeviceSecurityCard_description"
>
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
+
+ Learn more
+
renders both cards when user has both unver
class="mx_DeviceSecurityCard_description"
>
Consider signing out from old sessions (90 days or older) you don't use anymore
+
+ Learn more
+
renders inactive devices section when user
class="mx_DeviceSecurityCard_description"
>
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
+
+ Learn more
+
renders inactive devices section when user
class="mx_DeviceSecurityCard_description"
>
Consider signing out from old sessions (90 days or older) you don't use anymore
+
+ Learn more
+
renders unverified devices section when use
class="mx_DeviceSecurityCard_description"
>
Verify your sessions for enhanced secure messaging or sign out from those you don't recognize or use anymore.
+
+ Learn more
+
renders unverified devices section when use
class="mx_DeviceSecurityCard_description"
>
Consider signing out from old sessions (90 days or older) you don't use anymore
+
+ Learn more
+
', () => {
fireEvent.click(dropdown as Element);
// tick to let dropdown render
- await flushPromisesWithFakeTimers();
+ await flushPromises();
fireEvent.click(container.querySelector(`#device-list-filter__${option}`) as Element);
});
@@ -152,6 +152,19 @@ describe(' ', () => {
getByTestId: ReturnType['getByTestId'],
): boolean => !!(getByTestId('device-select-all-checkbox') as HTMLInputElement).checked;
+ const confirmSignout = async (
+ getByTestId: ReturnType['getByTestId'],
+ confirm = true,
+ ): Promise => {
+ // modal has sleeps in rendering process :(
+ await sleep(100);
+ const buttonId = confirm ? 'dialog-primary-button' : 'dialog-cancel-button';
+ fireEvent.click(getByTestId(buttonId));
+
+ // flush the confirmation promise
+ await flushPromises();
+ };
+
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(logger, 'error').mockRestore();
@@ -188,6 +201,10 @@ describe(' ', () => {
});
}
});
+
+ // sometimes a verification modal is in modal state when these tests run
+ // make sure the coast is clear
+ Modal.closeCurrentModal('');
});
it('renders spinner while devices load', () => {
@@ -201,7 +218,7 @@ describe(' ', () => {
expect(mockClient.getDevices).toHaveBeenCalled();
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
expect(container.getElementsByClassName('mx_Spinner').length).toBeFalsy();
});
@@ -213,7 +230,7 @@ describe(' ', () => {
const { container } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
expect(container.getElementsByClassName('mx_Spinner').length).toBeFalsy();
});
@@ -226,7 +243,7 @@ describe(' ', () => {
render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
// called for each device despite error
@@ -246,7 +263,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
expect(mockCrossSigningInfo.checkDeviceTrust).toHaveBeenCalledTimes(2);
@@ -270,7 +287,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
// twice for each device
@@ -287,7 +304,7 @@ describe(' ', () => {
const { getByTestId, queryByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
@@ -300,7 +317,7 @@ describe(' ', () => {
const { queryByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
expect(queryByTestId('other-sessions-section')).toBeFalsy();
@@ -313,7 +330,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
expect(getByTestId('other-sessions-section')).toBeTruthy();
@@ -324,13 +341,13 @@ describe(' ', () => {
const { getByTestId, container } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
fireEvent.click(getByTestId('unverified-devices-cta'));
// our session manager waits a tick for rerender
- await flushPromisesWithFakeTimers();
+ await flushPromises();
// unverified filter is set
expect(container.querySelector('.mx_FilteredDeviceListHeader')).toMatchSnapshot();
@@ -346,7 +363,7 @@ describe(' ', () => {
mockClient.getDevices.mockResolvedValue({ devices: [] });
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
expect(getByTestId('current-session-menu').getAttribute('aria-disabled')).toBeTruthy();
@@ -357,7 +374,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
expect(getByTestId('current-session-section')).toMatchSnapshot();
@@ -369,7 +386,7 @@ describe(' ', () => {
const modalSpy = jest.spyOn(Modal, 'createDialog');
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
// click verify button from current session section
@@ -387,7 +404,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
expect(getByTestId('current-session-section')).toMatchSnapshot();
@@ -402,7 +419,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
const otherSessionsSection = getByTestId('other-sessions-section');
@@ -418,7 +435,7 @@ describe(' ', () => {
const { getByTestId, queryByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesOlderMobileDevice.device_id);
@@ -449,7 +466,7 @@ describe(' ', () => {
const { getByTestId, queryByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesOlderMobileDevice.device_id);
@@ -475,7 +492,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
@@ -504,7 +521,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
@@ -534,7 +551,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
@@ -553,7 +570,7 @@ describe(' ', () => {
const { getByTestId, getByLabelText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
fireEvent.click(getByTestId('current-session-menu'));
@@ -568,7 +585,7 @@ describe(' ', () => {
const { getByTestId, queryByLabelText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
fireEvent.click(getByTestId('current-session-menu'));
@@ -582,11 +599,12 @@ describe(' ', () => {
const { getByTestId, getByLabelText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
fireEvent.click(getByTestId('current-session-menu'));
fireEvent.click(getByLabelText('Sign out all other sessions'));
+ await confirmSignout(getByTestId);
// other devices deleted, excluding current device
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([
@@ -611,7 +629,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
@@ -622,6 +640,8 @@ describe(' ', () => {
) as Element;
fireEvent.click(signOutButton);
+ await confirmSignout(getByTestId);
+
// sign out button is disabled with spinner
expect((deviceDetails.querySelector(
'[data-testid="device-detail-sign-out-cta"]',
@@ -631,12 +651,37 @@ describe(' ', () => {
[alicesMobileDevice.device_id], undefined,
);
- await flushPromisesWithFakeTimers();
+ await flushPromises();
// devices refreshed
expect(mockClient.getDevices).toHaveBeenCalled();
});
+ it('deletes a device when interactive auth is not required', async () => {
+ const { getByTestId } = render(getComponent());
+
+ await act(async () => {
+ await flushPromises();
+ });
+
+ toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
+
+ const deviceDetails = getByTestId(`device-detail-${alicesMobileDevice.device_id}`);
+ const signOutButton = deviceDetails.querySelector(
+ '[data-testid="device-detail-sign-out-cta"]',
+ ) as Element;
+ fireEvent.click(signOutButton);
+
+ await confirmSignout(getByTestId, false);
+
+ // doesnt enter loading state
+ expect((deviceDetails.querySelector(
+ '[data-testid="device-detail-sign-out-cta"]',
+ ) as Element).getAttribute('aria-disabled')).toEqual(null);
+ // delete not called
+ expect(mockClient.deleteMultipleDevices).not.toHaveBeenCalled();
+ });
+
it('deletes a device when interactive auth is required', async () => {
mockClient.deleteMultipleDevices
// require auth
@@ -652,7 +697,7 @@ describe(' ', () => {
const { getByTestId, getByLabelText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
// reset mock count after initial load
@@ -665,8 +710,9 @@ describe(' ', () => {
'[data-testid="device-detail-sign-out-cta"]',
) as Element;
fireEvent.click(signOutButton);
+ await confirmSignout(getByTestId);
- await flushPromisesWithFakeTimers();
+ await flushPromises();
// modal rendering has some weird sleeps
await sleep(100);
@@ -683,7 +729,7 @@ describe(' ', () => {
fireEvent.submit(getByLabelText('Password'));
});
- await flushPromisesWithFakeTimers();
+ await flushPromises();
// called again with auth
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith([alicesMobileDevice.device_id],
@@ -708,7 +754,7 @@ describe(' ', () => {
const { getByTestId, getByLabelText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
@@ -718,13 +764,14 @@ describe(' ', () => {
'[data-testid="device-detail-sign-out-cta"]',
) as Element;
fireEvent.click(signOutButton);
+ await confirmSignout(getByTestId);
// button is loading
expect((deviceDetails.querySelector(
'[data-testid="device-detail-sign-out-cta"]',
) as Element).getAttribute('aria-disabled')).toEqual("true");
- await flushPromisesWithFakeTimers();
+ await flushPromises();
// Modal rendering has some weird sleeps.
// Resetting ourselves twice in the main loop gives modal the chance to settle.
@@ -743,7 +790,7 @@ describe(' ', () => {
fireEvent.click(getByLabelText('Close dialog'));
});
- await flushPromisesWithFakeTimers();
+ await flushPromises();
// not called again
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledTimes(1);
@@ -761,12 +808,21 @@ describe(' ', () => {
alicesDevice, alicesMobileDevice, alicesOlderMobileDevice,
alicesInactiveDevice,
] });
- mockClient.deleteMultipleDevices.mockResolvedValue({});
+ // get a handle for resolving the delete call
+ // because promise flushing after the confirm modal is resolving this too
+ // and we want to test the loading state here
+ let resolveDeleteRequest;
+ mockClient.deleteMultipleDevices.mockImplementation(() => {
+ const promise = new Promise(resolve => {
+ resolveDeleteRequest = resolve;
+ });
+ return promise;
+ });
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
@@ -774,6 +830,8 @@ describe(' ', () => {
fireEvent.click(getByTestId('sign-out-selection-cta'));
+ await confirmSignout(getByTestId);
+
// buttons disabled in list header
expect(getByTestId('sign-out-selection-cta').getAttribute('aria-disabled')).toBeTruthy();
expect(getByTestId('cancel-selection-cta').getAttribute('aria-disabled')).toBeTruthy();
@@ -800,6 +858,8 @@ describe(' ', () => {
],
undefined,
);
+
+ resolveDeleteRequest?.();
});
});
});
@@ -819,15 +879,15 @@ describe(' ', () => {
fireEvent.change(input, { target: { value: newDeviceName } });
fireEvent.click(getByTestId('device-rename-submit-cta'));
- await flushPromisesWithFakeTimers();
- await flushPromisesWithFakeTimers();
+ await flushPromises();
+ await flushPromises();
};
it('renames current session', async () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
const newDeviceName = 'new device name';
@@ -844,7 +904,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
const newDeviceName = 'new device name';
@@ -861,7 +921,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
await updateDeviceName(getByTestId, alicesDevice, alicesDevice.display_name);
@@ -875,7 +935,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
await updateDeviceName(getByTestId, alicesDevice, '');
@@ -891,13 +951,13 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
const newDeviceName = 'new device name';
await updateDeviceName(getByTestId, alicesDevice, newDeviceName);
- await flushPromisesWithFakeTimers();
+ await flushPromises();
expect(logSpy).toHaveBeenCalledWith("Error setting session display name", error);
@@ -917,7 +977,7 @@ describe(' ', () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
@@ -941,7 +1001,7 @@ describe(' ', () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
@@ -961,7 +1021,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
@@ -970,7 +1030,7 @@ describe(' ', () => {
fireEvent.click(getByTestId('unverified-devices-cta'));
// our session manager waits a tick for rerender
- await flushPromisesWithFakeTimers();
+ await flushPromises();
// unselected
expect(isDeviceSelected(getByTestId, alicesOlderMobileDevice.device_id)).toBeFalsy();
@@ -981,7 +1041,7 @@ describe(' ', () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
fireEvent.click(getByTestId('device-select-all-checkbox'));
@@ -999,7 +1059,7 @@ describe(' ', () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceSelection(getByTestId, alicesMobileDevice.device_id);
@@ -1019,7 +1079,7 @@ describe(' ', () => {
const { getByTestId, getByText } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
fireEvent.click(getByTestId('device-select-all-checkbox'));
@@ -1042,7 +1102,7 @@ describe(' ', () => {
const { getByTestId, container } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
// filter for inactive sessions
@@ -1055,6 +1115,7 @@ describe(' ', () => {
// sign out of all selected sessions
fireEvent.click(getByTestId('sign-out-selection-cta'));
+ await confirmSignout(getByTestId);
// only called with session from active filter
expect(mockClient.deleteMultipleDevices).toHaveBeenCalledWith(
@@ -1071,7 +1132,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesMobileDevice.device_id);
@@ -1092,7 +1153,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
@@ -1116,7 +1177,7 @@ describe(' ', () => {
const { getByTestId } = render(getComponent());
await act(async () => {
- await flushPromisesWithFakeTimers();
+ await flushPromises();
});
toggleDeviceDetails(getByTestId, alicesDevice.device_id);
diff --git a/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap
index 2ff8c77b72..b875caf0d2 100644
--- a/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap
+++ b/test/components/views/settings/tabs/user/__snapshots__/SessionManagerTab-test.tsx.snap
@@ -130,6 +130,13 @@ exports[` current session section renders current session s
class="mx_DeviceSecurityCard_description"
>
This session is ready for secure messaging.
+
+ Learn more
+
@@ -252,6 +259,13 @@ exports[` current session section renders current session s
class="mx_DeviceSecurityCard_description"
>
Verify or sign out from this session for best security and reliability.
+
+ Learn more
+
{
await call.connect();
messaging.emit(
- `action:${ElementWidgetActions.Screenshare}`,
+ `action:${ElementWidgetActions.ScreenshareRequest}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
- waitFor(() => {
+ await waitFor(() => {
expect(messaging!.transport.reply).toHaveBeenCalledWith(
expect.objectContaining({}),
- expect.objectContaining({ desktopCapturerSourceId: sourceId }),
+ expect.objectContaining({ pending: true }),
+ );
+ });
+
+ await waitFor(() => {
+ expect(messaging!.transport.send).toHaveBeenCalledWith(
+ "io.element.screenshare_start", expect.objectContaining({ desktopCapturerSourceId: sourceId }),
);
});
});
- it("passes failed if we couldn't get a source id", async () => {
+ it("sends ScreenshareStop if we couldn't get a source id", async () => {
jest.spyOn(Modal, "createDialog").mockReturnValue(
{ finished: new Promise((r) => r([null])) } as IHandle
,
);
@@ -841,32 +847,38 @@ describe("ElementCall", () => {
await call.connect();
messaging.emit(
- `action:${ElementWidgetActions.Screenshare}`,
+ `action:${ElementWidgetActions.ScreenshareRequest}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
- waitFor(() => {
+ await waitFor(() => {
expect(messaging!.transport.reply).toHaveBeenCalledWith(
expect.objectContaining({}),
- expect.objectContaining({ failed: true }),
+ expect.objectContaining({ pending: true }),
+ );
+ });
+
+ await waitFor(() => {
+ expect(messaging!.transport.send).toHaveBeenCalledWith(
+ "io.element.screenshare_stop", expect.objectContaining({ }),
);
});
});
- it("passes an empty object if we don't support desktop capturer", async () => {
+ it("replies with pending: false if we don't support desktop capturer", async () => {
jest.spyOn(PlatformPeg.get(), "supportsDesktopCapturer").mockReturnValue(false);
await call.connect();
messaging.emit(
- `action:${ElementWidgetActions.Screenshare}`,
+ `action:${ElementWidgetActions.ScreenshareRequest}`,
new CustomEvent("widgetapirequest", { detail: {} }),
);
- waitFor(() => {
+ await waitFor(() => {
expect(messaging!.transport.reply).toHaveBeenCalledWith(
expect.objectContaining({}),
- expect.objectContaining({}),
+ expect.objectContaining({ pending: false }),
);
});
});
diff --git a/test/modules/ModuleComponents-test.tsx b/test/modules/ModuleComponents-test.tsx
index 1bc7f77c69..d3e13717d2 100644
--- a/test/modules/ModuleComponents-test.tsx
+++ b/test/modules/ModuleComponents-test.tsx
@@ -15,8 +15,7 @@ limitations under the License.
*/
import React from "react";
-// eslint-disable-next-line deprecate/import
-import { mount } from "enzyme";
+import { render } from "@testing-library/react";
import { TextInputField } from "@matrix-org/react-sdk-module-api/lib/components/TextInputField";
import { Spinner as ModuleSpinner } from "@matrix-org/react-sdk-module-api/lib/components/Spinner";
@@ -31,12 +30,12 @@ describe("Module Components", () => {
// ModuleRunner import to do its job (as per documentation in ModuleComponents).
it("should override the factory for a TextInputField", () => {
- const component = mount( {}} />);
- expect(component).toMatchSnapshot();
+ const { asFragment } = render( {}} />);
+ expect(asFragment()).toMatchSnapshot();
});
it("should override the factory for a ModuleSpinner", () => {
- const component = mount( );
- expect(component).toMatchSnapshot();
+ const { asFragment } = render( );
+ expect(asFragment()).toMatchSnapshot();
});
});
diff --git a/test/modules/__snapshots__/ModuleComponents-test.tsx.snap b/test/modules/__snapshots__/ModuleComponents-test.tsx.snap
index 864c775c4f..248d213452 100644
--- a/test/modules/__snapshots__/ModuleComponents-test.tsx.snap
+++ b/test/modules/__snapshots__/ModuleComponents-test.tsx.snap
@@ -1,68 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Module Components should override the factory for a ModuleSpinner 1`] = `
-
-
+
-
-
+ aria-label="Loading..."
+ class="mx_Spinner_icon"
+ data-testid="spinner"
+ role="progressbar"
+ style="width: 32px; height: 32px;"
+ />
+
+
`;
exports[`Module Components should override the factory for a TextInputField 1`] = `
-
-
+
-
-
+ My Label
+
+
+
`;
diff --git a/test/setup/setupLanguage.ts b/test/setup/setupLanguage.ts
index 5efd8786cd..bd07616ab3 100644
--- a/test/setup/setupLanguage.ts
+++ b/test/setup/setupLanguage.ts
@@ -20,10 +20,6 @@ import * as languageHandler from "../../src/languageHandler";
import en from "../../src/i18n/strings/en_EN.json";
import de from "../../src/i18n/strings/de_DE.json";
-fetchMock.config.overwriteRoutes = false;
-fetchMock.catch("");
-window.fetch = fetchMock.sandbox();
-
const lv = {
"Save": "Saglabāt",
"Uploading %(filename)s and %(count)s others|one": "Качване на %(filename)s и %(count)s друг",
diff --git a/test/setup/setupManualMocks.ts b/test/setup/setupManualMocks.ts
index 31c716e375..2adda89e0f 100644
--- a/test/setup/setupManualMocks.ts
+++ b/test/setup/setupManualMocks.ts
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+import fetchMock from "fetch-mock-jest";
import { TextDecoder, TextEncoder } from "util";
// jest 27 removes setImmediate from jsdom
@@ -54,3 +55,9 @@ global.TextDecoder = TextDecoder;
// prevent errors whenever a component tries to manually scroll.
window.HTMLElement.prototype.scrollIntoView = jest.fn();
+
+// set up fetch API mock
+fetchMock.config.overwriteRoutes = false;
+fetchMock.catch("");
+fetchMock.get("/image-file-stub", "image file stub");
+window.fetch = fetchMock.sandbox();
diff --git a/test/stores/notifications/RoomNotificationState-test.ts b/test/stores/notifications/RoomNotificationState-test.ts
index 904e068909..c9ee6dd497 100644
--- a/test/stores/notifications/RoomNotificationState-test.ts
+++ b/test/stores/notifications/RoomNotificationState-test.ts
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
-import { MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix";
+import { MatrixEventEvent, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
@@ -24,12 +24,16 @@ import * as testUtils from "../../test-utils";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
describe("RoomNotificationState", () => {
- stubClient();
- const client = MatrixClientPeg.get();
+ let testRoom: Room;
+ let client: MatrixClient;
+
+ beforeEach(() => {
+ stubClient();
+ client = MatrixClientPeg.get();
+ testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client);
+ });
it("Updates on event decryption", () => {
- const testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client);
-
const roomNotifState = new RoomNotificationState(testRoom as any as Room);
const listener = jest.fn();
roomNotifState.addListener(NotificationStateEvents.Update, listener);
@@ -40,4 +44,9 @@ describe("RoomNotificationState", () => {
client.emit(MatrixEventEvent.Decrypted, testEvent);
expect(listener).toHaveBeenCalled();
});
+
+ it("removes listeners", () => {
+ const roomNotifState = new RoomNotificationState(testRoom as any as Room);
+ expect(() => roomNotifState.destroy()).not.toThrow();
+ });
});
diff --git a/test/stores/notifications/RoomNotificationStateStore-test.ts b/test/stores/notifications/RoomNotificationStateStore-test.ts
new file mode 100644
index 0000000000..e5d24881ae
--- /dev/null
+++ b/test/stores/notifications/RoomNotificationStateStore-test.ts
@@ -0,0 +1,60 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { PendingEventOrdering } from "matrix-js-sdk/src/client";
+import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
+import { Room } from "matrix-js-sdk/src/models/room";
+
+import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
+import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
+import { stubClient } from "../../test-utils";
+
+describe("RoomNotificationStateStore", () => {
+ const ROOM_ID = "!roomId:example.org";
+
+ let room;
+ let client;
+
+ beforeEach(() => {
+ stubClient();
+ client = MatrixClientPeg.get();
+ room = new Room(ROOM_ID, client, client.getUserId(), {
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ });
+ });
+
+ it("does not use legacy thread notification store", () => {
+ client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable);
+ expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull();
+ });
+
+ it("use legacy thread notification store", () => {
+ client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported);
+ expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull();
+ });
+
+ it("does not use legacy thread notification store", () => {
+ client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable);
+ RoomNotificationStateStore.instance.getRoomState(room);
+ expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull();
+ });
+
+ it("use legacy thread notification store", () => {
+ client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported);
+ RoomNotificationStateStore.instance.getRoomState(room);
+ expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull();
+ });
+});
diff --git a/test/stores/room-list/SlidingRoomListStore-test.ts b/test/stores/room-list/SlidingRoomListStore-test.ts
new file mode 100644
index 0000000000..488c92396a
--- /dev/null
+++ b/test/stores/room-list/SlidingRoomListStore-test.ts
@@ -0,0 +1,319 @@
+/*
+Copyright 2022 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+import { mocked } from 'jest-mock';
+import { SlidingSync, SlidingSyncEvent } from 'matrix-js-sdk/src/sliding-sync';
+import { Room } from 'matrix-js-sdk/src/matrix';
+
+import {
+ LISTS_UPDATE_EVENT,
+ SlidingRoomListStoreClass,
+ SlidingSyncSortToFilter,
+} from "../../../src/stores/room-list/SlidingRoomListStore";
+import { SpaceStoreClass } from "../../../src/stores/spaces/SpaceStore";
+import { MockEventEmitter, stubClient, untilEmission } from "../../test-utils";
+import { TestSdkContext } from '../../TestSdkContext';
+import { SlidingSyncManager } from '../../../src/SlidingSyncManager';
+import { RoomViewStore } from '../../../src/stores/RoomViewStore';
+import { MatrixDispatcher } from '../../../src/dispatcher/dispatcher';
+import { SortAlgorithm } from '../../../src/stores/room-list/algorithms/models';
+import { DefaultTagID, TagID } from '../../../src/stores/room-list/models';
+import { UPDATE_SELECTED_SPACE } from '../../../src/stores/spaces';
+import { LISTS_LOADING_EVENT } from '../../../src/stores/room-list/RoomListStore';
+import { UPDATE_EVENT } from '../../../src/stores/AsyncStore';
+
+jest.mock('../../../src/SlidingSyncManager');
+const MockSlidingSyncManager = >SlidingSyncManager;
+
+describe("SlidingRoomListStore", () => {
+ let store: SlidingRoomListStoreClass;
+ let context: TestSdkContext;
+ let dis: MatrixDispatcher;
+ let activeSpace: string;
+ let tagIdToIndex = {};
+
+ beforeEach(async () => {
+ context = new TestSdkContext();
+ context.client = stubClient();
+ context._SpaceStore = new MockEventEmitter({
+ traverseSpace: jest.fn(),
+ get activeSpace() {
+ return activeSpace;
+ },
+ }) as SpaceStoreClass;
+ context._SlidingSyncManager = new MockSlidingSyncManager();
+ context._SlidingSyncManager.slidingSync = mocked(new MockEventEmitter({
+ getListData: jest.fn(),
+ }) as unknown as SlidingSync);
+ context._RoomViewStore = mocked(new MockEventEmitter({
+ getRoomId: jest.fn(),
+ }) as unknown as RoomViewStore);
+
+ // mock implementations to allow the store to map tag IDs to sliding sync list indexes and vice versa
+ let index = 0;
+ tagIdToIndex = {};
+ mocked(context._SlidingSyncManager.getOrAllocateListIndex).mockImplementation((listId: string): number => {
+ if (tagIdToIndex[listId] != null) {
+ return tagIdToIndex[listId];
+ }
+ tagIdToIndex[listId] = index;
+ index++;
+ return index;
+ });
+ mocked(context.slidingSyncManager.listIdForIndex).mockImplementation((i) => {
+ for (const tagId in tagIdToIndex) {
+ const j = tagIdToIndex[tagId];
+ if (i === j) {
+ return tagId;
+ }
+ }
+ return null;
+ });
+ mocked(context._SlidingSyncManager.ensureListRegistered).mockResolvedValue({
+ ranges: [[0, 10]],
+ });
+
+ dis = new MatrixDispatcher();
+ store = new SlidingRoomListStoreClass(dis, context);
+ });
+
+ describe("spaces", () => {
+ it("alters 'filters.spaces' on the DefaultTagID.Untagged list when the selected space changes", async () => {
+ await store.start(); // call onReady
+ const spaceRoomId = "!foo:bar";
+
+ const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => {
+ return listName === DefaultTagID.Untagged && !isLoading;
+ });
+
+ // change the active space
+ activeSpace = spaceRoomId;
+ context._SpaceStore.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false);
+ await p;
+
+ expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith(
+ tagIdToIndex[DefaultTagID.Untagged],
+ {
+ filters: expect.objectContaining({
+ spaces: [spaceRoomId],
+ }),
+ },
+ );
+ });
+
+ it("alters 'filters.spaces' on the DefaultTagID.Untagged list if it loads with an active space", async () => {
+ // change the active space before we are ready
+ const spaceRoomId = "!foo2:bar";
+ activeSpace = spaceRoomId;
+ const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => {
+ return listName === DefaultTagID.Untagged && !isLoading;
+ });
+ await store.start(); // call onReady
+ await p;
+ expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith(
+ tagIdToIndex[DefaultTagID.Untagged],
+ expect.objectContaining({
+ filters: expect.objectContaining({
+ spaces: [spaceRoomId],
+ }),
+ }),
+ );
+ });
+
+ it("includes subspaces in 'filters.spaces' when the selected space has subspaces", async () => {
+ await store.start(); // call onReady
+ const spaceRoomId = "!foo:bar";
+ const subSpace1 = "!ss1:bar";
+ const subSpace2 = "!ss2:bar";
+
+ const p = untilEmission(store, LISTS_LOADING_EVENT, (listName, isLoading) => {
+ return listName === DefaultTagID.Untagged && !isLoading;
+ });
+
+ mocked(context._SpaceStore.traverseSpace).mockImplementation(
+ (spaceId: string, fn: (roomId: string) => void) => {
+ if (spaceId === spaceRoomId) {
+ fn(subSpace1);
+ fn(subSpace2);
+ }
+ },
+ );
+
+ // change the active space
+ activeSpace = spaceRoomId;
+ context._SpaceStore.emit(UPDATE_SELECTED_SPACE, spaceRoomId, false);
+ await p;
+
+ expect(context._SlidingSyncManager.ensureListRegistered).toHaveBeenCalledWith(
+ tagIdToIndex[DefaultTagID.Untagged],
+ {
+ filters: expect.objectContaining({
+ spaces: [spaceRoomId, subSpace1, subSpace2],
+ }),
+ },
+ );
+ });
+ });
+
+ it("setTagSorting alters the 'sort' option in the list", async () => {
+ mocked(context._SlidingSyncManager.getOrAllocateListIndex).mockReturnValue(0);
+ const tagId: TagID = "foo";
+ await store.setTagSorting(tagId, SortAlgorithm.Alphabetic);
+ expect(context._SlidingSyncManager.ensureListRegistered).toBeCalledWith(0, {
+ sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
+ });
+ expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Alphabetic);
+
+ await store.setTagSorting(tagId, SortAlgorithm.Recent);
+ expect(context._SlidingSyncManager.ensureListRegistered).toBeCalledWith(0, {
+ sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
+ });
+ expect(store.getTagSorting(tagId)).toEqual(SortAlgorithm.Recent);
+ });
+
+ it("getTagsForRoom gets the tags for the room", async () => {
+ await store.start();
+ const untaggedIndex = context._SlidingSyncManager.getOrAllocateListIndex(DefaultTagID.Untagged);
+ const favIndex = context._SlidingSyncManager.getOrAllocateListIndex(DefaultTagID.Favourite);
+ const roomA = "!a:localhost";
+ const roomB = "!b:localhost";
+ const indexToListData = {
+ [untaggedIndex]: {
+ joinedCount: 10,
+ roomIndexToRoomId: {
+ 0: roomA,
+ 1: roomB,
+ },
+ },
+ [favIndex]: {
+ joinedCount: 2,
+ roomIndexToRoomId: {
+ 0: roomB,
+ },
+ },
+ };
+ mocked(context._SlidingSyncManager.slidingSync.getListData).mockImplementation((i: number) => {
+ return indexToListData[i] || null;
+ });
+
+ expect(store.getTagsForRoom(new Room(roomA, context.client, context.client.getUserId()))).toEqual(
+ [DefaultTagID.Untagged],
+ );
+ expect(store.getTagsForRoom(new Room(roomB, context.client, context.client.getUserId()))).toEqual(
+ [DefaultTagID.Favourite, DefaultTagID.Untagged],
+ );
+ });
+
+ it("emits LISTS_UPDATE_EVENT when slidingSync lists update", async () => {
+ await store.start();
+ const roomA = "!a:localhost";
+ const roomB = "!b:localhost";
+ const roomC = "!c:localhost";
+ const tagId = DefaultTagID.Favourite;
+ const listIndex = context.slidingSyncManager.getOrAllocateListIndex(tagId);
+ const joinCount = 10;
+ const roomIndexToRoomId = { // mixed to ensure we sort
+ 1: roomB,
+ 2: roomC,
+ 0: roomA,
+ };
+ const rooms = [
+ new Room(roomA, context.client, context.client.getUserId()),
+ new Room(roomB, context.client, context.client.getUserId()),
+ new Room(roomC, context.client, context.client.getUserId()),
+ ];
+ mocked(context.client.getRoom).mockImplementation((roomId: string) => {
+ switch (roomId) {
+ case roomA:
+ return rooms[0];
+ case roomB:
+ return rooms[1];
+ case roomC:
+ return rooms[2];
+ }
+ return null;
+ });
+ const p = untilEmission(store, LISTS_UPDATE_EVENT);
+ context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId);
+ await p;
+ expect(store.getCount(tagId)).toEqual(joinCount);
+ expect(store.orderedLists[tagId]).toEqual(rooms);
+ });
+
+ it("sets the sticky room on the basis of the viewed room in RoomViewStore", async () => {
+ await store.start();
+ // seed the store with 3 rooms
+ const roomIdA = "!a:localhost";
+ const roomIdB = "!b:localhost";
+ const roomIdC = "!c:localhost";
+ const tagId = DefaultTagID.Favourite;
+ const listIndex = context.slidingSyncManager.getOrAllocateListIndex(tagId);
+ const joinCount = 10;
+ const roomIndexToRoomId = { // mixed to ensure we sort
+ 1: roomIdB,
+ 2: roomIdC,
+ 0: roomIdA,
+ };
+ const roomA = new Room(roomIdA, context.client, context.client.getUserId());
+ const roomB = new Room(roomIdB, context.client, context.client.getUserId());
+ const roomC = new Room(roomIdC, context.client, context.client.getUserId());
+ mocked(context.client.getRoom).mockImplementation((roomId: string) => {
+ switch (roomId) {
+ case roomIdA:
+ return roomA;
+ case roomIdB:
+ return roomB;
+ case roomIdC:
+ return roomC;
+ }
+ return null;
+ });
+ mocked(context._SlidingSyncManager.slidingSync.getListData).mockImplementation((i: number) => {
+ if (i !== listIndex) {
+ return null;
+ }
+ return {
+ roomIndexToRoomId: roomIndexToRoomId,
+ joinedCount: joinCount,
+ };
+ });
+ let p = untilEmission(store, LISTS_UPDATE_EVENT);
+ context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId);
+ await p;
+ expect(store.orderedLists[tagId]).toEqual([roomA, roomB, roomC]);
+
+ // make roomB sticky and inform the store
+ mocked(context.roomViewStore.getRoomId).mockReturnValue(roomIdB);
+ context.roomViewStore.emit(UPDATE_EVENT);
+
+ // bump room C to the top, room B should not move from i=1 despite the list update saying to
+ roomIndexToRoomId[0] = roomIdC;
+ roomIndexToRoomId[1] = roomIdA;
+ roomIndexToRoomId[2] = roomIdB;
+ p = untilEmission(store, LISTS_UPDATE_EVENT);
+ context.slidingSyncManager.slidingSync.emit(SlidingSyncEvent.List, listIndex, joinCount, roomIndexToRoomId);
+ await p;
+
+ // check that B didn't move and that A was put below B
+ expect(store.orderedLists[tagId]).toEqual([roomC, roomB, roomA]);
+
+ // make room C sticky: rooms should move as a result, without needing an additional list update
+ mocked(context.roomViewStore.getRoomId).mockReturnValue(roomIdC);
+ p = untilEmission(store, LISTS_UPDATE_EVENT);
+ context.roomViewStore.emit(UPDATE_EVENT);
+ await p;
+ expect(store.orderedLists[tagId].map((r) => r.roomId)).toEqual([roomC, roomA, roomB].map((r) => r.roomId));
+ });
+});
diff --git a/test/stores/room-list/algorithms/RecentAlgorithm-test.ts b/test/stores/room-list/algorithms/RecentAlgorithm-test.ts
index 40ce53f225..45dd89d7b5 100644
--- a/test/stores/room-list/algorithms/RecentAlgorithm-test.ts
+++ b/test/stores/room-list/algorithms/RecentAlgorithm-test.ts
@@ -21,6 +21,7 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import "../../../../src/stores/room-list/RoomListStore";
import { RecentAlgorithm } from "../../../../src/stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import { EffectiveMembership } from "../../../../src/utils/membership";
+import { makeThreadEvent, mkThread } from "../../../test-utils/threads";
describe("RecentAlgorithm", () => {
let algorithm;
@@ -122,6 +123,60 @@ describe("RecentAlgorithm", () => {
room1.addLiveEvents([evt]);
expect(algorithm.sortRooms([room2, room1])).toEqual([room2, room1]);
+
+ const { events } = mkThread({
+ room: room1,
+ client: cli,
+ authorId: "@bob:matrix.org",
+ participantUserIds: ["@bob:matrix.org"],
+ ts: 12,
+ });
+
+ room1.addLiveEvents(events);
+ });
+
+ it("orders rooms based on thread replies too", () => {
+ const room1 = new Room("room1", cli, "@bob:matrix.org");
+ const room2 = new Room("room2", cli, "@bob:matrix.org");
+
+ room1.getMyMembership = () => "join";
+ room2.getMyMembership = () => "join";
+
+ const { rootEvent, events: events1 } = mkThread({
+ room: room1,
+ client: cli,
+ authorId: "@bob:matrix.org",
+ participantUserIds: ["@bob:matrix.org"],
+ ts: 12,
+ length: 5,
+ });
+ room1.addLiveEvents(events1);
+
+ const { events: events2 } = mkThread({
+ room: room2,
+ client: cli,
+ authorId: "@bob:matrix.org",
+ participantUserIds: ["@bob:matrix.org"],
+ ts: 14,
+ length: 10,
+ });
+ room2.addLiveEvents(events2);
+
+ expect(algorithm.sortRooms([room1, room2])).toEqual([room2, room1]);
+
+ const threadReply = makeThreadEvent({
+ user: "@bob:matrix.org",
+ room: room1.roomId,
+ event: true,
+ msg: `hello world`,
+ rootEventId: rootEvent.getId(),
+ replyToEventId: rootEvent.getId(),
+ // replies are 1ms after each other
+ ts: 50,
+ });
+ room1.addLiveEvents([threadReply]);
+
+ expect(algorithm.sortRooms([room1, room2])).toEqual([room1, room2]);
});
});
});
diff --git a/test/stores/room-list/filters/VisibilityProvider-test.ts b/test/stores/room-list/filters/VisibilityProvider-test.ts
index f22901a40f..ca6c67dfb1 100644
--- a/test/stores/room-list/filters/VisibilityProvider-test.ts
+++ b/test/stores/room-list/filters/VisibilityProvider-test.ts
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
-import { Room } from "matrix-js-sdk/src/matrix";
+import { Room, RoomType } from "matrix-js-sdk/src/matrix";
import { VisibilityProvider } from "../../../../src/stores/room-list/filters/VisibilityProvider";
import LegacyCallHandler from "../../../../src/LegacyCallHandler";
@@ -43,6 +43,7 @@ jest.mock("../../../../src/customisations/RoomList", () => ({
const createRoom = (isSpaceRoom = false): Room => {
return {
isSpaceRoom: () => isSpaceRoom,
+ getType: () => isSpaceRoom ? RoomType.Space : undefined,
} as unknown as Room;
};
diff --git a/test/stores/widgets/StopGapWidgetDriver-test.ts b/test/stores/widgets/StopGapWidgetDriver-test.ts
index 0fd2f18be7..7adf38a853 100644
--- a/test/stores/widgets/StopGapWidgetDriver-test.ts
+++ b/test/stores/widgets/StopGapWidgetDriver-test.ts
@@ -66,6 +66,8 @@ describe("StopGapWidgetDriver", () => {
"m.always_on_screen",
"town.robin.msc3846.turn_servers",
"org.matrix.msc2762.timeline:!1:example.org",
+ "org.matrix.msc2762.send.event:org.matrix.rageshake_request",
+ "org.matrix.msc2762.receive.event:org.matrix.rageshake_request",
"org.matrix.msc2762.receive.state_event:m.room.member",
"org.matrix.msc2762.send.state_event:org.matrix.msc3401.call",
"org.matrix.msc2762.receive.state_event:org.matrix.msc3401.call",
diff --git a/test/stores/widgets/__snapshots__/StopGapWidgetDriver-test.ts.snap b/test/stores/widgets/__snapshots__/StopGapWidgetDriver-test.ts.snap
index 5f19dbb793..4c67524015 100644
--- a/test/stores/widgets/__snapshots__/StopGapWidgetDriver-test.ts.snap
+++ b/test/stores/widgets/__snapshots__/StopGapWidgetDriver-test.ts.snap
@@ -6,7 +6,7 @@ Array [
Array [
Object {
"deviceInfo": DeviceInfo {
- "algorithms": undefined,
+ "algorithms": Array [],
"deviceId": "aliceWeb",
"keys": Object {},
"known": false,
@@ -18,7 +18,7 @@ Array [
},
Object {
"deviceInfo": DeviceInfo {
- "algorithms": undefined,
+ "algorithms": Array [],
"deviceId": "aliceMobile",
"keys": Object {},
"known": false,
@@ -37,7 +37,7 @@ Array [
Array [
Object {
"deviceInfo": DeviceInfo {
- "algorithms": undefined,
+ "algorithms": Array [],
"deviceId": "bobDesktop",
"keys": Object {},
"known": false,
diff --git a/test/test-utils/client.ts b/test/test-utils/client.ts
index e0c532c021..e155dd17c4 100644
--- a/test/test-utils/client.ts
+++ b/test/test-utils/client.ts
@@ -21,6 +21,26 @@ import { MatrixClient, User } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
+/**
+ * Mocked generic class with a real EventEmitter.
+ * Useful for mocks which need event emitters.
+ */
+export class MockEventEmitter extends EventEmitter {
+ /**
+ * Construct a new event emitter with additional properties/functions. The event emitter functions
+ * like .emit and .on will be real.
+ * @param mockProperties An object with the mock property or function implementations. 'getters'
+ * are correctly cloned to this event emitter.
+ */
+ constructor(mockProperties: Partial|PropertyKeysOf, unknown>> = {}) {
+ super();
+ // We must use defineProperties and not assign as the former clones getters correctly,
+ // whereas the latter invokes the getter and sets the return value permanently on the
+ // destination object.
+ Object.defineProperties(this, Object.getOwnPropertyDescriptors(mockProperties));
+ }
+}
+
/**
* Mock client with real event emitter
* useful for testing code that listens
diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts
index 4549190600..85045a6da8 100644
--- a/test/test-utils/test-utils.ts
+++ b/test/test-utils/test-utils.ts
@@ -31,6 +31,7 @@ import {
IEventRelation,
IUnsigned,
IPusher,
+ RoomType,
} from 'matrix-js-sdk/src/matrix';
import { normalize } from "matrix-js-sdk/src/utils";
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
@@ -142,7 +143,7 @@ export function createTestClient(): MatrixClient {
sendTyping: jest.fn().mockResolvedValue({}),
sendMessage: jest.fn().mockResolvedValue({}),
sendStateEvent: jest.fn().mockResolvedValue(undefined),
- getSyncState: () => "SYNCING",
+ getSyncState: jest.fn().mockReturnValue("SYNCING"),
generateClientSecret: () => "t35tcl1Ent5ECr3T",
isGuest: jest.fn().mockReturnValue(false),
getRoomHierarchy: jest.fn().mockReturnValue({
@@ -178,6 +179,7 @@ export function createTestClient(): MatrixClient {
sendToDevice: jest.fn().mockResolvedValue(undefined),
queueToDevice: jest.fn().mockResolvedValue(undefined),
encryptAndSendToDevices: jest.fn().mockResolvedValue(undefined),
+ cancelPendingEvent: jest.fn(),
getMediaHandler: jest.fn().mockReturnValue({
setVideoInput: jest.fn(),
@@ -210,6 +212,7 @@ type MakeEventPassThruProps = {
};
type MakeEventProps = MakeEventPassThruProps & {
type: string;
+ redacts?: string;
content: IContent;
room?: Room["roomId"]; // to-device messages are roomless
// eslint-disable-next-line camelcase
@@ -243,6 +246,7 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent {
event_id: "$" + Math.random() + "-" + Math.random(),
origin_server_ts: opts.ts ?? 0,
unsigned: opts.unsigned,
+ redacts: opts.redacts,
};
if (opts.skey !== undefined) {
event.state_key = opts.skey;
@@ -447,6 +451,7 @@ export function mkStubRoom(roomId: string = null, name: string, client: MatrixCl
getAvatarUrl: () => 'mxc://avatar.url/room.png',
getMxcAvatarUrl: () => 'mxc://avatar.url/room.png',
isSpaceRoom: jest.fn().mockReturnValue(false),
+ getType: jest.fn().mockReturnValue(undefined),
isElementVideoRoom: jest.fn().mockReturnValue(false),
getUnreadNotificationCount: jest.fn(() => 0),
getEventReadUpTo: jest.fn(() => null),
@@ -544,6 +549,7 @@ export const mkSpace = (
): MockedObject => {
const space = mocked(mkRoom(client, spaceId, rooms));
space.isSpaceRoom.mockReturnValue(true);
+ space.getType.mockReturnValue(RoomType.Space);
mocked(space.currentState).getStateEvents.mockImplementation(mockStateEventImplementation(children.map(roomId =>
mkEvent({
event: true,
diff --git a/test/test-utils/threads.ts b/test/test-utils/threads.ts
index 419b09b2b8..3b07c45051 100644
--- a/test/test-utils/threads.ts
+++ b/test/test-utils/threads.ts
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
-import { MatrixClient, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
+import { MatrixClient, MatrixEvent, MatrixEventEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
import { Thread } from "matrix-js-sdk/src/models/thread";
import { mkMessage, MessageEventProps } from "./test-utils";
@@ -106,7 +106,7 @@ export const mkThread = ({
participantUserIds,
length = 2,
ts = 1,
-}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent } => {
+}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent, events: MatrixEvent[] } => {
const { rootEvent, events } = makeThreadEvents({
roomId: room.roomId,
authorId,
@@ -115,10 +115,18 @@ export const mkThread = ({
ts,
currentUserId: client.getUserId(),
});
+ expect(rootEvent).toBeTruthy();
+
+ for (const evt of events) {
+ room?.reEmitter.reEmit(evt, [
+ MatrixEventEvent.BeforeRedaction,
+ ]);
+ }
const thread = room.createThread(rootEvent.getId(), rootEvent, events, true);
// So that we do not have to mock the thread loading
thread.initialEventsFetched = true;
+ thread.addEvents(events, true);
- return { thread, rootEvent };
+ return { thread, rootEvent, events };
};
diff --git a/test/utils/EventUtils-test.ts b/test/utils/EventUtils-test.ts
index 120d47aa1d..644f274c19 100644
--- a/test/utils/EventUtils-test.ts
+++ b/test/utils/EventUtils-test.ts
@@ -18,21 +18,27 @@ import { M_LOCATION } from "matrix-js-sdk/src/@types/location";
import {
EventStatus,
EventType,
+ IEvent,
+ MatrixClient,
MatrixEvent,
MsgType,
+ PendingEventOrdering,
RelationType,
+ Room,
} from "matrix-js-sdk/src/matrix";
+import { Thread } from "matrix-js-sdk/src/models/thread";
import { MatrixClientPeg } from "../../src/MatrixClientPeg";
import {
canCancel,
canEditContent,
canEditOwnEvent,
+ fetchInitialEvent,
isContentActionable,
isLocationEvent,
isVoiceMessage,
} from "../../src/utils/EventUtils";
-import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent } from "../test-utils";
+import { getMockClientWithEventEmitter, makeBeaconInfoEvent, makePollStartEvent, stubClient } from "../test-utils";
describe('EventUtils', () => {
const userId = '@user:server';
@@ -336,4 +342,92 @@ describe('EventUtils', () => {
expect(canCancel(status)).toBe(false);
});
});
+
+ describe("fetchInitialEvent", () => {
+ const ROOM_ID = "!roomId:example.org";
+ let room: Room;
+ let client: MatrixClient;
+
+ const NORMAL_EVENT = "$normalEvent";
+ const THREAD_ROOT = "$threadRoot";
+ const THREAD_REPLY = "$threadReply";
+
+ const events: Record> = {
+ [NORMAL_EVENT]: {
+ event_id: NORMAL_EVENT,
+ type: EventType.RoomMessage,
+ content: {
+ "body": "Classic event",
+ "msgtype": MsgType.Text,
+ },
+ },
+ [THREAD_ROOT]: {
+ event_id: THREAD_ROOT,
+ type: EventType.RoomMessage,
+ content: {
+ "body": "Thread root",
+ "msgtype": "m.text",
+ },
+ unsigned: {
+ "m.relations": {
+ [RelationType.Thread]: {
+ latest_event: {
+ event_id: THREAD_REPLY,
+ type: EventType.RoomMessage,
+ content: {
+ "body": "Thread reply",
+ "msgtype": MsgType.Text,
+ "m.relates_to": {
+ event_id: "$threadRoot",
+ rel_type: RelationType.Thread,
+ },
+ },
+ },
+ count: 1,
+ current_user_participated: false,
+ },
+ },
+ },
+ },
+ [THREAD_REPLY]: {
+ event_id: THREAD_REPLY,
+ type: EventType.RoomMessage,
+ content: {
+ "body": "Thread reply",
+ "msgtype": MsgType.Text,
+ "m.relates_to": {
+ event_id: THREAD_ROOT,
+ rel_type: RelationType.Thread,
+ },
+ },
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ stubClient();
+ client = MatrixClientPeg.get();
+
+ room = new Room(ROOM_ID, client, client.getUserId(), {
+ pendingEventOrdering: PendingEventOrdering.Detached,
+ });
+
+ jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
+ jest.spyOn(client, "getRoom").mockReturnValue(room);
+ jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
+ return events[eventId] ?? Promise.reject();
+ });
+ });
+
+ it("returns null for unknown events", async () => {
+ expect(await fetchInitialEvent(client, room.roomId, "$UNKNOWN")).toBeNull();
+ expect(await fetchInitialEvent(client, room.roomId, NORMAL_EVENT)).toBeInstanceOf(MatrixEvent);
+ });
+
+ it("creates a thread when needed", async () => {
+ await fetchInitialEvent(client, room.roomId, THREAD_REPLY);
+ expect(room.getThread(THREAD_ROOT)).toBeInstanceOf(Thread);
+ });
+ });
});
diff --git a/test/utils/location/isSelfLocation-test.ts b/test/utils/location/isSelfLocation-test.ts
index 6fafc5e467..cd1b3452a9 100644
--- a/test/utils/location/isSelfLocation-test.ts
+++ b/test/utils/location/isSelfLocation-test.ts
@@ -28,7 +28,7 @@ import { isSelfLocation } from "../../../src/utils/location";
describe("isSelfLocation", () => {
it("Returns true for a full m.asset event", () => {
- const content = makeLocationContent("", '0');
+ const content = makeLocationContent("", '0', Date.now());
expect(isSelfLocation(content)).toBe(true);
});
diff --git a/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts b/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts
index e60d7e2d96..df7da24ce5 100644
--- a/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts
+++ b/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts
@@ -26,6 +26,8 @@ import {
VoiceBroadcastRecorderEvent,
} from "../../../src/voice-broadcast";
+jest.mock("../../../src/audio/VoiceRecording");
+
describe("VoiceBroadcastRecorder", () => {
describe("createVoiceBroadcastRecorder", () => {
beforeEach(() => {
@@ -44,6 +46,7 @@ describe("VoiceBroadcastRecorder", () => {
it("should return a VoiceBroadcastRecorder instance with targetChunkLength from config", () => {
const voiceBroadcastRecorder = createVoiceBroadcastRecorder();
+ expect(mocked(VoiceRecording).mock.instances[0].disableMaxLength).toHaveBeenCalled();
expect(voiceBroadcastRecorder).toBeInstanceOf(VoiceBroadcastRecorder);
expect(voiceBroadcastRecorder.targetChunkLength).toBe(1337);
});
@@ -72,16 +75,12 @@ describe("VoiceBroadcastRecorder", () => {
};
beforeEach(() => {
- voiceRecording = {
- contentType,
- start: jest.fn().mockResolvedValue(undefined),
- stop: jest.fn().mockResolvedValue(undefined),
- on: jest.fn(),
- off: jest.fn(),
- emit: jest.fn(),
- destroy: jest.fn(),
- recorderSeconds: 23,
- } as unknown as VoiceRecording;
+ voiceRecording = new VoiceRecording();
+ // @ts-ignore
+ voiceRecording.recorderSeconds = 23;
+ // @ts-ignore
+ voiceRecording.contentType = contentType;
+
voiceBroadcastRecorder = new VoiceBroadcastRecorder(voiceRecording, chunkLength);
jest.spyOn(voiceBroadcastRecorder, "removeAllListeners");
onChunkRecorded = jest.fn();
diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody-test.tsx
index 25e1f7c215..36b2b4c5a7 100644
--- a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody-test.tsx
+++ b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingBody-test.tsx
@@ -50,7 +50,7 @@ describe("VoiceBroadcastRecordingBody", () => {
room: roomId,
user: userId,
});
- recording = new VoiceBroadcastRecording(infoEvent, client, VoiceBroadcastInfoState.Running);
+ recording = new VoiceBroadcastRecording(infoEvent, client, VoiceBroadcastInfoState.Resumed);
});
describe("when rendering a live broadcast", () => {
diff --git a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx
index 4cd85d37ef..f07b7dd0bd 100644
--- a/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx
+++ b/test/voice-broadcast/components/molecules/VoiceBroadcastRecordingPip-test.tsx
@@ -118,7 +118,7 @@ describe("VoiceBroadcastRecordingPip", () => {
});
it("should resume the recording", () => {
- expect(recording.getState()).toBe(VoiceBroadcastInfoState.Running);
+ expect(recording.getState()).toBe(VoiceBroadcastInfoState.Resumed);
});
});
});
diff --git a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap
index 4dba41de67..1eace91e48 100644
--- a/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap
+++ b/test/voice-broadcast/components/molecules/__snapshots__/VoiceBroadcastPlaybackBody-test.tsx.snap
@@ -3,7 +3,7 @@
exports[`VoiceBroadcastPlaybackBody when rendering a 0 broadcast should render as expected 1`] = `