Merge branch 'develop' into last-admin-leave-room-warning
This commit is contained in:
commit
93550dde60
196 changed files with 6010 additions and 2185 deletions
4
.github/workflows/cypress.yaml
vendored
4
.github/workflows/cypress.yaml
vendored
|
@ -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:
|
||||
|
|
50
CHANGELOG.md
50
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)
|
||||
=====================================================================================================
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -77,7 +77,7 @@ async function proxyStart(synapse: SynapseInstance): Promise<ProxyInstance> {
|
|||
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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -65,7 +65,7 @@ limitations under the License.
|
|||
.mx_UseCaseSelection_skip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: start;
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
37
res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss
Normal file
37
res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss
Normal file
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
10
res/img/element-icons/room/composer/plain_text.svg
Normal file
10
res/img/element-icons/room/composer/plain_text.svg
Normal file
|
@ -0,0 +1,10 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1456_146350)">
|
||||
<path d="M1 18.6667C1 19.4 1.6 20 2.33333 20H18.3333C19.0667 20 19.6667 19.4 19.6667 18.6667C19.6667 17.9333 19.0667 17.3333 18.3333 17.3333H2.33333C1.6 17.3333 1 17.9333 1 18.6667ZM7 11.7333H13.6667L14.5467 13.8667C14.7467 14.3467 15.2133 14.6667 15.7333 14.6667C16.6533 14.6667 17.2667 13.72 16.9067 12.88L11.7333 0.92C11.4933 0.36 10.9467 0 10.3333 0C9.72 0 9.17333 0.36 8.93333 0.92L3.76 12.88C3.4 13.72 4.02667 14.6667 4.94667 14.6667C5.46667 14.6667 5.93333 14.3467 6.13333 13.8667L7 11.7333ZM10.3333 2.64L12.8267 9.33333H7.84L10.3333 2.64Z" fill="#C1C6CD"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1456_146350">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 818 B |
11
res/img/element-icons/room/composer/rich_text.svg
Normal file
11
res/img/element-icons/room/composer/rich_text.svg
Normal file
|
@ -0,0 +1,11 @@
|
|||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1456_146365)">
|
||||
<path d="M7.00042 13.7333H13.6671L14.5471 15.8667C14.7471 16.3467 15.2137 16.6667 15.7337 16.6667C16.6537 16.6667 17.2671 15.72 16.9071 14.88L11.7337 2.92C11.4937 2.36 10.9471 2 10.3337 2C9.72042 2 9.17375 2.36 8.93375 2.92L3.76042 14.88C3.40042 15.72 4.02708 16.6667 4.94708 16.6667C5.46708 16.6667 5.93375 16.3467 6.13375 15.8667L7.00042 13.7333ZM10.3337 4.64L12.8271 11.3333H7.84042L10.3337 4.64Z" fill="#C1C6CD"/>
|
||||
<path d="M0.5 9.66927C0.5 10.6787 1.32386 11.5026 2.33333 11.5026H18.3333C19.3428 11.5026 20.1667 10.6787 20.1667 9.66927C20.1667 8.6598 19.3428 7.83594 18.3333 7.83594H2.33333C1.32386 7.83594 0.5 8.6598 0.5 9.66927Z" fill="#C1C6CD" stroke="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1456_146365">
|
||||
<rect width="20" height="20" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
After Width: | Height: | Size: 921 B |
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
) {
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import { _t } from './languageHandler';
|
||||
|
||||
export function levelRoleMap(usersDefault: number) {
|
||||
export function levelRoleMap(usersDefault: number): Record<number | "undefined", string> {
|
||||
return {
|
||||
undefined: _t('Default'),
|
||||
0: _t('Restricted'),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -60,6 +60,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
|
|||
private recorderProcessor: ScriptProcessorNode;
|
||||
private recording = false;
|
||||
private observable: SimpleObservable<IRecordingUpdate>;
|
||||
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
|
||||
|
|
|
@ -835,6 +835,13 @@ export default class MessagePanel extends React.Component<IProps, IState> {
|
|||
: 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 ||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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<IProps, IState> {
|
|||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
}
|
||||
|
||||
dis.dispatch<ThreadPayload>({
|
||||
action: Action.ViewThread,
|
||||
thread_id: null,
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps) {
|
||||
|
@ -225,11 +228,13 @@ export default class ThreadView extends React.Component<IProps, IState> {
|
|||
};
|
||||
|
||||
private async postThreadUpdate(thread: Thread): Promise<void> {
|
||||
dis.dispatch<ThreadPayload>({
|
||||
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<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private nextBatch: string | undefined | null = null;
|
||||
|
||||
private onPaginationRequest = async (
|
||||
timelineWindow: TimelineWindow | null,
|
||||
direction = Direction.Backward,
|
||||
limit = 20,
|
||||
): Promise<boolean> => {
|
||||
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<IProps, IState> {
|
|||
highlightedEventId={highlightedEventId}
|
||||
eventScrollIntoView={this.props.initialEventScrollIntoView}
|
||||
onEventScrolledIntoView={this.resetJumpToEvent}
|
||||
onPaginationRequest={this.onPaginationRequest}
|
||||
/>
|
||||
</>;
|
||||
} else {
|
||||
|
|
|
@ -1409,24 +1409,28 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
|||
// 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<IProps, IState> {
|
|||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<ComponentProps<typeof BaseAvatar>, "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<IProps, IState> {
|
|||
}
|
||||
|
||||
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<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
<BaseAvatar
|
||||
{...otherProps}
|
||||
className={classNames(className, {
|
||||
mx_RoomAvatar_isSpaceRoom: room?.isSpaceRoom(),
|
||||
mx_RoomAvatar_isSpaceRoom: (room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space,
|
||||
})}
|
||||
name={roomName}
|
||||
idName={idName}
|
||||
|
|
|
@ -47,7 +47,7 @@ const ShareLatestLocation: React.FC<Props> = ({ latestLocationState }) => {
|
|||
return <>
|
||||
<TooltipTarget label={_t('Open in OpenStreetMap')}>
|
||||
<a
|
||||
data-test-id='open-location-in-osm'
|
||||
data-testid='open-location-in-osm'
|
||||
href={mapLink}
|
||||
target='_blank'
|
||||
rel='noreferrer noopener'
|
||||
|
|
|
@ -382,7 +382,13 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
|||
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<IProps, IState>
|
|||
return (
|
||||
<React.Fragment>
|
||||
<IconizedContextMenu
|
||||
{...this.props}
|
||||
{...other}
|
||||
className="mx_MessageContextMenu"
|
||||
compact={true}
|
||||
data-testid="mx_MessageContextMenu"
|
||||
|
|
|
@ -93,6 +93,7 @@ import { TooltipOption } from "./TooltipOption";
|
|||
import { isLocalRoom } from "../../../../utils/localRoom/isLocalRoom";
|
||||
import { useSlidingSyncRoomSearch } from "../../../../hooks/useSlidingSyncRoomSearch";
|
||||
import { shouldShowFeedback } from "../../../../utils/Feedback";
|
||||
import RoomAvatar from "../../avatars/RoomAvatar";
|
||||
|
||||
const MAX_RECENT_SEARCHES = 10;
|
||||
const SECTION_LIMIT = 50; // only show 50 results per section for performance reasons
|
||||
|
@ -656,6 +657,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
|||
shouldPeek: result.publicRoom.world_readable || cli.isGuest(),
|
||||
}, true, ev.type !== "click");
|
||||
};
|
||||
|
||||
return (
|
||||
<Option
|
||||
id={`mx_SpotlightDialog_button_result_${result.publicRoom.room_id}`}
|
||||
|
@ -674,13 +676,14 @@ const SpotlightDialog: React.FC<IProps> = ({ 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`}
|
||||
>
|
||||
<BaseAvatar
|
||||
<RoomAvatar
|
||||
className="mx_SearchResultAvatar"
|
||||
url={result?.publicRoom?.avatar_url
|
||||
? mediaFromMxc(result?.publicRoom?.avatar_url).getSquareThumbnailHttp(AVATAR_SIZE)
|
||||
: null}
|
||||
name={result.publicRoom.name}
|
||||
idName={result.publicRoom.room_id}
|
||||
oobData={{
|
||||
roomId: result.publicRoom.room_id,
|
||||
name: result.publicRoom.name,
|
||||
avatarUrl: result.publicRoom.avatar_url,
|
||||
roomType: result.publicRoom.room_type,
|
||||
}}
|
||||
width={AVATAR_SIZE}
|
||||
height={AVATAR_SIZE}
|
||||
/>
|
||||
|
|
|
@ -75,7 +75,7 @@ type IProps<T extends keyof JSX.IntrinsicElements> = DynamicHtmlElementProps<T>
|
|||
onClick: ((e: ButtonEvent) => void | Promise<void>) | null;
|
||||
};
|
||||
|
||||
interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
|
||||
export interface IAccessibleButtonProps extends React.InputHTMLAttributes<Element> {
|
||||
ref?: React.Ref<Element>;
|
||||
}
|
||||
|
||||
|
|
|
@ -82,7 +82,7 @@ export default class DialogButtons extends React.Component<IProps> {
|
|||
cancelButton = <button
|
||||
// important: the default type is 'submit' and this button comes before the
|
||||
// primary in the DOM so will get form submissions unless we make it not a submit.
|
||||
data-test-id="dialog-cancel-button"
|
||||
data-testid="dialog-cancel-button"
|
||||
type="button"
|
||||
onClick={this.onCancelClick}
|
||||
className={this.props.cancelButtonClass}
|
||||
|
@ -104,7 +104,7 @@ export default class DialogButtons extends React.Component<IProps> {
|
|||
{ cancelButton }
|
||||
{ this.props.children }
|
||||
<button type={this.props.primaryIsSubmit ? 'submit' : 'button'}
|
||||
data-test-id="dialog-primary-button"
|
||||
data-testid="dialog-primary-button"
|
||||
className={primaryButtonClassName}
|
||||
onClick={this.props.onPrimaryButtonClick}
|
||||
autoFocus={this.props.focus}
|
||||
|
|
56
src/components/views/elements/LearnMore.tsx
Normal file
56
src/components/views/elements/LearnMore.tsx
Normal file
|
@ -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 from 'react';
|
||||
|
||||
import { _t } from '../../../languageHandler';
|
||||
import Modal from '../../../Modal';
|
||||
import InfoDialog from '../dialogs/InfoDialog';
|
||||
import AccessibleButton, { IAccessibleButtonProps } from './AccessibleButton';
|
||||
|
||||
export interface LearnMoreProps extends IAccessibleButtonProps {
|
||||
title: string;
|
||||
description: string | React.ReactNode;
|
||||
}
|
||||
|
||||
const LearnMore: React.FC<LearnMoreProps> = ({
|
||||
title,
|
||||
description,
|
||||
...rest
|
||||
}) => {
|
||||
const onClick = () => {
|
||||
Modal.createDialog(
|
||||
InfoDialog,
|
||||
{
|
||||
title,
|
||||
description,
|
||||
button: _t('Got it'),
|
||||
hasCloseButton: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return <AccessibleButton
|
||||
{...rest}
|
||||
kind='link_inline'
|
||||
onClick={onClick}
|
||||
className='mx_LearnMore_button'
|
||||
>
|
||||
{ _t('Learn more') }
|
||||
</AccessibleButton>;
|
||||
};
|
||||
|
||||
export default LearnMore;
|
|
@ -44,14 +44,13 @@ interface IProps {
|
|||
}
|
||||
|
||||
interface IState {
|
||||
levelRoleMap: {};
|
||||
levelRoleMap: Partial<Record<number | "undefined", string>>;
|
||||
// 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<IProps, IState> {
|
||||
|
@ -101,7 +100,7 @@ export default class PowerSelector extends React.Component<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
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<HTMLInputElement>): void => {
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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<IBodyProps, IState> {
|
|||
|
||||
render() {
|
||||
if (this.props.editState) {
|
||||
return <EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />;
|
||||
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||
return isWysiwygComposerEnabled ?
|
||||
<EditWysiwygComposer editorStateTransfer={this.props.editState} className="mx_EventTile_content" /> :
|
||||
<EditMessageComposer editState={this.props.editState} className="mx_EventTile_content" />;
|
||||
}
|
||||
const mxEvent = this.props.mxEvent;
|
||||
const content = mxEvent.getContent();
|
||||
|
|
|
@ -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<IProps> {
|
|||
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<IProps> {
|
|||
? <HeaderButton
|
||||
key={RightPanelPhases.ThreadPanel}
|
||||
name="threadsButton"
|
||||
data-testid="threadsButton"
|
||||
title={_t("Threads")}
|
||||
onClick={this.onThreadsPanelClicked}
|
||||
isHighlighted={this.isPhase(RoomHeaderButtons.THREAD_PHASES)}
|
||||
isUnread={this.threadNotificationState.color > 0}
|
||||
isUnread={this.state.threadNotificationColor > 0}
|
||||
>
|
||||
<UnreadIndicator color={this.threadNotificationState.color} />
|
||||
<UnreadIndicator color={this.state.threadNotificationColor} />
|
||||
</HeaderButton>
|
||||
: null,
|
||||
);
|
||||
|
|
|
@ -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<IProps, IState> {
|
||||
export class UnwrappedEventTile extends React.Component<EventTileProps, IState> {
|
||||
private suppressReadReceiptAnimation: boolean;
|
||||
private isListeningForReceipts: boolean;
|
||||
private tile = React.createRef<IEventTileType>();
|
||||
|
@ -267,7 +269,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
|
|||
static contextType = RoomContext;
|
||||
public context!: React.ContextType<typeof RoomContext>;
|
||||
|
||||
constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
constructor(props: EventTileProps, context: React.ContextType<typeof MatrixClientContext>) {
|
||||
super(props, context);
|
||||
|
||||
const thread = this.thread;
|
||||
|
@ -394,7 +396,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
|
||||
// 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<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
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<IProps, IState> {
|
|||
}
|
||||
}
|
||||
|
||||
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<IProps, IState> {
|
|||
|
||||
private renderThreadInfo(): React.ReactNode {
|
||||
if (this.state.thread?.id === this.props.mxEvent.getId()) {
|
||||
return <ThreadSummary mxEvent={this.props.mxEvent} thread={this.state.thread} />;
|
||||
return <ThreadSummary
|
||||
mxEvent={this.props.mxEvent}
|
||||
thread={this.state.thread}
|
||||
data-testid="thread-summary"
|
||||
/>;
|
||||
}
|
||||
|
||||
if (this.context.timelineRenderingType === TimelineRenderingType.Search && this.props.mxEvent.threadRootId) {
|
||||
|
@ -667,7 +680,7 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
|
|||
}, 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<IProps, IState> {
|
|||
]);
|
||||
}
|
||||
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<IProps, IState> {
|
|||
"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<IProps, IState> {
|
|||
</RovingAccessibleTooltipButton>
|
||||
</Toolbar>
|
||||
{ msgOption }
|
||||
<UnreadNotificationBadge
|
||||
room={room}
|
||||
threadId={this.props.mxEvent.getId()} />
|
||||
</>)
|
||||
);
|
||||
}
|
||||
|
@ -1512,10 +1531,12 @@ export class UnwrappedEventTile extends React.Component<IProps, IState> {
|
|||
}
|
||||
|
||||
// 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<UnwrappedEventTile>) => {
|
||||
return <TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout}>
|
||||
<UnwrappedEventTile ref={ref} {...props} />
|
||||
</TileErrorBoundary>;
|
||||
const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject<UnwrappedEventTile>) => {
|
||||
return <>
|
||||
<TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout}>
|
||||
<UnwrappedEventTile ref={ref} {...props} />
|
||||
</TileErrorBoundary>
|
||||
</>;
|
||||
});
|
||||
export default SafeEventTile;
|
||||
|
||||
|
|
|
@ -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<IProps, IState> {
|
||||
export class MessageComposer extends React.Component<IProps, IState> {
|
||||
private dispatcherRef?: string;
|
||||
private messageComposerInput = createRef<SendMessageComposerClass>();
|
||||
private voiceRecordingButton = createRef<VoiceRecordComposerTile>();
|
||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||
private instanceId: number;
|
||||
private composerSendMessage?: () => void;
|
||||
|
||||
private _voiceRecording: Optional<VoiceMessageRecording>;
|
||||
|
||||
|
@ -116,6 +121,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
public static defaultProps = {
|
||||
compact: false,
|
||||
showVoiceBroadcastButton: false,
|
||||
isRichTextEnabled: true,
|
||||
};
|
||||
|
||||
public constructor(props: IProps) {
|
||||
|
@ -124,6 +130,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
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<IProps, IState> {
|
|||
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
|
||||
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
|
||||
showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
|
||||
isWysiwygLabEnabled: SettingsStore.getValue<boolean>("feature_wysiwyg_composer"),
|
||||
isRichTextEnabled: true,
|
||||
initialComposerContent: '',
|
||||
};
|
||||
|
||||
this.instanceId = instanceCount++;
|
||||
|
@ -138,6 +148,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
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<VoiceMessageRecording> {
|
||||
|
@ -218,6 +229,12 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
}
|
||||
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<IProps, IState> {
|
|||
}
|
||||
|
||||
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<IProps, IState> {
|
|||
|
||||
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<IProps, IState> {
|
|||
}
|
||||
|
||||
public render() {
|
||||
const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
|
||||
const controls = [
|
||||
this.props.e2eStatus ?
|
||||
<E2EIcon key="e2eIcon" status={this.props.e2eStatus} className="mx_MessageComposer_e2eIcon" /> :
|
||||
|
@ -400,18 +435,15 @@ export default class MessageComposer extends React.Component<IProps, IState> {
|
|||
|
||||
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
|
||||
if (canSendMessages) {
|
||||
if (isWysiwygComposerEnabled) {
|
||||
if (this.state.isWysiwygLabEnabled) {
|
||||
controls.push(
|
||||
<WysiwygComposer key="controls_input"
|
||||
<SendWysiwygComposer key="controls_input"
|
||||
disabled={this.state.haveRecording}
|
||||
onChange={this.onWysiwygChange}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
relation={this.props.relation}
|
||||
replyToEvent={this.props.replyToEvent}>
|
||||
{ (sendMessage) => {
|
||||
this.composerSendMessage = sendMessage;
|
||||
} }
|
||||
</WysiwygComposer>,
|
||||
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<IProps, IState> {
|
|||
"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<IProps, IState> {
|
|||
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<IProps, IState> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer);
|
||||
export default MessageComposerWithMatrixClient;
|
||||
|
|
|
@ -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<IProps> = (props: IProps) => {
|
|||
} else {
|
||||
mainButtons = [
|
||||
emojiButton(props),
|
||||
props.showComposerModeButton &&
|
||||
<ComposerModeButton key="composerModeButton" isRichTextEnabled={props.isRichTextEnabled} onClick={props.onComposerModeClick} />,
|
||||
uploadButton(), // props passed via UploadButtonContext
|
||||
];
|
||||
moreButtons = [
|
||||
|
@ -397,4 +402,23 @@ function showLocationButton(
|
|||
);
|
||||
}
|
||||
|
||||
interface WysiwygToggleButtonProps {
|
||||
isRichTextEnabled: boolean;
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
}
|
||||
|
||||
function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps) {
|
||||
const title = isRichTextEnabled ? _t("Show plain text") : _t("Show formatting");
|
||||
|
||||
return <CollapsibleButton
|
||||
className="mx_MessageComposer_button"
|
||||
iconClassName={classNames({
|
||||
"mx_MessageComposer_plain_text": isRichTextEnabled,
|
||||
"mx_MessageComposer_rich_text": !isRichTextEnabled,
|
||||
})}
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
/>;
|
||||
}
|
||||
|
||||
export default MessageComposerButtons;
|
||||
|
|
|
@ -175,14 +175,22 @@ const NewRoomIntro = () => {
|
|||
}
|
||||
|
||||
const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url;
|
||||
body = <React.Fragment>
|
||||
<MiniAvatarUploader
|
||||
hasAvatar={!!avatarUrl}
|
||||
let avatar = (
|
||||
<RoomAvatar room={room} width={AVATAR_SIZE} height={AVATAR_SIZE} viewAvatarOnClick={!!avatarUrl} />
|
||||
);
|
||||
|
||||
if (!avatarUrl) {
|
||||
avatar = <MiniAvatarUploader
|
||||
hasAvatar={false}
|
||||
noAvatarLabel={_t("Add a photo, so people can easily spot your room.")}
|
||||
setAvatarUrl={url => cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, '')}
|
||||
>
|
||||
<RoomAvatar room={room} width={AVATAR_SIZE} height={AVATAR_SIZE} viewAvatarOnClick={true} />
|
||||
</MiniAvatarUploader>
|
||||
{ avatar }
|
||||
</MiniAvatarUploader>;
|
||||
}
|
||||
|
||||
body = <React.Fragment>
|
||||
{ avatar }
|
||||
|
||||
<h2>{ room.name }</h2>
|
||||
|
||||
|
|
|
@ -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<XOR<IProps, I
|
|||
|
||||
public render(): React.ReactElement {
|
||||
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
|
||||
const { notification, showUnsentTooltip, forceCount, roomId, onClick, ...props } = this.props;
|
||||
const { notification, showUnsentTooltip, forceCount, onClick } = this.props;
|
||||
|
||||
// Don't show a badge if we don't need to
|
||||
if (notification.isIdle) return null;
|
||||
|
||||
// TODO: Update these booleans for FTUE Notifications: https://github.com/vector-im/element-web/issues/14261
|
||||
// As of writing, that is "if red, show count always" and "optionally show counts instead of dots".
|
||||
// See git diff for what that boolean state looks like.
|
||||
// XXX: We ignore this.state.showCounts (the setting which controls counts vs dots).
|
||||
const hasAnySymbol = notification.symbol || notification.count > 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 = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccessibleButton
|
||||
aria-label={label}
|
||||
{...props}
|
||||
className={classes}
|
||||
onClick={onClick}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<span className="mx_NotificationBadge_count">{ symbol }</span>
|
||||
{ tooltip }
|
||||
</AccessibleButton>
|
||||
);
|
||||
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 = <Tooltip className="mx_RoleButton_tooltip" label={label} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<span className="mx_NotificationBadge_count">{ symbol }</span>
|
||||
</div>
|
||||
);
|
||||
return <StatelessNotificationBadge
|
||||
label={label}
|
||||
symbol={notification.symbol}
|
||||
count={notification.count}
|
||||
color={notification.color}
|
||||
onClick={onClick}
|
||||
onMouseOver={this.onMouseOver}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{ tooltip }
|
||||
</StatelessNotificationBadge>;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
<AccessibleButton
|
||||
aria-label={props.label}
|
||||
{...props}
|
||||
className={classes}
|
||||
onClick={props.onClick}
|
||||
onMouseOver={props.onMouseOver}
|
||||
onMouseLeave={props.onMouseLeave}
|
||||
>
|
||||
<span className="mx_NotificationBadge_count">{ symbol }</span>
|
||||
{ props.children }
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<span className="mx_NotificationBadge_count">{ symbol }</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 <StatelessNotificationBadge
|
||||
symbol={symbol}
|
||||
count={count}
|
||||
color={color}
|
||||
/>;
|
||||
}
|
|
@ -263,9 +263,9 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
|
|||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -570,8 +570,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
|
|||
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"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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 (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
className="mx_ThreadSummary"
|
||||
onClick={(ev: ButtonEvent) => {
|
||||
defaultDispatcher.dispatch<ShowThreadPayload>({
|
||||
|
@ -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 <>
|
||||
<MemberAvatar
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
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 classNames from 'classnames';
|
||||
|
||||
import EditorStateTransfer from '../../../../utils/EditorStateTransfer';
|
||||
import { WysiwygComposer } from './components/WysiwygComposer';
|
||||
import { EditionButtons } from './components/EditionButtons';
|
||||
import { useWysiwygEditActionHandler } from './hooks/useWysiwygEditActionHandler';
|
||||
import { useEditing } from './hooks/useEditing';
|
||||
import { useInitialContent } from './hooks/useInitialContent';
|
||||
|
||||
interface ContentProps {
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const Content = forwardRef<HTMLElement, ContentProps>(
|
||||
function Content({ disabled }: ContentProps, forwardRef: RefObject<HTMLElement>) {
|
||||
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 && <WysiwygComposer
|
||||
className={classNames("mx_EditWysiwygComposer", className)}
|
||||
initialContent={initialContent}
|
||||
onChange={onChange}
|
||||
onSend={editMessage}
|
||||
{...props}>
|
||||
{ (ref) => (
|
||||
<>
|
||||
<Content disabled={props.disabled} ref={ref} />
|
||||
<EditionButtons onCancelClick={endEditing} onSaveClick={editMessage} isSaveDisabled={isSaveDisabled} />
|
||||
</>)
|
||||
}
|
||||
</WysiwygComposer>;
|
||||
}
|
|
@ -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<HTMLElement, ContentProps>(
|
||||
function Content({ disabled, composerFunctions }: ContentProps, forwardRef: RefObject<HTMLElement>) {
|
||||
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 <Composer className="mx_SendWysiwygComposer" {...props}>
|
||||
{ (ref, composerFunctions) => (
|
||||
<Content disabled={props.disabled} ref={ref} composerFunctions={composerFunctions} />
|
||||
) }
|
||||
</Composer>;
|
||||
}
|
|
@ -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 (
|
||||
<div className="mx_WysiwygComposer">
|
||||
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
|
||||
<Editor ref={ref} disabled={!isWysiwygReady || disabled} />
|
||||
{ children?.(memoizedSendMessage) }
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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<HTMLButtonElement>;
|
||||
onSaveClick: MouseEventHandler<HTMLButtonElement>;
|
||||
isSaveDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function EditionButtons({ onCancelClick, onSaveClick, isSaveDisabled = false }: EditionButtonsProps) {
|
||||
return <div className="mx_EditWysiwygComposer_buttons">
|
||||
<AccessibleButton kind="secondary" onClick={onCancelClick}>
|
||||
{ _t("Cancel") }
|
||||
</AccessibleButton>
|
||||
<AccessibleButton kind="primary" onClick={onSaveClick} disabled={isSaveDisabled}>
|
||||
{ _t("Save") }
|
||||
</AccessibleButton>
|
||||
</div>;
|
||||
}
|
|
@ -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<typeof useWysiwyg>['wysiwyg'];
|
||||
formattingStates: ReturnType<typeof useWysiwyg>['formattingStates'];
|
||||
composer: FormattingFunctions;
|
||||
formattingStates: FormattingStates;
|
||||
}
|
||||
|
||||
export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) {
|
|
@ -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<HTMLDivElement | null>,
|
||||
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 <div
|
||||
data-testid="PlainTextComposer"
|
||||
className={className}
|
||||
onInput={onInput}
|
||||
onPaste={onPaste}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<Editor ref={ref} disabled={disabled} />
|
||||
{ children?.(ref, composerFunctions) }
|
||||
</div>;
|
||||
}
|
|
@ -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<HTMLDivElement | null>,
|
||||
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 (
|
||||
<div data-testid="WysiwygComposer" className={className}>
|
||||
<FormattingButtons composer={wysiwyg} formattingStates={formattingStates} />
|
||||
<Editor ref={ref} disabled={!isReady} />
|
||||
{ children?.(ref, wysiwyg) }
|
||||
</div>
|
||||
);
|
||||
});
|
|
@ -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<HTMLDivElement>) {
|
||||
return useMemo(() => ({
|
||||
clear: () => {
|
||||
if (ref.current) {
|
||||
ref.current.innerHTML = '';
|
||||
}
|
||||
},
|
||||
}), [ref]);
|
||||
}
|
|
@ -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 };
|
||||
}
|
|
@ -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<string>(() => {
|
||||
if (editorStateTransfer && roomContext.room) {
|
||||
return parseEditorStateTransfer(editorStateTransfer, roomContext.room, mxClient);
|
||||
}
|
||||
}, [editorStateTransfer, roomContext, mxClient]);
|
||||
}
|
|
@ -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<boolean>("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]);
|
||||
}
|
|
@ -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<HTMLElement>) {
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.innerText = initialContent;
|
||||
}
|
||||
}, [ref, initialContent]);
|
||||
}
|
|
@ -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<HTMLDivElement>();
|
||||
const send = useCallback((() => {
|
||||
if (ref.current) {
|
||||
ref.current.innerHTML = '';
|
||||
}
|
||||
onSend();
|
||||
}), [ref, onSend]);
|
||||
|
||||
const onInput = useCallback((event: SyntheticEvent<HTMLDivElement, InputEvent | ClipboardEvent>) => {
|
||||
if (isDivElement(event.target)) {
|
||||
onChange(event.target.innerHTML);
|
||||
}
|
||||
}, [onChange]);
|
||||
|
||||
const isCtrlEnter = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
|
||||
const onKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && (!isCtrlEnter || (isCtrlEnter && event.ctrlKey))) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
send();
|
||||
}
|
||||
}, [isCtrlEnter, send]);
|
||||
|
||||
return { ref, onInput, onPaste: onInput, onKeyDown };
|
||||
}
|
|
@ -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<HTMLElement>) {
|
||||
useEffect(() => {
|
||||
if (ref.current && !disabled) {
|
||||
setCursorPositionAtTheEnd(ref.current);
|
||||
}
|
||||
}, [ref, disabled]);
|
||||
}
|
|
@ -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<HTMLElement>,
|
||||
) {
|
||||
const roomContext = useRoomContext();
|
||||
const timeoutId = useRef<number>();
|
||||
|
||||
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);
|
||||
}
|
|
@ -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<HTMLElement>,
|
||||
composerFunctions: ComposerFunctions,
|
||||
) {
|
||||
const roomContext = useRoomContext();
|
||||
const timeoutId = useRef<number>();
|
||||
|
||||
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);
|
||||
}
|
|
@ -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<HTMLElement>,
|
||||
) {
|
||||
const roomContext = useRoomContext();
|
||||
const timeoutId = useRef<number>();
|
||||
|
||||
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<HTMLElement>,
|
||||
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();
|
||||
}
|
19
src/components/views/rooms/wysiwyg_composer/index.ts
Normal file
19
src/components/views/rooms/wysiwyg_composer/index.ts
Normal file
|
@ -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';
|
19
src/components/views/rooms/wysiwyg_composer/types.ts
Normal file
19
src/components/views/rooms/wysiwyg_composer/types.ts
Normal file
|
@ -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;
|
||||
};
|
|
@ -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(/<br>/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<boolean>("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;
|
||||
}
|
50
src/components/views/rooms/wysiwyg_composer/utils/editing.ts
Normal file
50
src/components/views/rooms/wysiwyg_composer/utils/editing.ts
Normal file
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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<SendMessageParams, 'roomContext' | 'mxClient'>,
|
||||
): 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<ComposerEvent>({
|
||||
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<ISendEventResponse> | 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;
|
||||
}
|
|
@ -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<Props & { stopEditing: () => void }> = ({
|
|||
<Caption
|
||||
id={descriptionId}
|
||||
>
|
||||
{ _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.') }
|
||||
<LearnMore
|
||||
title={_t('Renaming sessions')}
|
||||
description={<>
|
||||
<p>
|
||||
{ _t(`Other users in direct messages and rooms that you join ` +
|
||||
`are able to view a full list of your sessions.`,
|
||||
) }
|
||||
</p>
|
||||
<p>
|
||||
{ _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.`,
|
||||
) }
|
||||
</p>
|
||||
</>}
|
||||
/>
|
||||
{ !!error &&
|
||||
<span
|
||||
data-testid="device-rename-error"
|
||||
|
|
|
@ -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 from 'react';
|
||||
|
||||
import { _t } from "../../../../languageHandler";
|
||||
import LearnMore, { LearnMoreProps } from "../../elements/LearnMore";
|
||||
import { DeviceSecurityVariation } from "./types";
|
||||
|
||||
interface Props extends Omit<LearnMoreProps, 'title' | 'description'> {
|
||||
variation: DeviceSecurityVariation;
|
||||
}
|
||||
|
||||
const securityCardContent: Record<DeviceSecurityVariation, {
|
||||
title: string;
|
||||
description: React.ReactNode | string;
|
||||
}> = {
|
||||
[DeviceSecurityVariation.Verified]: {
|
||||
title: _t('Verified sessions'),
|
||||
description: <>
|
||||
<p>{ _t('Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.') }
|
||||
</p>
|
||||
<p>
|
||||
{ _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.`,
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</>,
|
||||
},
|
||||
[DeviceSecurityVariation.Unverified]: {
|
||||
title: _t('Unverified sessions'),
|
||||
description: <>
|
||||
<p>{ _t('Unverified sessions are sessions that have logged in with your credentials but have not been cross-verified.') }
|
||||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
`You should make especially certain that you recognise these sessions ` +
|
||||
`as they could represent an unauthorised use of your account.`,
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</>,
|
||||
},
|
||||
[DeviceSecurityVariation.Inactive]: {
|
||||
title: _t('Inactive sessions'),
|
||||
description: <>
|
||||
<p>{ _t('Inactive sessions are sessions you have not used in some time, but they continue to receive encryption keys.') }
|
||||
</p>
|
||||
<p>
|
||||
{ _t(
|
||||
`Removing inactive sessions improves security and performance, ` +
|
||||
`and makes it easier for you to identify if a new session is suspicious.`,
|
||||
)
|
||||
}
|
||||
</p>
|
||||
</>,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* LearnMore with content for device security warnings
|
||||
*/
|
||||
export const DeviceSecurityLearnMore: React.FC<Props> = ({ variation }) => {
|
||||
const { title, description } = securityCardContent[variation];
|
||||
return <LearnMore title={title} description={description} />;
|
||||
};
|
|
@ -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<Props> = ({
|
|||
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.') }
|
||||
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Verified} />
|
||||
</>,
|
||||
} : {
|
||||
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.') }
|
||||
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
|
||||
</>,
|
||||
};
|
||||
return <DeviceSecurityCard
|
||||
{...securityCardProps}
|
||||
|
|
|
@ -38,6 +38,7 @@ import {
|
|||
import { DevicesState } from './useOwnDevices';
|
||||
import FilteredDeviceListHeader from './FilteredDeviceListHeader';
|
||||
import Spinner from '../../elements/Spinner';
|
||||
import { DeviceSecurityLearnMore } from './DeviceSecurityLearnMore';
|
||||
|
||||
interface Props {
|
||||
devices: DevicesDictionary;
|
||||
|
@ -73,48 +74,53 @@ const getFilteredSortedDevices = (devices: DevicesDictionary, filter?: DeviceSec
|
|||
const ALL_FILTER_ID = 'ALL';
|
||||
type DeviceFilterKey = DeviceSecurityVariation | typeof ALL_FILTER_ID;
|
||||
|
||||
const securityCardContent: Record<DeviceSecurityVariation, {
|
||||
title: string;
|
||||
description: string;
|
||||
}> = {
|
||||
[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<string>(DeviceSecurityVariation).includes(filter);
|
||||
|
||||
const FilterSecurityCard: React.FC<{ filter?: DeviceFilterKey }> = ({ filter }) => {
|
||||
switch (filter) {
|
||||
case DeviceSecurityVariation.Verified:
|
||||
return <div className='mx_FilteredDeviceList_securityCard'>
|
||||
<DeviceSecurityCard
|
||||
variation={DeviceSecurityVariation.Verified}
|
||||
heading={_t('Verified sessions')}
|
||||
description={_t(
|
||||
`For best security, sign out from any session` +
|
||||
` that you don't recognize or use anymore.`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
;
|
||||
case DeviceSecurityVariation.Unverified:
|
||||
return <div className='mx_FilteredDeviceList_securityCard'>
|
||||
<DeviceSecurityCard
|
||||
variation={DeviceSecurityVariation.Unverified}
|
||||
heading={_t('Unverified sessions')}
|
||||
description={_t(
|
||||
`Verify your sessions for enhanced secure messaging or sign out`
|
||||
+ ` from those you don't recognize or use anymore.`,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
;
|
||||
case DeviceSecurityVariation.Inactive:
|
||||
return <div className='mx_FilteredDeviceList_securityCard'>
|
||||
<DeviceSecurityCard
|
||||
variation={DeviceSecurityVariation.Inactive}
|
||||
heading={_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 },
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
;
|
||||
default:
|
||||
return null;
|
||||
if (isSecurityVariation(filter)) {
|
||||
const { title, description } = securityCardContent[filter];
|
||||
return <div className='mx_FilteredDeviceList_securityCard'>
|
||||
<DeviceSecurityCard
|
||||
variation={filter}
|
||||
heading={title}
|
||||
description={<span>
|
||||
{ description }
|
||||
<DeviceSecurityLearnMore
|
||||
variation={filter}
|
||||
/>
|
||||
</span>}
|
||||
/>
|
||||
</div>
|
||||
;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const getNoResultsMessage = (filter?: DeviceSecurityVariation): string => {
|
||||
|
|
|
@ -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<Props> = ({
|
|||
<DeviceSecurityCard
|
||||
variation={DeviceSecurityVariation.Unverified}
|
||||
heading={_t('Unverified sessions')}
|
||||
description={_t(
|
||||
`Verify your sessions for enhanced secure messaging` +
|
||||
description={<>
|
||||
{ _t(
|
||||
`Verify your sessions for enhanced secure messaging` +
|
||||
` or sign out from those you don't recognize or use anymore.`,
|
||||
)}
|
||||
) }
|
||||
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Unverified} />
|
||||
</>}
|
||||
>
|
||||
<AccessibleButton
|
||||
kind='link_inline'
|
||||
|
@ -91,11 +95,15 @@ const SecurityRecommendations: React.FC<Props> = ({
|
|||
<DeviceSecurityCard
|
||||
variation={DeviceSecurityVariation.Inactive}
|
||||
heading={_t('Inactive sessions')}
|
||||
description={_t(
|
||||
`Consider signing out from old sessions ` +
|
||||
`(%(inactiveAgeDays)s days or older) you don't use anymore`,
|
||||
{ inactiveAgeDays },
|
||||
)}
|
||||
description={<>
|
||||
{ _t(
|
||||
`Consider signing out from old sessions ` +
|
||||
`(%(inactiveAgeDays)s days or older) you don't use anymore`,
|
||||
{ inactiveAgeDays },
|
||||
) }
|
||||
<DeviceSecurityLearnMore variation={DeviceSecurityVariation.Inactive} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<AccessibleButton
|
||||
kind='link_inline'
|
||||
|
|
|
@ -36,6 +36,25 @@ import LoginWithQRSection from '../../devices/LoginWithQRSection';
|
|||
import LoginWithQR, { Mode } from '../../../auth/LoginWithQR';
|
||||
import SettingsStore from '../../../../../settings/SettingsStore';
|
||||
import { useAsyncMemo } from '../../../../../hooks/useAsyncMemo';
|
||||
import QuestionDialog from '../../../dialogs/QuestionDialog';
|
||||
|
||||
const confirmSignOut = async (sessionsToSignOutCount: number): Promise<boolean> => {
|
||||
const { finished } = Modal.createDialog(QuestionDialog, {
|
||||
title: _t("Sign out"),
|
||||
description: (
|
||||
<div>
|
||||
<p>{ _t("Are you sure you want to sign out of %(count)s sessions?", {
|
||||
count: sessionsToSignOutCount,
|
||||
}) }</p>
|
||||
</div>
|
||||
),
|
||||
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(
|
||||
|
|
|
@ -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<LobbyProps> = ({ 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<LobbyProps> = ({ 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<JoinCallViewProps> = ({ room, resizing, call }) => {
|
|||
lobby = <Lobby
|
||||
room={room}
|
||||
connect={connect}
|
||||
joinCallButtonTooltip={joinCallButtonTooltip}
|
||||
joinCallButtonTooltip={joinCallButtonTooltip ?? undefined}
|
||||
joinCallButtonDisabled={joinCallButtonDisabled}
|
||||
>
|
||||
{ facePile }
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
|
|
26
src/dispatcher/payloads/ThreadPayload.ts
Normal file
26
src/dispatcher/payloads/ThreadPayload.ts
Normal file
|
@ -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<ActionPayload, "action"> {
|
||||
action: Action.ViewThread;
|
||||
|
||||
thread_id: string | null;
|
||||
}
|
||||
/* eslint-enable camelcase */
|
|
@ -52,7 +52,6 @@ export const useSlidingSyncRoomSearch = () => {
|
|||
ranges: [[0, limit]],
|
||||
filters: {
|
||||
room_name_like: term,
|
||||
is_tombstoned: false,
|
||||
},
|
||||
});
|
||||
const rooms = [];
|
||||
|
|
93
src/hooks/useUnreadNotifications.ts
Normal file
93
src/hooks/useUnreadNotifications.ts
Normal file
|
@ -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<string | null>(null);
|
||||
const [count, setCount] = useState<number>(0);
|
||||
const [color, setColor] = useState<NotificationColor>(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,
|
||||
};
|
||||
};
|
|
@ -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",
|
||||
|
|
|
@ -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<void> {
|
||||
|
@ -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<IWidgetApiRequest>) => {
|
||||
private onScreenshareRequest = async (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||
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 });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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<string> {
|
||||
return this.state.threadId;
|
||||
}
|
||||
|
||||
// The event to scroll to when the room is first viewed
|
||||
public getInitialEventId(): Optional<string> {
|
||||
return this.state.initialEventId;
|
||||
|
|
|
@ -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_";
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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<IState> {
|
|||
instance.start();
|
||||
return instance;
|
||||
})();
|
||||
|
||||
private roomMap = new Map<Room, RoomNotificationState>();
|
||||
private roomThreadsMap = new Map<Room, ThreadsRoomNotificationState>();
|
||||
|
||||
private roomThreadsMap: Map<Room, ThreadsRoomNotificationState> = new Map<Room, ThreadsRoomNotificationState>();
|
||||
private listMap = new Map<TagID, ListNotificationState>();
|
||||
private _globalState = new SummarizedNotificationState();
|
||||
|
||||
|
@ -86,18 +87,25 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<IState> {
|
|||
*/
|
||||
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));
|
||||
}
|
||||
|
|
|
@ -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<IState> 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;
|
||||
}
|
||||
|
|
|
@ -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, string[]> = {
|
||||
[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<TagID, MSC3575Filter> = {
|
|||
},
|
||||
[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<TagID, MSC3575Filter> = {
|
|||
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<IState> impl
|
|||
private counts: Record<TagID, number> = {};
|
||||
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<IState> 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<IState> 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<IState> impl
|
|||
}
|
||||
|
||||
private onSlidingSyncListUpdate(listIndex: number, joinCount: number, roomIndexToRoomId: Record<number, string>) {
|
||||
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<IState> 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<IState> 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<IState> impl
|
|||
protected async onReady(): Promise<any> {
|
||||
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<IState> 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<IState> 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,
|
||||
},
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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",
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue