Merge branch 'develop' into last-admin-leave-room-warning

This commit is contained in:
Arne Wilken 2022-10-28 15:41:49 +02:00 committed by GitHub
commit 93550dde60
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
196 changed files with 6010 additions and 2185 deletions

View file

@ -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:

View file

@ -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)
=====================================================================================================

View file

@ -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);

View file

@ -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');

View file

@ -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",

View file

@ -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",

View file

@ -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";

View file

@ -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;
}

View file

@ -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;

View file

@ -65,7 +65,7 @@ limitations under the License.
.mx_UseCaseSelection_skip {
display: flex;
flex-direction: column;
align-self: start;
align-self: flex-start;
}
}

View file

@ -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));

View file

@ -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');
}

View file

@ -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;
}
}
}

View file

@ -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;

View 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;
}
}
}

View file

@ -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;

View file

@ -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;
}

View 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

View 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

View file

@ -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;
}

View file

@ -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()
) {

View file

@ -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'),

View file

@ -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

View file

@ -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) {

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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 ||

View file

@ -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);
});
}

View file

@ -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 {

View file

@ -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();
}

View file

@ -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";

View file

@ -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';

View file

@ -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";

View file

@ -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}

View file

@ -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'

View file

@ -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"

View file

@ -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}
/>

View file

@ -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>;
}

View file

@ -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}

View 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;

View file

@ -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 => {

View file

@ -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";

View file

@ -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();

View file

@ -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,
);

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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>

View file

@ -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>;
}
}

View file

@ -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>
);
}

View file

@ -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}
/>;
}

View file

@ -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,
},
};
}

View file

@ -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"
);
}

View file

@ -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

View file

@ -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>;
}

View file

@ -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>;
}

View file

@ -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>
);
}

View 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.
*/
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>;
}

View file

@ -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) {

View 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, { 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>;
}

View file

@ -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>
);
});

View file

@ -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]);
}

View file

@ -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 };
}

View file

@ -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]);
}

View file

@ -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]);
}

View file

@ -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]);
}

View 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 { 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 };
}

View file

@ -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]);
}

View file

@ -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);
}

View 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 { 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);
}

View file

@ -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();
}

View 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';

View 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;
};

View file

@ -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;
}

View 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);
}
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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"

View file

@ -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} />;
};

View file

@ -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}

View file

@ -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 => {

View file

@ -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'

View file

@ -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(

View file

@ -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 }

View file

@ -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.
*/

View 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 */

View file

@ -52,7 +52,6 @@ export const useSlidingSyncRoomSearch = () => {
ranges: [[0, limit]],
filters: {
room_name_like: term,
is_tombstoned: false,
},
});
const rooms = [];

View 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,
};
};

View file

@ -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",

View file

@ -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 });
}
};
}

View file

@ -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;

View file

@ -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_";

View file

@ -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();

View file

@ -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));
}

View file

@ -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;
}

View file

@ -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,
},

View file

@ -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);
};
/**

View file

@ -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