From 8fa1c2bf2a16ea8d98211818ddfd3ffd796e6931 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Tue, 18 Oct 2022 14:07:55 +0100 Subject: [PATCH 01/79] [Backport staging] Fix usages of useContextMenu which never pass the ref to the element (#9450) Co-authored-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/messages/MessageActionBar.tsx | 8 ++++---- src/components/views/rooms/MessageComposerButtons.tsx | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index c510805116..c1637b9a0c 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -83,7 +83,7 @@ const OptionsButton: React.FC = ({ getRelationsForEvent, }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -123,7 +123,7 @@ const OptionsButton: React.FC = ({ onClick={onOptionsClick} onContextMenu={onOptionsClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > @@ -141,7 +141,7 @@ interface IReactButtonProps { const ReactButton: React.FC = ({ mxEvent, reactions, onFocusChange }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - const [onFocus, isActive, ref] = useRovingTabIndex(button); + const [onFocus, isActive] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -173,7 +173,7 @@ const ReactButton: React.FC = ({ mxEvent, reactions, onFocusC onClick={onClick} onContextMenu={onClick} isExpanded={menuDisplayed} - inputRef={ref} + inputRef={button} onFocus={onFocus} tabIndex={isActive ? 0 : -1} > diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index cc7ce70f44..b77bff66a8 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -179,6 +179,7 @@ const EmojiButton: React.FC = ({ addEmoji, menuPosition }) => iconClassName="mx_MessageComposer_emoji" onClick={openMenu} title={_t("Emoji")} + inputRef={button} /> { contextMenu } From c8a5788bca9c5f56eb47d35a493606ee8b1b3346 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 18 Oct 2022 14:08:43 +0100 Subject: [PATCH 02/79] Upgrade matrix-js-sdk to 21.0.0-rc.1 --- package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index b203cf51e9..037f0dd6eb 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "maplibre-gl": "^1.15.2", "matrix-encrypt-attachment": "^1.0.3", "matrix-events-sdk": "^0.0.1-beta.7", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "21.0.0-rc.1", "matrix-widget-api": "^1.1.1", "minimist": "^1.2.5", "opus-recorder": "^8.0.3", diff --git a/yarn.lock b/yarn.lock index b54bc1ec81..209e57ed16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7027,9 +7027,10 @@ matrix-events-sdk@^0.0.1-beta.7: resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1-beta.7.tgz#5ffe45eba1f67cc8d7c2377736c728b322524934" integrity sha512-9jl4wtWanUFSy2sr2lCjErN/oC8KTAtaeaozJtrgot1JiQcEI4Rda9OLgQ7nLKaqb4Z/QUx/fR3XpDzm5Jy1JA== -"matrix-js-sdk@github:matrix-org/matrix-js-sdk#develop": - version "20.1.0" - resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/8eed354e17001cd25e3cafe81f74dab499a9882e" +matrix-js-sdk@21.0.0-rc.1: + version "21.0.0-rc.1" + resolved "https://registry.yarnpkg.com/matrix-js-sdk/-/matrix-js-sdk-21.0.0-rc.1.tgz#254d7183cca845643a5da3f72de7193fafd9780c" + integrity sha512-jHJQLSb5egMMZPrDrjKY1mmsGdIaPPZNy+7MTikeb46tP3ES7Om9U6wsSouKFdW8khvg/nuxZfbEdM1lBMi15Q== dependencies: "@babel/runtime" "^7.12.5" another-json "^0.2.0" From 028a21825c54fdd399508cefaf9635e2fe2bbb6c Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 18 Oct 2022 14:12:15 +0100 Subject: [PATCH 03/79] Prepare changelog for v3.59.0-rc.1 --- CHANGELOG.md | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb088cd64..6d43cee46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,52 @@ +Changes in [3.59.0-rc.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.59.0-rc.1) (2022-10-18) +=============================================================================================================== + +## ✨ 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 + * 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) ===================================================================================================== From 9ffba57f933894a99e8634bf6ac2b4f56c367c22 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 18 Oct 2022 14:12:16 +0100 Subject: [PATCH 04/79] v3.59.0-rc.1 --- package.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 037f0dd6eb..11f3ccb129 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-react-sdk", - "version": "3.58.1", + "version": "3.59.0-rc.1", "description": "SDK for matrix.org using React", "author": "matrix.org", "repository": { @@ -23,7 +23,7 @@ "package.json", ".stylelintrc.js" ], - "main": "./src/index.ts", + "main": "./lib/index.ts", "matrix_src_main": "./src/index.ts", "matrix_lib_main": "./lib/index.ts", "matrix_lib_typings": "./lib/index.d.ts", @@ -253,5 +253,6 @@ "jestSonar": { "reportPath": "coverage", "sonar56x": true - } + }, + "typings": "./lib/index.d.ts" } From 460f60e99d447fe1e47b14f6e223acbf1413c4c0 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 12:45:51 +0200 Subject: [PATCH 05/79] First attempt to make the edition works in the WysiwygComposer --- src/components/views/messages/TextualBody.tsx | 6 +- .../views/rooms/BasicMessageComposer.tsx | 2 + .../views/rooms/EditMessageComposer.tsx | 1 + .../views/rooms/MessageComposer.tsx | 34 ++-- .../wysiwyg_composer/EditWysiwygComposer.tsx | 105 ++++++++++++ .../wysiwyg_composer/SendWysiwygComposer.tsx | 46 ++++++ .../wysiwyg_composer/WysiwygComposer.tsx | 68 -------- .../components/EditionButtons.tsx | 36 ++++ .../{ => components}/Editor.tsx | 0 .../{ => components}/FormattingButtons.tsx | 13 +- .../components/WysiwygComposer.tsx | 49 ++++++ .../hooks/useWysiwygEditActionHandler.ts | 48 ++++++ .../hooks/useWysiwygSendActionHandler.ts | 56 +++++++ .../utils.ts} | 36 +--- .../views/rooms/wysiwyg_composer/index.ts | 19 +++ .../views/rooms/wysiwyg_composer/types.ts | 21 +++ .../utils/createMessageContent.ts | 117 +++++++++++++ .../rooms/wysiwyg_composer/utils/editing.ts | 50 ++++++ .../utils/isContentModified.ts | 30 ++++ .../wysiwyg_composer/{ => utils}/message.ts | 155 +++++++++--------- src/dispatcher/actions.ts | 5 + .../views/rooms/MessageComposer-test.tsx | 2 +- .../FormattingButtons-test.tsx | 2 +- .../wysiwyg_composer/WysiwygComposer-test.tsx | 2 +- .../rooms/wysiwyg_composer/message-test.ts | 2 +- 25 files changed, 705 insertions(+), 200 deletions(-) create mode 100644 src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx delete mode 100644 src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx rename src/components/views/rooms/wysiwyg_composer/{ => components}/Editor.tsx (100%) rename src/components/views/rooms/wysiwyg_composer/{ => components}/FormattingButtons.tsx (87%) create mode 100644 src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts rename src/components/views/rooms/wysiwyg_composer/{useWysiwygActionHandler.ts => hooks/utils.ts} (52%) create mode 100644 src/components/views/rooms/wysiwyg_composer/index.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/types.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/editing.ts create mode 100644 src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts rename src/components/views/rooms/wysiwyg_composer/{ => utils}/message.ts (57%) diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 23ba901acd..983cbe51e3 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -48,6 +48,7 @@ import RoomContext from "../../../contexts/RoomContext"; import AccessibleButton from '../elements/AccessibleButton'; import { options as linkifyOpts } from "../../../linkify-matrix"; import { getParentEventId } from '../../../utils/Reply'; +import { EditWysiwygComposer } from '../rooms/wysiwyg_composer'; const MAX_HIGHLIGHT_LENGTH = 4096; @@ -562,7 +563,10 @@ export default class TextualBody extends React.Component { render() { if (this.props.editState) { - return ; + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + return isWysiwygComposerEnabled ? + : + ; } const mxEvent = this.props.mxEvent; const content = mxEvent.getContent(); diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index d74c7b5148..962059091c 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -833,6 +833,8 @@ export default class BasicMessageEditor extends React.Component } public insertPlaintext(text: string): void { + console.log('insertPlaintext', text); + debugger; this.modifiedFlag = true; const { model } = this.props; const { partCreator } = model; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 52312e1a99..bb01454127 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -350,6 +350,7 @@ class EditMessageComposer extends React.Component { +class MessageComposer extends React.Component { private dispatcherRef?: string; private messageComposerInput = createRef(); private voiceRecordingButton = createRef(); private ref: React.RefObject = createRef(); private instanceId: number; - private composerSendMessage?: () => void; private _voiceRecording: Optional; @@ -124,6 +125,7 @@ export default class MessageComposer extends React.Component { this.state = { isComposerEmpty: true, + composerContent: '', haveRecording: false, recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast isMenuOpen: false, @@ -315,7 +317,15 @@ export default class MessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); - this.composerSendMessage?.(); + // this.composerSendMessage?.(); + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + + if (isWysiwygComposerEnabled) { + const { permalinkCreator, relation, replyToEvent } = this.props; + sendMessage(this.state.composerContent, + { mxClient: this.props.mxClient, roomContext: this.context, permalinkCreator, relation, replyToEvent }); + dis.dispatch({ action: Action.ClearAndFocusSendMessageComposer }); + } }; private onChange = (model: EditorModel) => { @@ -326,6 +336,7 @@ export default class MessageComposer extends React.Component { private onWysiwygChange = (content: string) => { this.setState({ + composerContent: content, isComposerEmpty: content?.length === 0, }); }; @@ -406,16 +417,10 @@ export default class MessageComposer extends React.Component { if (canSendMessages) { if (isWysiwygComposerEnabled) { controls.push( - - { (sendMessage) => { - this.composerSendMessage = sendMessage; - } } - , + />, ); } else { controls.push( @@ -555,3 +560,6 @@ export default class MessageComposer extends React.Component { ); } } + +const MessageComposerWithMatrixClient = withMatrixClientHOC(MessageComposer); +export default MessageComposerWithMatrixClient; diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx new file mode 100644 index 0000000000..17b664410c --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -0,0 +1,105 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { forwardRef, RefObject, useMemo } from 'react'; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { useRoomContext } from '../../../../contexts/RoomContext'; +import { useMatrixClientContext } from '../../../../contexts/MatrixClientContext'; +import EditorStateTransfer from '../../../../utils/EditorStateTransfer'; +import { CommandPartCreator, Part } from '../../../../editor/parts'; +import { IRoomState } from '../../../structures/RoomView'; +import SettingsStore from '../../../../settings/SettingsStore'; +import { parseEvent } from '../../../../editor/deserialize'; +import { WysiwygComposer } from './components/WysiwygComposer'; +import { EditionButtons } from './components/EditionButtons'; +import { useWysiwygEditActionHandler } from './hooks/useWysiwygEditActionHandler'; +import { endEditing } from './utils/editing'; +import { editMessage } from './utils/message'; + +function parseEditorStateTransfer( + editorStateTransfer: EditorStateTransfer, + roomContext: IRoomState, + mxClient: MatrixClient, +) { + if (!roomContext.room) { + return; + } + + const { room } = roomContext; + + 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(); +} + +interface ContentProps { + disabled: boolean; +} + +const Content = forwardRef( + function Content({ disabled }: ContentProps, forwardRef: RefObject) { + useWysiwygEditActionHandler(disabled, forwardRef); + return null; + }, +); + +interface EditWysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + editorStateTransfer?: EditorStateTransfer; +} + +export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiwygComposerProps) { + const roomContext = useRoomContext(); + const mxClient = useMatrixClientContext(); + + const initialContent = useMemo(() => { + if (editorStateTransfer) { + return parseEditorStateTransfer(editorStateTransfer, roomContext, mxClient); + } + }, [editorStateTransfer, roomContext, mxClient]); + const isReady = !editorStateTransfer || Boolean(initialContent); + + return isReady && { (ref, wysiwyg, content) => ( + <> + + endEditing(roomContext)} onSaveClick={() => editMessage(content, { roomContext, mxClient, editorStateTransfer })} /> + ) + } + ; +} diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx new file mode 100644 index 0000000000..577374e116 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -0,0 +1,46 @@ +/* +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 { Wysiwyg } from './types'; + +interface SendWysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; +} + +export function SendWysiwygComposer(props: SendWysiwygComposerProps) { + return ( + { (ref, wysiwyg) => ( + + ) } + ); +} + +interface ContentProps { + disabled: boolean; + wysiwyg: Wysiwyg; +} + +const Content = forwardRef( + function Content({ disabled, wysiwyg }: ContentProps, forwardRef: RefObject) { + useWysiwygSendActionHandler(disabled, forwardRef, wysiwyg); + return null; + }, +); diff --git a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx deleted file mode 100644 index 8701f5be77..0000000000 --- a/src/components/views/rooms/wysiwyg_composer/WysiwygComposer.tsx +++ /dev/null @@ -1,68 +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 } 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'; - -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 { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg(); - - useEffect(() => { - if (!disabled && content !== null) { - onChange(content); - } - }, [onChange, content, disabled]); - - const memoizedSendMessage = useCallback(() => { - sendMessage(content, { mxClient, roomContext, ...props }); - wysiwyg.clear(); - ref.current?.focus(); - }, [content, mxClient, roomContext, wysiwyg, props, ref]); - - useWysiwygActionHandler(disabled, ref); - - return ( -
- - - { children?.(memoizedSendMessage) } -
- ); -} diff --git a/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx new file mode 100644 index 0000000000..20a3df2a7f --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx @@ -0,0 +1,36 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { MouseEventHandler } from 'react'; + +import { _t } from '../../../../../languageHandler'; +import AccessibleButton from '../../../elements/AccessibleButton'; + +interface EditionButtonsProps { + onCancelClick: MouseEventHandler; + onSaveClick: MouseEventHandler; +} + +export function EditionButtons({ onCancelClick, onSaveClick }: EditionButtonsProps) { + return
+ + { _t("Cancel") } + + + { _t("Save") } + +
; +} diff --git a/src/components/views/rooms/wysiwyg_composer/Editor.tsx b/src/components/views/rooms/wysiwyg_composer/components/Editor.tsx similarity index 100% rename from src/components/views/rooms/wysiwyg_composer/Editor.tsx rename to src/components/views/rooms/wysiwyg_composer/components/Editor.tsx diff --git a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx similarity index 87% rename from src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx rename to src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index 19941ad3f9..c806c27861 100644 --- a/src/components/views/rooms/wysiwyg_composer/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -18,11 +18,12 @@ import React, { MouseEventHandler } from "react"; import { useWysiwyg } 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"; +import { Wysiwyg } from "../types"; interface TooltipProps { label: string; @@ -55,7 +56,7 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) } interface FormattingButtonsProps { - composer: ReturnType['wysiwyg']; + composer: Wysiwyg; formattingStates: ReturnType['formattingStates']; } diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx new file mode 100644 index 0000000000..3e63c35fe3 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -0,0 +1,49 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { MutableRefObject, ReactNode, useEffect } from 'react'; +import { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; + +import { FormattingButtons } from './FormattingButtons'; +import { Editor } from './Editor'; +import { Wysiwyg } from '../types'; + +interface WysiwygComposerProps { + disabled?: boolean; + onChange?: (content: string) => void; + initialContent?: string; + children?: (ref: MutableRefObject, wysiwyg: Wysiwyg, content: string) => ReactNode; +} + +export function WysiwygComposer( + { disabled = false, onChange, initialContent, children }: WysiwygComposerProps, +) { + const { ref, isWysiwygReady, content, formattingStates, wysiwyg } = useWysiwyg({ initialContent }); + + useEffect(() => { + if (!disabled && content !== null) { + onChange?.(content); + } + }, [onChange, content, disabled]); + + return ( +
+ + + { children?.(ref, wysiwyg, content) } +
+ ); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts new file mode 100644 index 0000000000..2cbd7cf52c --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygEditActionHandler.ts @@ -0,0 +1,48 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { RefObject, useCallback, useRef } from "react"; + +import defaultDispatcher from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { ActionPayload } from "../../../../../dispatcher/payloads"; +import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { useDispatcher } from "../../../../../hooks/useDispatcher"; +import { focusComposer } from "./utils"; + +export function useWysiwygEditActionHandler( + disabled: boolean, + composerElement: RefObject, +) { + const roomContext = useRoomContext(); + const timeoutId = useRef(); + + const handler = useCallback((payload: ActionPayload) => { + // don't let the user into the composer if it is disabled - all of these branches lead + // to the cursor being in the composer + if (disabled || !composerElement.current) return; + + const context = payload.context ?? TimelineRenderingType.Room; + + switch (payload.action) { + case Action.FocusSendMessageComposer: + focusComposer(composerElement, context, roomContext, timeoutId); + break; + } + }, [disabled, composerElement, timeoutId, roomContext]); + + useDispatcher(defaultDispatcher, handler); +} diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts new file mode 100644 index 0000000000..41169a4e2d --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useWysiwygSendActionHandler.ts @@ -0,0 +1,56 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { RefObject, useCallback, useRef } from "react"; + +import defaultDispatcher from "../../../../../dispatcher/dispatcher"; +import { Action } from "../../../../../dispatcher/actions"; +import { ActionPayload } from "../../../../../dispatcher/payloads"; +import { TimelineRenderingType, useRoomContext } from "../../../../../contexts/RoomContext"; +import { useDispatcher } from "../../../../../hooks/useDispatcher"; +import { Wysiwyg } from "../types"; +import { focusComposer } from "./utils"; + +export function useWysiwygSendActionHandler( + disabled: boolean, + composerElement: RefObject, + wysiwyg: Wysiwyg, +) { + const roomContext = useRoomContext(); + const timeoutId = useRef(); + + const handler = useCallback((payload: ActionPayload) => { + // don't let the user into the composer if it is disabled - all of these branches lead + // to the cursor being in the composer + if (disabled || !composerElement.current) return; + + const context = payload.context ?? TimelineRenderingType.Room; + + switch (payload.action) { + case "reply_to_event": + case Action.FocusSendMessageComposer: + focusComposer(composerElement, context, roomContext, timeoutId); + break; + case Action.ClearAndFocusSendMessageComposer: + wysiwyg.clear(); + focusComposer(composerElement, context, roomContext, timeoutId); + break; + // TODO: case Action.ComposerInsert: - see SendMessageComposer + } + }, [disabled, composerElement, wysiwyg, timeoutId, roomContext]); + + useDispatcher(defaultDispatcher, handler); +} diff --git a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts similarity index 52% rename from src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts rename to src/components/views/rooms/wysiwyg_composer/hooks/utils.ts index 683498d485..eab855e086 100644 --- a/src/components/views/rooms/wysiwyg_composer/useWysiwygActionHandler.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/utils.ts @@ -14,40 +14,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { useRef } from "react"; +import { TimelineRenderingType } from "../../../../../contexts/RoomContext"; +import { IRoomState } from "../../../../structures/RoomView"; -import defaultDispatcher from "../../../../dispatcher/dispatcher"; -import { Action } from "../../../../dispatcher/actions"; -import { ActionPayload } from "../../../../dispatcher/payloads"; -import { IRoomState } from "../../../structures/RoomView"; -import { TimelineRenderingType, useRoomContext } from "../../../../contexts/RoomContext"; -import { useDispatcher } from "../../../../hooks/useDispatcher"; - -export function useWysiwygActionHandler( - disabled: boolean, - composerElement: React.MutableRefObject, -) { - const roomContext = useRoomContext(); - const timeoutId = useRef(); - - useDispatcher(defaultDispatcher, (payload: ActionPayload) => { - // don't let the user into the composer if it is disabled - all of these branches lead - // to the cursor being in the composer - if (disabled) return; - - const context = payload.context ?? TimelineRenderingType.Room; - - switch (payload.action) { - case "reply_to_event": - case Action.FocusSendMessageComposer: - focusComposer(composerElement, context, roomContext, timeoutId); - break; - // TODO: case Action.ComposerInsert: - see SendMessageComposer - } - }); -} - -function focusComposer( +export function focusComposer( composerElement: React.MutableRefObject, renderingType: TimelineRenderingType, roomContext: IRoomState, diff --git a/src/components/views/rooms/wysiwyg_composer/index.ts b/src/components/views/rooms/wysiwyg_composer/index.ts new file mode 100644 index 0000000000..ec8c9cff23 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/index.ts @@ -0,0 +1,19 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export { SendWysiwygComposer } from './SendWysiwygComposer'; +export { EditWysiwygComposer } from './EditWysiwygComposer'; +export { sendMessage } from './utils/message'; diff --git a/src/components/views/rooms/wysiwyg_composer/types.ts b/src/components/views/rooms/wysiwyg_composer/types.ts new file mode 100644 index 0000000000..6c57ce6a86 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/types.ts @@ -0,0 +1,21 @@ +/* +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 { useWysiwyg } from "@matrix-org/matrix-wysiwyg"; + +// TODO +// Change when the matrix-wysiwyg typescript definition will be refined +export type Wysiwyg = ReturnType['wysiwyg']; diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts new file mode 100644 index 0000000000..fe7f7706b4 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -0,0 +1,117 @@ +/* +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 { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; +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, + }; + } +} + +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) || ""; +} + +interface CreateMessageContentParams { + relation?: IEventRelation; + replyToEvent?: MatrixEvent; + permalinkCreator?: RoomPermalinkCreator; + includeReplyLegacyFallback?: boolean; + editedEvent?: MatrixEvent; +} + +export function createMessageContent( + message: string, + { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true, editedEvent }: + CreateMessageContentParams, +): IContent { + // TODO emote ? + + const isReply = Boolean(replyToEvent?.replyEventId); + const isEditing = Boolean(editedEvent); + + /*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: MsgType.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"; + + const htmlPrefix = isReply ? getHtmlReplyFallback(editedEvent) : ''; + content.formatted_body = isEditing ? `${htmlPrefix} * ${formattedBody}` : formattedBody; + + if (isEditing) { + content['m.new_content'] = { + "msgtype": content.msgtype, + "body": body, + "format": "org.matrix.custom.html", + 'formatted_body': formattedBody, + }; + } + } + + const newRelation = isEditing ? + { ...relation, 'rel_type': 'm.replace', 'event_id': editedEvent.getId() } + : relation; + + attachRelation(content, newRelation); + + if (!isEditing && replyToEvent && permalinkCreator) { + addReplyToMessageContent(content, replyToEvent, { + permalinkCreator, + includeLegacyFallback: includeReplyLegacyFallback, + }); + } + + return content; +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/editing.ts b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts new file mode 100644 index 0000000000..a0cb608383 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/editing.ts @@ -0,0 +1,50 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { EventStatus, MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { IRoomState } from "../../../../structures/RoomView"; +import dis from '../../../../../dispatcher/dispatcher'; +import { Action } from "../../../../../dispatcher/actions"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; + +export function endEditing(roomContext: IRoomState) { + // todo local storage + // localStorage.removeItem(this.editorRoomKey); + // localStorage.removeItem(this.editorStateKey); + + // close the event editing and focus composer + dis.dispatch({ + action: Action.EditEvent, + event: null, + timelineRenderingType: roomContext.timelineRenderingType, + }); + dis.dispatch({ + action: Action.FocusSendMessageComposer, + context: roomContext.timelineRenderingType, + }); +} + +export function cancelPreviousPendingEdit(mxClient: MatrixClient, editorStateTransfer: EditorStateTransfer) { + const originalEvent = editorStateTransfer.getEvent(); + const previousEdit = originalEvent.replacingEvent(); + if (previousEdit && ( + previousEdit.status === EventStatus.QUEUED || + previousEdit.status === EventStatus.NOT_SENT + )) { + mxClient.cancelPendingEvent(previousEdit); + } +} diff --git a/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts b/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts new file mode 100644 index 0000000000..88715dda38 --- /dev/null +++ b/src/components/views/rooms/wysiwyg_composer/utils/isContentModified.ts @@ -0,0 +1,30 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { IContent } from "matrix-js-sdk/src/matrix"; + +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; + +export function isContentModified(newContent: IContent, editorStateTransfer: EditorStateTransfer): boolean { + // if nothing has changed then bail + const oldContent = editorStateTransfer.getEvent().getContent(); + if (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] && + oldContent["format"] === newContent["format"] && + oldContent["formatted_body"] === newContent["formatted_body"]) { + return false; + } + return true; +} diff --git a/src/components/views/rooms/wysiwyg_composer/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts similarity index 57% rename from src/components/views/rooms/wysiwyg_composer/message.ts rename to src/components/views/rooms/wysiwyg_composer/utils/message.ts index 5569af02a9..0f4de2d8a3 100644 --- a/src/components/views/rooms/wysiwyg_composer/message.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts @@ -19,90 +19,32 @@ import { IContent, IEventRelation, MatrixEvent } from 'matrix-js-sdk/src/models/ import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; -import { PosthogAnalytics } from "../../../../PosthogAnalytics"; -import SettingsStore from "../../../../settings/SettingsStore"; -import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../sendTimePerformanceMetrics"; -import { RoomPermalinkCreator } from "../../../../utils/permalinks/Permalinks"; -import { doMaybeLocalRoomAction } from "../../../../utils/local-room"; -import { CHAT_EFFECTS } from "../../../../effects"; -import { containsEmoji } from "../../../../effects/utils"; -import { IRoomState } from "../../../structures/RoomView"; -import dis from '../../../../dispatcher/dispatcher'; -import { addReplyToMessageContent } from "../../../../utils/Reply"; - -// Merges favouring the given relation -function attachRelation(content: IContent, relation?: IEventRelation): void { - if (relation) { - content['m.relates_to'] = { - ...(content['m.relates_to'] || {}), - ...relation, - }; - } -} +import { PosthogAnalytics } from "../../../../../PosthogAnalytics"; +import SettingsStore from "../../../../../settings/SettingsStore"; +import { decorateStartSendingTime, sendRoundTripMetric } from "../../../../../sendTimePerformanceMetrics"; +import { RoomPermalinkCreator } from "../../../../../utils/permalinks/Permalinks"; +import { doMaybeLocalRoomAction } from "../../../../../utils/local-room"; +import { CHAT_EFFECTS } from "../../../../../effects"; +import { containsEmoji } from "../../../../../effects/utils"; +import { IRoomState } from "../../../../structures/RoomView"; +import dis from '../../../../../dispatcher/dispatcher'; +import { createRedactEventDialog } from "../../../dialogs/ConfirmRedactDialog"; +import { endEditing, cancelPreviousPendingEdit } from "./editing"; +import EditorStateTransfer from "../../../../../utils/EditorStateTransfer"; +import { createMessageContent } from "./createMessageContent"; +import { isContentModified } from "./isContentModified"; interface SendMessageParams { mxClient: MatrixClient; relation?: IEventRelation; replyToEvent?: MatrixEvent; roomContext: IRoomState; - permalinkCreator: RoomPermalinkCreator; + permalinkCreator?: RoomPermalinkCreator; includeReplyLegacyFallback?: boolean; } -// exported for tests -export function createMessageContent( - message: string, - { relation, replyToEvent, permalinkCreator, includeReplyLegacyFallback = true }: - Omit, -): IContent { - // TODO emote ? - - /*const isEmote = containsEmote(model); - if (isEmote) { - model = stripEmoteCommand(model); - } - if (startsWith(model, "//")) { - model = stripPrefix(model, "/"); - } - model = unescapeMessage(model);*/ - - // const body = textSerialize(model); - const body = message; - - const content: IContent = { - // TODO emote - // msgtype: isEmote ? "m.emote" : "m.text", - msgtype: "m.text", - body: body, - }; - - // TODO markdown support - - /*const formattedBody = htmlSerializeIfNeeded(model, { - forceHTML: !!replyToEvent, - useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), - });*/ - const formattedBody = message; - - if (formattedBody) { - content.format = "org.matrix.custom.html"; - content.formatted_body = formattedBody; - } - - attachRelation(content, relation); - - if (replyToEvent) { - addReplyToMessageContent(content, replyToEvent, { - permalinkCreator, - includeLegacyFallback: includeReplyLegacyFallback, - }); - } - - return content; -} - export function sendMessage( - message: string, + html: string, { roomContext, mxClient, ...params }: SendMessageParams, ) { const { relation, replyToEvent } = params; @@ -113,6 +55,7 @@ export function sendMessage( eventName: "Composer", isEditing: false, isReply: Boolean(replyToEvent), + // TODO thread inThread: relation?.rel_type === THREAD_RELATION_TYPE.name, }; @@ -133,7 +76,7 @@ export function sendMessage( if (!content) { content = createMessageContent( - message, + html, params, ); } @@ -197,3 +140,65 @@ export function sendMessage( return prom; } + +interface EditMessageParams { + mxClient: MatrixClient; + roomContext: IRoomState; + editorStateTransfer: EditorStateTransfer; +} + +export function editMessage( + html: string, + { roomContext, mxClient, editorStateTransfer }: EditMessageParams, +) { + const editedEvent = editorStateTransfer.getEvent(); + + PosthogAnalytics.instance.trackEvent({ + eventName: "Composer", + isEditing: true, + inThread: Boolean(editedEvent?.getThread()), + isReply: Boolean(editedEvent.replyEventId), + }); + + // 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, { editedEvent }); + const newContent = editContent["m.new_content"]; + + const shouldSend = true; + + if (newContent?.body === '') { + cancelPreviousPendingEdit(mxClient, editorStateTransfer); + createRedactEventDialog({ + mxEvent: editedEvent, + onCloseDialog: () => { + endEditing(roomContext); + }, + }); + return; + } + + // 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; + + console.log('editContent', editContent); + mxClient.sendMessage(roomId, threadId, editContent); + dis.dispatch({ action: "message_sent" }); + } + } + + endEditing(roomContext); +} diff --git a/src/dispatcher/actions.ts b/src/dispatcher/actions.ts index 2b2e443e81..7d2d935f70 100644 --- a/src/dispatcher/actions.ts +++ b/src/dispatcher/actions.ts @@ -75,6 +75,11 @@ export enum Action { */ FocusSendMessageComposer = "focus_send_message_composer", + /** + * Clear the to the send message composer. Should be used with a FocusComposerPayload. + */ + ClearAndFocusSendMessageComposer = "clear_focus_send_message_composer", + /** * Focuses the user's cursor to the edit message composer. Should be used with a FocusComposerPayload. */ diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx index bc0b26f745..596cf0bcfe 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -39,7 +39,7 @@ import { SendMessageComposer } from "../../../../src/components/views/rooms/Send import { E2EStatus } from "../../../../src/utils/ShieldUtils"; import { addTextToComposer } from "../../../test-utils/composer"; import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore"; -import { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; +import { WysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement // See https://github.com/matrix-org/matrix-wysiwyg/blob/main/platforms/web/test.setup.ts diff --git a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx b/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx index 6c3e8573ae..a9838ecaca 100644 --- a/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/FormattingButtons-test.tsx @@ -18,7 +18,7 @@ import React from 'react'; import { render, screen } from "@testing-library/react"; import userEvent from '@testing-library/user-event'; -import { FormattingButtons } from "../../../../../src/components/views/rooms/wysiwyg_composer/FormattingButtons"; +import { FormattingButtons } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/FormattingButtons"; describe('FormattingButtons', () => { const wysiwyg = { diff --git a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx index b0aa838879..91020250ae 100644 --- a/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/WysiwygComposer-test.tsx @@ -24,7 +24,7 @@ import defaultDispatcher from "../../../../../src/dispatcher/dispatcher"; import { Action } from "../../../../../src/dispatcher/actions"; import { IRoomState } from "../../../../../src/components/structures/RoomView"; import { Layout } from "../../../../../src/settings/enums/Layout"; -import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/WysiwygComposer"; +import { WysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; // The wysiwyg fetch wasm bytes and a specific workaround is needed to make it works in a node (jest) environnement diff --git a/test/components/views/rooms/wysiwyg_composer/message-test.ts b/test/components/views/rooms/wysiwyg_composer/message-test.ts index 712b671c9f..50f0f77c1a 100644 --- a/test/components/views/rooms/wysiwyg_composer/message-test.ts +++ b/test/components/views/rooms/wysiwyg_composer/message-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { IRoomState } from "../../../../../src/components/structures/RoomView"; -import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/message"; +import { createMessageContent, sendMessage } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/message"; import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { createTestClient, mkEvent, mkStubRoom } from "../../../../test-utils"; From 0a65d919a1a04bb457d59e4b24505a87c65ff001 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 18:17:03 +0200 Subject: [PATCH 06/79] Fix typing --- .../views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx | 3 +-- .../wysiwyg_composer/components/FormattingButtons.tsx | 7 +++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index f36dbd2d9b..24909bf2ee 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -22,10 +22,9 @@ import { WysiwygComposer } from './components/WysiwygComposer'; interface SendWysiwygComposerProps { disabled?: boolean; - onChange?: (content: string) => void; + onChange: (content: string) => void; onSend(): () => void; } - interface ContentProps { disabled: boolean; formattingFunctions: FormattingFunctions; diff --git a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index c806c27861..00127e5e43 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -15,7 +15,7 @@ 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"; @@ -23,7 +23,6 @@ import { Alignment } from "../../../elements/Tooltip"; import { KeyboardShortcut } from "../../../settings/KeyboardShortcut"; import { KeyCombo } from "../../../../../KeyBindingsManager"; import { _td } from "../../../../../languageHandler"; -import { Wysiwyg } from "../types"; interface TooltipProps { label: string; @@ -56,8 +55,8 @@ function Button({ label, keyCombo, onClick, isActive, className }: ButtonProps) } interface FormattingButtonsProps { - composer: Wysiwyg; - formattingStates: ReturnType['formattingStates']; + composer: FormattingFunctions; + formattingStates: FormattingStates; } export function FormattingButtons({ composer, formattingStates }: FormattingButtonsProps) { From 63c3a55758ed45b3ad90e38f5afd0fd6f8914efe Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 18:57:49 +0200 Subject: [PATCH 07/79] Disable save button until change --- .../views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx | 6 +++--- .../rooms/wysiwyg_composer/components/EditionButtons.tsx | 5 +++-- .../views/rooms/wysiwyg_composer/hooks/useEditing.ts | 9 +++++++-- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index cef1003285..341d045ba4 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -44,18 +44,18 @@ export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiw const initialContent = useInitialContent(editorStateTransfer); const isReady = !editorStateTransfer || Boolean(initialContent); - const { editMessage, endEditing, setContent } = useEditing(initialContent, editorStateTransfer); + const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(initialContent, editorStateTransfer); return isReady && { (ref, wysiwyg, content) => ( <> - + ) } ; diff --git a/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx index 20a3df2a7f..9e94c12470 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/EditionButtons.tsx @@ -22,14 +22,15 @@ import AccessibleButton from '../../../elements/AccessibleButton'; interface EditionButtonsProps { onCancelClick: MouseEventHandler; onSaveClick: MouseEventHandler; + isSaveDisabled?: boolean; } -export function EditionButtons({ onCancelClick, onSaveClick }: EditionButtonsProps) { +export function EditionButtons({ onCancelClick, onSaveClick, isSaveDisabled = false }: EditionButtonsProps) { return
{ _t("Cancel") } - + { _t("Save") }
; diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts index 8076c0062a..fcd4471cb1 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useEditing.ts @@ -26,13 +26,18 @@ export function useEditing(initialContent: string, editorStateTransfer: EditorSt 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 { setContent, editMessage: editMessageMemoized, endEditing: endEditingMemoized }; + return { onChange, editMessage: editMessageMemoized, endEditing: endEditingMemoized, isSaveDisabled }; } From c7e83baa360d874fc2c61d411813e8e100eeb070 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 19:29:42 +0200 Subject: [PATCH 08/79] Remove unused parameters --- .../views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx | 3 +-- .../rooms/wysiwyg_composer/components/WysiwygComposer.tsx | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 341d045ba4..d60d98ee9f 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -51,8 +51,7 @@ export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiw onChange={onChange} onSend={editMessage} {...props}> - { (ref, wysiwyg, - content) => ( + { (ref) => ( <> diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 308a3fd202..4a58b3693f 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -29,7 +29,7 @@ interface WysiwygComposerProps { children?: ( ref: MutableRefObject, wysiwyg: FormattingFunctions, - content: string) => ReactNode; + ) => ReactNode; } export const WysiwygComposer = memo(function WysiwygComposer( @@ -50,7 +50,7 @@ export const WysiwygComposer = memo(function WysiwygComposer(
- { children?.(ref, wysiwyg, content) } + { children?.(ref, wysiwyg) }
); }); From e9b285c5e058f4349b3856391089c51e4f0858a1 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 19 Oct 2022 19:44:49 +0200 Subject: [PATCH 09/79] Cleaning files --- src/components/views/rooms/BasicMessageComposer.tsx | 2 -- src/components/views/rooms/EditMessageComposer.tsx | 1 - src/components/views/rooms/MessageComposer.tsx | 3 +-- 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 962059091c..d74c7b5148 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -833,8 +833,6 @@ export default class BasicMessageEditor extends React.Component } public insertPlaintext(text: string): void { - console.log('insertPlaintext', text); - debugger; this.modifiedFlag = true; const { model } = this.props; const { partCreator } = model; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index bb01454127..52312e1a99 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -350,7 +350,6 @@ class EditMessageComposer extends React.Component { } this.messageComposerInput.current?.sendMessage(); - // this.composerSendMessage?.(); - const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); + const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); if (isWysiwygComposerEnabled) { const { permalinkCreator, relation, replyToEvent } = this.props; sendMessage(this.state.composerContent, From 5e6d0f640447cd17998f46512c1d7e67170bbb5c Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 20 Oct 2022 10:53:57 +0200 Subject: [PATCH 10/79] Copy css for edition --- res/css/_components.pcss | 5 +- .../_EditWysiwygComposer.pcss | 53 +++++++++++++++++++ ...omposer.pcss => _SendWysiwygComposer.pcss} | 2 +- .../{ => components}/_FormattingButtons.pcss | 0 .../wysiwyg_composer/EditWysiwygComposer.tsx | 1 + .../wysiwyg_composer/SendWysiwygComposer.tsx | 2 +- .../components/WysiwygComposer.tsx | 5 +- 7 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss rename res/css/views/rooms/wysiwyg_composer/{_WysiwygComposer.pcss => _SendWysiwygComposer.pcss} (98%) rename res/css/views/rooms/wysiwyg_composer/{ => components}/_FormattingButtons.pcss (100%) diff --git a/res/css/_components.pcss b/res/css/_components.pcss index b2fcb0dd4f..a0300e8432 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -299,8 +299,9 @@ @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/components/_FormattingButtons.pcss"; +@import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss"; +@import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss"; @import "./views/settings/_AvatarSetting.pcss"; @import "./views/settings/_CrossSigningPanel.pcss"; @import "./views/settings/_CryptographyPanel.pcss"; diff --git a/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss new file mode 100644 index 0000000000..15bc00f5f2 --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss @@ -0,0 +1,53 @@ +/* +Copyright 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_EditWysiwygComposer { + --EditWysiwygComposer-padding-inline: 3px; + + display: flex; + flex-direction: column; + max-width: 100%; /* disable overflow */ + width: auto; + gap: 5px; + padding: 3px var(--EditWysiwygComposer-padding-inline); + + .mx_WysiwygComposer_container { + 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 */ + } + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss similarity index 98% rename from res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss rename to res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss index 133b66388e..a00f8c7e11 100644 --- a/res/css/views/rooms/wysiwyg_composer/_WysiwygComposer.pcss +++ b/res/css/views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -.mx_WysiwygComposer { +.mx_SendWysiwygComposer { flex: 1; display: flex; flex-direction: column; diff --git a/res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss similarity index 100% rename from res/css/views/rooms/wysiwyg_composer/_FormattingButtons.pcss rename to res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index d60d98ee9f..53031fc6e1 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -47,6 +47,7 @@ export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiw const { editMessage, endEditing, onChange, isSaveDisabled } = useEditing(initialContent, editorStateTransfer); return isReady && ( export function SendWysiwygComposer(props: SendWysiwygComposerProps) { return ( - { (ref, wysiwyg) => ( + { (ref, wysiwyg) => ( ) } ); diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx index 4a58b3693f..7dc059ffb2 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer.tsx @@ -26,6 +26,7 @@ interface WysiwygComposerProps { onChange?: (content: string) => void; onSend: () => void; initialContent?: string; + className?: string; children?: ( ref: MutableRefObject, wysiwyg: FormattingFunctions, @@ -33,7 +34,7 @@ interface WysiwygComposerProps { } export const WysiwygComposer = memo(function WysiwygComposer( - { disabled = false, onChange, onSend, initialContent, children }: WysiwygComposerProps, + { disabled = false, onChange, onSend, initialContent, className, children }: WysiwygComposerProps, ) { const inputEventProcessor = useInputEventProcessor(onSend); @@ -47,7 +48,7 @@ export const WysiwygComposer = memo(function WysiwygComposer( }, [onChange, content, disabled]); return ( -
+
{ children?.(ref, wysiwyg) } From 072c767b68491a42d4dcce61370b367ce1590d46 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 20 Oct 2022 11:52:50 +0200 Subject: [PATCH 11/79] Fix suppression when message is empty --- .../utils/createMessageContent.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index fe7f7706b4..f0488e4599 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -89,15 +89,15 @@ export function createMessageContent( const htmlPrefix = isReply ? getHtmlReplyFallback(editedEvent) : ''; content.formatted_body = isEditing ? `${htmlPrefix} * ${formattedBody}` : formattedBody; + } - if (isEditing) { - content['m.new_content'] = { - "msgtype": content.msgtype, - "body": body, - "format": "org.matrix.custom.html", - 'formatted_body': formattedBody, - }; - } + if (isEditing) { + content['m.new_content'] = { + "msgtype": content.msgtype, + "body": body, + "format": "org.matrix.custom.html", + 'formatted_body': formattedBody, + }; } const newRelation = isEditing ? From 5987a6889b619852f8544c90568b57bff988a487 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 20 Oct 2022 17:31:17 +0200 Subject: [PATCH 12/79] Add styling to editing --- res/css/_components.pcss | 1 + .../_EditWysiwygComposer.pcss | 12 ++++-- .../wysiwyg_composer/components/_Editor.pcss | 38 +++++++++++++++++++ .../components/_FormattingButtons.pcss | 2 +- src/components/views/messages/TextualBody.tsx | 2 +- .../wysiwyg_composer/EditWysiwygComposer.tsx | 6 ++- .../components/EditionButtons.tsx | 2 +- 7 files changed, 55 insertions(+), 8 deletions(-) create mode 100644 res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss diff --git a/res/css/_components.pcss b/res/css/_components.pcss index a0300e8432..8ee602deee 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -299,6 +299,7 @@ @import "./views/rooms/_TopUnreadMessagesBar.pcss"; @import "./views/rooms/_VoiceRecordComposerTile.pcss"; @import "./views/rooms/_WhoIsTypingTile.pcss"; +@import "./views/rooms/wysiwyg_composer/components/_Editor.pcss"; @import "./views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss"; @import "./views/rooms/wysiwyg_composer/_SendWysiwygComposer.pcss"; @import "./views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss"; diff --git a/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss index 15bc00f5f2..8c245bc90f 100644 --- a/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss +++ b/res/css/views/rooms/wysiwyg_composer/_EditWysiwygComposer.pcss @@ -22,10 +22,10 @@ limitations under the License. flex-direction: column; max-width: 100%; /* disable overflow */ width: auto; - gap: 5px; - padding: 3px var(--EditWysiwygComposer-padding-inline); + gap: 8px; + padding: 8px var(--EditWysiwygComposer-padding-inline); - .mx_WysiwygComposer_container { + .mx_WysiwygComposer_content { border-radius: 4px; border: solid 1px $primary-hairline-color; background-color: $background; @@ -50,4 +50,10 @@ limitations under the License. min-width: 100px; /* magic number to align the edge of the button with the input area */ } } + + .mx_FormattingButtons_Button { + &:first-child { + margin-left: 0px; + } + } } diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss new file mode 100644 index 0000000000..88fa080e23 --- /dev/null +++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss @@ -0,0 +1,38 @@ +/* +Copyright 2019 New Vector Ltd +Copyright 2019 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +.mx_WysiwygComposer_container { + position: relative; + + @keyframes visualbell { + from { background-color: $visual-bell-bg-color; } + to { background-color: $background; } + } + + .mx_WysiwygComposer_content { + white-space: pre-wrap; + word-wrap: break-word; + outline: none; + overflow-x: hidden; + + /* Force caret nodes to be selected in full so that they can be */ + /* navigated through in a single keypress */ + .caretNode { + user-select: all; + } + } +} diff --git a/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss index 36f84ae5f1..499b2b457b 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_FormattingButtons.pcss @@ -45,7 +45,7 @@ limitations under the License. left: 6px; height: 16px; width: 16px; - background-color: $icon-button-color; + background-color: $tertiary-content; mask-repeat: no-repeat; mask-size: contain; mask-position: center; diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 983cbe51e3..ab9c27f7fb 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -565,7 +565,7 @@ export default class TextualBody extends React.Component { if (this.props.editState) { const isWysiwygComposerEnabled = SettingsStore.getValue("feature_wysiwyg_composer"); return isWysiwygComposerEnabled ? - : + : ; } const mxEvent = this.props.mxEvent; diff --git a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx index 53031fc6e1..c03e87c526 100644 --- a/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/EditWysiwygComposer.tsx @@ -15,6 +15,7 @@ limitations under the License. */ import React, { forwardRef, RefObject } from 'react'; +import classNames from 'classnames'; import EditorStateTransfer from '../../../../utils/EditorStateTransfer'; import { WysiwygComposer } from './components/WysiwygComposer'; @@ -38,16 +39,17 @@ interface EditWysiwygComposerProps { disabled?: boolean; onChange?: (content: string) => void; editorStateTransfer: EditorStateTransfer; + className?: string; } -export function EditWysiwygComposer({ editorStateTransfer, ...props }: EditWysiwygComposerProps) { +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 && + return
{ _t("Cancel") } From c9bf7da62965bd03873963bef8a5c49cd22ee7ac Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 21 Oct 2022 10:35:54 +0200 Subject: [PATCH 13/79] Fix send message on enter --- src/components/views/rooms/MessageComposer.tsx | 2 +- .../views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index b94fcdddb1..60bc5ee641 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -415,7 +415,7 @@ class MessageComposer extends React.Component { this.sendMessage} + onSend={this.sendMessage} />, ); } else { diff --git a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx index 4a7cfb65a0..2a485d9975 100644 --- a/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx +++ b/src/components/views/rooms/wysiwyg_composer/SendWysiwygComposer.tsx @@ -23,7 +23,7 @@ import { WysiwygComposer } from './components/WysiwygComposer'; interface SendWysiwygComposerProps { disabled?: boolean; onChange: (content: string) => void; - onSend(): () => void; + onSend: () => void; } interface ContentProps { disabled: boolean; From 8ae67aa4dd73d88f4a6ad9bc10880b50143aee3a Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 21 Oct 2022 14:45:38 +0100 Subject: [PATCH 14/79] Only show mini avatar uploader in room intro when no avatar yet exists (#9479) --- src/components/views/rooms/NewRoomIntro.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/components/views/rooms/NewRoomIntro.tsx b/src/components/views/rooms/NewRoomIntro.tsx index 56c7d7224c..371494c79e 100644 --- a/src/components/views/rooms/NewRoomIntro.tsx +++ b/src/components/views/rooms/NewRoomIntro.tsx @@ -175,14 +175,22 @@ const NewRoomIntro = () => { } const avatarUrl = room.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url; - body = - + ); + + if (!avatarUrl) { + avatar = cli.sendStateEvent(roomId, EventType.RoomAvatar, { url }, '')} > - - + { avatar } + ; + } + + body = + { avatar }

{ room.name }

From d4f1c573adf109460fcbd86c5043d570bb690714 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Sat, 22 Oct 2022 13:07:39 +0200 Subject: [PATCH 15/79] Fix voice broadcast recording limit (#9478) --- src/audio/VoiceRecording.ts | 11 ++ .../audio/VoiceBroadcastRecorder.ts | 4 +- test/audio/VoiceRecording-test.ts | 105 ++++++++++++++++++ .../audio/VoiceBroadcastRecorder-test.ts | 19 ++-- 4 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 test/audio/VoiceRecording-test.ts diff --git a/src/audio/VoiceRecording.ts b/src/audio/VoiceRecording.ts index 0e18756fe5..99f878868d 100644 --- a/src/audio/VoiceRecording.ts +++ b/src/audio/VoiceRecording.ts @@ -60,6 +60,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private recorderProcessor: ScriptProcessorNode; private recording = false; private observable: SimpleObservable; + private targetMaxLength: number | null = TARGET_MAX_LENGTH; public amplitudes: number[] = []; // at each second mark, generated private liveWaveform = new FixedRollingArray(RECORDING_PLAYBACK_SAMPLES, 0); public onDataAvailable: (data: ArrayBuffer) => void; @@ -83,6 +84,10 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { return true; // we don't ever care if the event had listeners, so just return "yes" } + public disableMaxLength(): void { + this.targetMaxLength = null; + } + private async makeRecorder() { try { this.recorderStream = await navigator.mediaDevices.getUserMedia({ @@ -203,6 +208,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { // In testing, recorder time and worker time lag by about 400ms, which is roughly the // time needed to encode a sample/frame. // + + if (!this.targetMaxLength) { + // skip time checks if max length has been disabled + return; + } + const secondsLeft = TARGET_MAX_LENGTH - this.recorderSeconds; if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping diff --git a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts index ff1d22a41c..df7ae362d9 100644 --- a/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts +++ b/src/voice-broadcast/audio/VoiceBroadcastRecorder.ts @@ -139,5 +139,7 @@ export class VoiceBroadcastRecorder } export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => { - return new VoiceBroadcastRecorder(new VoiceRecording(), getChunkLength()); + const voiceRecording = new VoiceRecording(); + voiceRecording.disableMaxLength(); + return new VoiceBroadcastRecorder(voiceRecording, getChunkLength()); }; diff --git a/test/audio/VoiceRecording-test.ts b/test/audio/VoiceRecording-test.ts new file mode 100644 index 0000000000..ac4f52eabe --- /dev/null +++ b/test/audio/VoiceRecording-test.ts @@ -0,0 +1,105 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { VoiceRecording } from "../../src/audio/VoiceRecording"; + +/** + * The tests here are heavily using access to private props. + * While this is not so great, we can at lest test some behaviour easily this way. + */ +describe("VoiceRecording", () => { + let recording: VoiceRecording; + let recorderSecondsSpy: jest.SpyInstance; + + const itShouldNotCallStop = () => { + it("should not call stop", () => { + expect(recording.stop).not.toHaveBeenCalled(); + }); + }; + + const simulateUpdate = (recorderSeconds: number) => { + beforeEach(() => { + recorderSecondsSpy.mockReturnValue(recorderSeconds); + // @ts-ignore + recording.processAudioUpdate(recorderSeconds); + }); + }; + + beforeEach(() => { + recording = new VoiceRecording(); + // @ts-ignore + recording.observable = { + update: jest.fn(), + }; + jest.spyOn(recording, "stop").mockImplementation(); + recorderSecondsSpy = jest.spyOn(recording, "recorderSeconds", "get"); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("when recording", () => { + beforeEach(() => { + // @ts-ignore + recording.recording = true; + }); + + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + + it("should call stop", () => { + expect(recording.stop).toHaveBeenCalled(); + }); + }); + + describe("and the max length limit has been disabled", () => { + beforeEach(() => { + recording.disableMaxLength(); + }); + + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + itShouldNotCallStop(); + }); + }); + }); + + describe("when not recording", () => { + describe("and there is an audio update and time left", () => { + simulateUpdate(42); + itShouldNotCallStop(); + }); + + describe("and there is an audio update and time is up", () => { + // one second above the limit + simulateUpdate(901); + itShouldNotCallStop(); + }); + }); +}); diff --git a/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts b/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts index e60d7e2d96..df7da24ce5 100644 --- a/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts +++ b/test/voice-broadcast/audio/VoiceBroadcastRecorder-test.ts @@ -26,6 +26,8 @@ import { VoiceBroadcastRecorderEvent, } from "../../../src/voice-broadcast"; +jest.mock("../../../src/audio/VoiceRecording"); + describe("VoiceBroadcastRecorder", () => { describe("createVoiceBroadcastRecorder", () => { beforeEach(() => { @@ -44,6 +46,7 @@ describe("VoiceBroadcastRecorder", () => { it("should return a VoiceBroadcastRecorder instance with targetChunkLength from config", () => { const voiceBroadcastRecorder = createVoiceBroadcastRecorder(); + expect(mocked(VoiceRecording).mock.instances[0].disableMaxLength).toHaveBeenCalled(); expect(voiceBroadcastRecorder).toBeInstanceOf(VoiceBroadcastRecorder); expect(voiceBroadcastRecorder.targetChunkLength).toBe(1337); }); @@ -72,16 +75,12 @@ describe("VoiceBroadcastRecorder", () => { }; beforeEach(() => { - voiceRecording = { - contentType, - start: jest.fn().mockResolvedValue(undefined), - stop: jest.fn().mockResolvedValue(undefined), - on: jest.fn(), - off: jest.fn(), - emit: jest.fn(), - destroy: jest.fn(), - recorderSeconds: 23, - } as unknown as VoiceRecording; + voiceRecording = new VoiceRecording(); + // @ts-ignore + voiceRecording.recorderSeconds = 23; + // @ts-ignore + voiceRecording.contentType = contentType; + voiceBroadcastRecorder = new VoiceBroadcastRecorder(voiceRecording, chunkLength); jest.spyOn(voiceBroadcastRecorder, "removeAllListeners"); onChunkRecorded = jest.fn(); From 9eb4f8d723863bc17f8d226621e7445ee67d25ec Mon Sep 17 00:00:00 2001 From: Germain Date: Mon, 24 Oct 2022 07:50:21 +0100 Subject: [PATCH 16/79] Add thread notification with server assistance (MSC3773) (#9400) Co-authored-by: Janne Mareike Koschinski --- cypress/e2e/sliding-sync/sliding-sync.ts | 2 +- res/css/views/rooms/_EventTile.pcss | 18 ++- src/RoomNotifs.ts | 16 ++- src/Unread.ts | 6 - src/components/structures/RoomStatusBar.tsx | 6 +- .../views/right_panel/RoomHeaderButtons.tsx | 54 +++++-- src/components/views/rooms/EventTile.tsx | 73 ++++++---- .../views/rooms/NotificationBadge.tsx | 74 +++------- .../StatelessNotificationBadge.tsx | 81 +++++++++++ .../UnreadNotificationBadge.tsx | 36 +++++ src/hooks/useUnreadNotifications.ts | 93 ++++++++++++ .../notifications/RoomNotificationState.ts | 28 ++-- .../RoomNotificationStateStore.ts | 26 ++-- test/RoomNotifs-test.ts | 79 ++++++++++- .../structures/RoomStatusBar-test.tsx | 91 ++++++++++++ .../right_panel/RoomHeaderButtons-test.tsx | 97 +++++++++++++ .../components/views/rooms/EventTile-test.tsx | 112 +++++++++++++++ .../NotificationBadge-test.tsx | 49 +++++++ .../UnreadNotificationBadge-test.tsx | 132 ++++++++++++++++++ .../RoomNotificationState-test.ts | 19 ++- .../RoomNotificationStateStore-test.ts | 60 ++++++++ test/test-utils/threads.ts | 4 +- 22 files changed, 1014 insertions(+), 142 deletions(-) create mode 100644 src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx create mode 100644 src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx create mode 100644 src/hooks/useUnreadNotifications.ts create mode 100644 test/components/structures/RoomStatusBar-test.tsx create mode 100644 test/components/views/right_panel/RoomHeaderButtons-test.tsx create mode 100644 test/components/views/rooms/EventTile-test.tsx create mode 100644 test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx create mode 100644 test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx create mode 100644 test/stores/notifications/RoomNotificationStateStore-test.ts diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.ts index e0e7c974a7..ebc90443f3 100644 --- a/cypress/e2e/sliding-sync/sliding-sync.ts +++ b/cypress/e2e/sliding-sync/sliding-sync.ts @@ -235,7 +235,7 @@ describe("Sliding Sync", () => { "Test Room", "Dummy", ]); - cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.exist"); + cy.contains(".mx_RoomTile", "Test Room").get(".mx_NotificationBadge").should("not.be.visible"); }); it("should update user settings promptly", () => { diff --git a/res/css/views/rooms/_EventTile.pcss b/res/css/views/rooms/_EventTile.pcss index 35cd87b136..55702c787b 100644 --- a/res/css/views/rooms/_EventTile.pcss +++ b/res/css/views/rooms/_EventTile.pcss @@ -426,7 +426,7 @@ $left-gutter: 64px; } &.mx_EventTile_selected .mx_EventTile_line { - // TODO: check if this would be necessary + /* TODO: check if this would be necessary; */ padding-inline-start: calc(var(--EventTile_group_line-spacing-inline-start) + 20px); } } @@ -894,15 +894,22 @@ $left-gutter: 64px; } /* Display notification dot */ - &[data-notification]::before { + &[data-notification]::before, + .mx_NotificationBadge { + position: absolute; $notification-inset-block-start: 14px; /* 14px: align the dot with the timestamp row */ - width: $notification-dot-size; - height: $notification-dot-size; + /* !important to fix overly specific CSS selector applied on mx_NotificationBadge */ + width: $notification-dot-size !important; + height: $notification-dot-size !important; border-radius: 50%; inset: $notification-inset-block-start $spacing-8 auto auto; } + .mx_NotificationBadge_count { + display: none; + } + &[data-notification="total"]::before { background-color: $room-icon-unread-color; } @@ -1301,7 +1308,8 @@ $left-gutter: 64px; } } - &[data-shape="ThreadsList"][data-notification]::before { + &[data-shape="ThreadsList"][data-notification]::before, + .mx_NotificationBadge { /* stylelint-disable-next-line declaration-colon-space-after */ inset-block-start: calc($notification-inset-block-start - var(--MatrixChat_useCompactLayout_group-padding-top)); diff --git a/src/RoomNotifs.ts b/src/RoomNotifs.ts index 08c15970c5..6c1e07e66b 100644 --- a/src/RoomNotifs.ts +++ b/src/RoomNotifs.ts @@ -78,15 +78,23 @@ export function setRoomNotifsState(roomId: string, newState: RoomNotifState): Pr } } -export function getUnreadNotificationCount(room: Room, type: NotificationCountType = null): number { - let notificationCount = room.getUnreadNotificationCount(type); +export function getUnreadNotificationCount( + room: Room, + type: NotificationCountType, + threadId?: string, +): number { + let notificationCount = (!!threadId + ? room.getThreadUnreadNotificationCount(threadId, type) + : room.getUnreadNotificationCount(type)); // Check notification counts in the old room just in case there's some lost // there. We only go one level down to avoid performance issues, and theory // is that 1st generation rooms will have already been read by the 3rd generation. const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); - if (createEvent && createEvent.getContent()['predecessor']) { - const oldRoomId = createEvent.getContent()['predecessor']['room_id']; + const predecessor = createEvent?.getContent().predecessor; + // Exclude threadId, as the same thread can't continue over a room upgrade + if (!threadId && predecessor) { + const oldRoomId = predecessor.room_id; const oldRoom = MatrixClientPeg.get().getRoom(oldRoomId); if (oldRoom) { // We only ever care if there's highlights in the old room. No point in diff --git a/src/Unread.ts b/src/Unread.ts index 1804ddefb7..60ef9ca19e 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -23,7 +23,6 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import shouldHideEvent from './shouldHideEvent'; import { haveRendererForEvent } from "./events/EventTileFactory"; import SettingsStore from "./settings/SettingsStore"; -import { RoomNotificationStateStore } from "./stores/notifications/RoomNotificationStateStore"; /** * Returns true if this event arriving in a room should affect the room's @@ -77,11 +76,6 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { return false; } - } else { - const threadState = RoomNotificationStateStore.instance.getThreadsRoomState(room); - if (threadState.color > 0) { - return true; - } } // if the read receipt relates to an event is that part of a thread diff --git a/src/components/structures/RoomStatusBar.tsx b/src/components/structures/RoomStatusBar.tsx index d46ad12b50..e703252546 100644 --- a/src/components/structures/RoomStatusBar.tsx +++ b/src/components/structures/RoomStatusBar.tsx @@ -34,10 +34,12 @@ const STATUS_BAR_HIDDEN = 0; const STATUS_BAR_EXPANDED = 1; const STATUS_BAR_EXPANDED_LARGE = 2; -export function getUnsentMessages(room: Room): MatrixEvent[] { +export function getUnsentMessages(room: Room, threadId?: string): MatrixEvent[] { if (!room) { return []; } return room.getPendingEvents().filter(function(ev) { - return ev.status === EventStatus.NOT_SENT; + const isNotSent = ev.status === EventStatus.NOT_SENT; + const belongsToTheThread = threadId === ev.threadRootId; + return isNotSent && (!threadId || belongsToTheThread); }); } diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index 262b8fc38d..c6e012fff4 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -20,7 +20,8 @@ limitations under the License. import React from "react"; import classNames from "classnames"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { _t } from '../../../languageHandler'; import HeaderButton from './HeaderButton'; @@ -43,6 +44,7 @@ import { SummarizedNotificationState } from "../../../stores/notifications/Summa import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import PosthogTrackers from "../../../PosthogTrackers"; import { ButtonEvent } from "../elements/AccessibleButton"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; const ROOM_INFO_PHASES = [ RightPanelPhases.RoomSummary, @@ -136,32 +138,67 @@ export default class RoomHeaderButtons extends HeaderButtons { private threadNotificationState: ThreadsRoomNotificationState; private globalNotificationState: SummarizedNotificationState; + private get supportsThreadNotifications(): boolean { + const client = MatrixClientPeg.get(); + return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; + } + constructor(props: IProps) { super(props, HeaderKind.Room); - this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room); + if (!this.supportsThreadNotifications) { + this.threadNotificationState = RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room); + } this.globalNotificationState = RoomNotificationStateStore.instance.globalState; } public componentDidMount(): void { super.componentDidMount(); - this.threadNotificationState.on(NotificationStateEvents.Update, this.onThreadNotification); + if (!this.supportsThreadNotifications) { + this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate); + } else { + this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + } + this.onNotificationUpdate(); RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } public componentWillUnmount(): void { super.componentWillUnmount(); - this.threadNotificationState.off(NotificationStateEvents.Update, this.onThreadNotification); + if (!this.supportsThreadNotifications) { + this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate); + } else { + this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + } RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } - private onThreadNotification = (): void => { + private onNotificationUpdate = (): void => { + let threadNotificationColor: NotificationColor; + if (!this.supportsThreadNotifications) { + threadNotificationColor = this.threadNotificationState.color; + } else { + threadNotificationColor = this.notificationColor; + } + + // console.log // XXX: why don't we read from this.state.threadNotificationColor in the render methods? this.setState({ - threadNotificationColor: this.threadNotificationState.color, + threadNotificationColor, }); }; + private get notificationColor(): NotificationColor { + switch (this.props.room.threadsAggregateNotificationType) { + case NotificationCountType.Highlight: + return NotificationColor.Red; + case NotificationCountType.Total: + return NotificationColor.Grey; + default: + return NotificationColor.None; + } + } + private onUpdateStatus = (notificationState: SummarizedNotificationState): void => { // XXX: why don't we read from this.state.globalNotificationCount in the render methods? this.globalNotificationState = notificationState; @@ -255,12 +292,13 @@ export default class RoomHeaderButtons extends HeaderButtons { ? 0} + isUnread={this.state.threadNotificationColor > 0} > - + : null, ); diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index b13eba33e4..670a291a42 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -27,6 +27,7 @@ import { NotificationCountType, Room, RoomEvent } from 'matrix-js-sdk/src/models import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { UserTrustLevel } from 'matrix-js-sdk/src/crypto/CrossSigning'; +import { Feature, ServerSupport } from 'matrix-js-sdk/src/feature'; import { Icon as LinkIcon } from '../../../../res/img/element-icons/link.svg'; import { Icon as ViewInRoomIcon } from '../../../../res/img/element-icons/view-in-room.svg'; @@ -84,6 +85,7 @@ import { useTooltip } from "../../../utils/useTooltip"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { isLocalRoom } from '../../../utils/localRoom/isLocalRoom'; import { ElementCall } from "../../../models/Call"; +import { UnreadNotificationBadge } from './NotificationBadge/UnreadNotificationBadge'; export type GetRelationsForEvent = (eventId: string, relationType: string, eventType: string) => Relations; @@ -113,7 +115,7 @@ export interface IEventTileType extends React.Component { getEventTileOps?(): IEventTileOps; } -interface IProps { +export interface EventTileProps { // the MatrixEvent to show mxEvent: MatrixEvent; @@ -248,7 +250,7 @@ interface IState { } // MUST be rendered within a RoomContext with a set timelineRenderingType -export class UnwrappedEventTile extends React.Component { +export class UnwrappedEventTile extends React.Component { private suppressReadReceiptAnimation: boolean; private isListeningForReceipts: boolean; private tile = React.createRef(); @@ -267,7 +269,7 @@ export class UnwrappedEventTile extends React.Component { static contextType = RoomContext; public context!: React.ContextType; - constructor(props: IProps, context: React.ContextType) { + constructor(props: EventTileProps, context: React.ContextType) { super(props, context); const thread = this.thread; @@ -394,7 +396,7 @@ export class UnwrappedEventTile extends React.Component { if (SettingsStore.getValue("feature_thread")) { this.props.mxEvent.on(ThreadEvent.Update, this.updateThread); - if (this.thread) { + if (this.thread && !this.supportsThreadNotifications) { this.setupNotificationListener(this.thread); } } @@ -405,33 +407,40 @@ export class UnwrappedEventTile extends React.Component { room?.on(ThreadEvent.New, this.onNewThread); } + private get supportsThreadNotifications(): boolean { + const client = MatrixClientPeg.get(); + return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported; + } + private setupNotificationListener(thread: Thread): void { - const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); - - this.threadState = notifications.getThreadRoomState(thread); - - this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); - this.onThreadStateUpdate(); + if (!this.supportsThreadNotifications) { + const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room); + this.threadState = notifications.getThreadRoomState(thread); + this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate); + this.onThreadStateUpdate(); + } } private onThreadStateUpdate = (): void => { - let threadNotification = null; - switch (this.threadState?.color) { - case NotificationColor.Grey: - threadNotification = NotificationCountType.Total; - break; - case NotificationColor.Red: - threadNotification = NotificationCountType.Highlight; - break; - } + if (!this.supportsThreadNotifications) { + let threadNotification = null; + switch (this.threadState?.color) { + case NotificationColor.Grey: + threadNotification = NotificationCountType.Total; + break; + case NotificationColor.Red: + threadNotification = NotificationCountType.Highlight; + break; + } - this.setState({ - threadNotification, - }); + this.setState({ + threadNotification, + }); + } }; private updateThread = (thread: Thread) => { - if (thread !== this.state.thread) { + if (thread !== this.state.thread && !this.supportsThreadNotifications) { if (this.threadState) { this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate); } @@ -444,7 +453,7 @@ export class UnwrappedEventTile extends React.Component { // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line - UNSAFE_componentWillReceiveProps(nextProps: IProps) { + UNSAFE_componentWillReceiveProps(nextProps: EventTileProps) { // re-check the sender verification as outgoing events progress through // the send process. if (nextProps.eventSendStatus !== this.props.eventSendStatus) { @@ -452,7 +461,7 @@ export class UnwrappedEventTile extends React.Component { } } - shouldComponentUpdate(nextProps: IProps, nextState: IState): boolean { + shouldComponentUpdate(nextProps: EventTileProps, nextState: IState): boolean { if (objectHasDiff(this.state, nextState)) { return true; } @@ -481,7 +490,7 @@ export class UnwrappedEventTile extends React.Component { } } - componentDidUpdate(prevProps: IProps, prevState: IState, snapshot) { + componentDidUpdate() { // If we're not listening for receipts and expect to be, register a listener. if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) { MatrixClientPeg.get().on(RoomEvent.Receipt, this.onRoomReceipt); @@ -667,7 +676,7 @@ export class UnwrappedEventTile extends React.Component { }, this.props.onHeightChanged); // Decryption may have caused a change in size } - private propsEqual(objA: IProps, objB: IProps): boolean { + private propsEqual(objA: EventTileProps, objB: EventTileProps): boolean { const keysA = Object.keys(objA); const keysB = Object.keys(objB); @@ -1348,6 +1357,7 @@ export class UnwrappedEventTile extends React.Component { ]); } case TimelineRenderingType.ThreadsList: { + const room = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return ( React.createElement(this.props.as || "li", { @@ -1361,7 +1371,9 @@ export class UnwrappedEventTile extends React.Component { "data-shape": this.context.timelineRenderingType, "data-self": isOwnEvent, "data-has-reply": !!replyChain, - "data-notification": this.state.threadNotification, + "data-notification": !this.supportsThreadNotifications + ? this.state.threadNotification + : undefined, "onMouseEnter": () => this.setState({ hover: true }), "onMouseLeave": () => this.setState({ hover: false }), "onClick": (ev: MouseEvent) => { @@ -1409,6 +1421,9 @@ export class UnwrappedEventTile extends React.Component { { msgOption } + ) ); } @@ -1512,7 +1527,7 @@ export class UnwrappedEventTile extends React.Component { } // Wrap all event tiles with the tile error boundary so that any throws even during construction are captured -const SafeEventTile = forwardRef((props: IProps, ref: RefObject) => { +const SafeEventTile = forwardRef((props: EventTileProps, ref: RefObject) => { return ; diff --git a/src/components/views/rooms/NotificationBadge.tsx b/src/components/views/rooms/NotificationBadge.tsx index 51745209aa..3555582298 100644 --- a/src/components/views/rooms/NotificationBadge.tsx +++ b/src/components/views/rooms/NotificationBadge.tsx @@ -15,16 +15,14 @@ limitations under the License. */ import React, { MouseEvent } from "react"; -import classNames from "classnames"; -import { formatCount } from "../../../utils/FormattingUtils"; import SettingsStore from "../../../settings/SettingsStore"; -import AccessibleButton from "../elements/AccessibleButton"; import { XOR } from "../../../@types/common"; import { NotificationState, NotificationStateEvents } from "../../../stores/notifications/NotificationState"; import Tooltip from "../elements/Tooltip"; import { _t } from "../../../languageHandler"; import { NotificationColor } from "../../../stores/notifications/NotificationColor"; +import { StatelessNotificationBadge } from "./NotificationBadge/StatelessNotificationBadge"; interface IProps { notification: NotificationState; @@ -113,61 +111,25 @@ export default class NotificationBadge extends React.PureComponent 0; - let isEmptyBadge = !hasAnySymbol || !notification.hasUnreadCount; - if (forceCount) { - isEmptyBadge = false; - if (!notification.hasUnreadCount) return null; // Can't render a badge + let 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 = ; } - let symbol = notification.symbol || formatCount(notification.count); - if (isEmptyBadge) symbol = ""; - - const classes = classNames({ - 'mx_NotificationBadge': true, - 'mx_NotificationBadge_visible': isEmptyBadge ? true : notification.hasUnreadCount, - 'mx_NotificationBadge_highlighted': notification.hasMentions, - 'mx_NotificationBadge_dot': isEmptyBadge, - 'mx_NotificationBadge_2char': symbol.length > 0 && symbol.length < 3, - 'mx_NotificationBadge_3char': symbol.length > 2, - }); - - if (onClick) { - let label: string; - let tooltip: JSX.Element; - if (showUnsentTooltip && this.state.showTooltip && notification.color === NotificationColor.Unsent) { - label = _t("Message didn't send. Click for info."); - tooltip = ; - } - - return ( - - { symbol } - { tooltip } - - ); - } - - return ( -
- { symbol } -
- ); + return + { tooltip } + ; } } diff --git a/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx new file mode 100644 index 0000000000..868df3216f --- /dev/null +++ b/src/components/views/rooms/NotificationBadge/StatelessNotificationBadge.tsx @@ -0,0 +1,81 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React, { MouseEvent } from "react"; +import classNames from "classnames"; + +import { formatCount } from "../../../../utils/FormattingUtils"; +import AccessibleButton from "../../elements/AccessibleButton"; +import { NotificationColor } from "../../../../stores/notifications/NotificationColor"; + +interface Props { + symbol: string | null; + count: number; + color: NotificationColor; + onClick?: (ev: MouseEvent) => void; + onMouseOver?: (ev: MouseEvent) => void; + onMouseLeave?: (ev: MouseEvent) => void; + children?: React.ReactChildren | JSX.Element; + label?: string; +} + +export function StatelessNotificationBadge({ + symbol, + count, + color, + ...props }: Props) { + // Don't show a badge if we don't need to + if (color === NotificationColor.None) return null; + + const hasUnreadCount = color >= NotificationColor.Grey && (!!count || !!symbol); + + const isEmptyBadge = symbol === null && count === 0; + + if (symbol === null && count > 0) { + symbol = formatCount(count); + } + + const classes = classNames({ + 'mx_NotificationBadge': true, + 'mx_NotificationBadge_visible': isEmptyBadge ? true : hasUnreadCount, + 'mx_NotificationBadge_highlighted': color === NotificationColor.Red, + 'mx_NotificationBadge_dot': isEmptyBadge, + 'mx_NotificationBadge_2char': symbol?.length > 0 && symbol?.length < 3, + 'mx_NotificationBadge_3char': symbol?.length > 2, + }); + + if (props.onClick) { + return ( + + { symbol } + { props.children } + + ); + } + + return ( +
+ { symbol } +
+ ); +} diff --git a/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx new file mode 100644 index 0000000000..a623daa716 --- /dev/null +++ b/src/components/views/rooms/NotificationBadge/UnreadNotificationBadge.tsx @@ -0,0 +1,36 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { Room } from "matrix-js-sdk/src/models/room"; +import React from "react"; + +import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications"; +import { StatelessNotificationBadge } from "./StatelessNotificationBadge"; + +interface Props { + room: Room; + threadId?: string; +} + +export function UnreadNotificationBadge({ room, threadId }: Props) { + const { symbol, count, color } = useUnreadNotifications(room, threadId); + + return ; +} diff --git a/src/hooks/useUnreadNotifications.ts b/src/hooks/useUnreadNotifications.ts new file mode 100644 index 0000000000..3262137274 --- /dev/null +++ b/src/hooks/useUnreadNotifications.ts @@ -0,0 +1,93 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { NotificationCount, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { useCallback, useEffect, useState } from "react"; + +import { getUnsentMessages } from "../components/structures/RoomStatusBar"; +import { getRoomNotifsState, getUnreadNotificationCount, RoomNotifState } from "../RoomNotifs"; +import { NotificationColor } from "../stores/notifications/NotificationColor"; +import { doesRoomHaveUnreadMessages } from "../Unread"; +import { EffectiveMembership, getEffectiveMembership } from "../utils/membership"; +import { useEventEmitter } from "./useEventEmitter"; + +export const useUnreadNotifications = (room: Room, threadId?: string): { + symbol: string | null; + count: number; + color: NotificationColor; +} => { + const [symbol, setSymbol] = useState(null); + const [count, setCount] = useState(0); + const [color, setColor] = useState(0); + + useEventEmitter(room, RoomEvent.UnreadNotifications, + (unreadNotifications: NotificationCount, evtThreadId?: string) => { + // Discarding all events not related to the thread if one has been setup + if (threadId && threadId !== evtThreadId) return; + updateNotificationState(); + }, + ); + useEventEmitter(room, RoomEvent.Receipt, () => updateNotificationState()); + useEventEmitter(room, RoomEvent.Timeline, () => updateNotificationState()); + useEventEmitter(room, RoomEvent.Redaction, () => updateNotificationState()); + useEventEmitter(room, RoomEvent.LocalEchoUpdated, () => updateNotificationState()); + useEventEmitter(room, RoomEvent.MyMembership, () => updateNotificationState()); + + const updateNotificationState = useCallback(() => { + if (getUnsentMessages(room, threadId).length > 0) { + setSymbol("!"); + setCount(1); + setColor(NotificationColor.Unsent); + } else if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) { + setSymbol("!"); + setCount(1); + setColor(NotificationColor.Red); + } else if (getRoomNotifsState(room.roomId) === RoomNotifState.Mute) { + setSymbol(null); + setCount(0); + setColor(NotificationColor.None); + } else { + const redNotifs = getUnreadNotificationCount(room, NotificationCountType.Highlight, threadId); + const greyNotifs = getUnreadNotificationCount(room, NotificationCountType.Total, threadId); + + const trueCount = greyNotifs || redNotifs; + setCount(trueCount); + setSymbol(null); + if (redNotifs > 0) { + setColor(NotificationColor.Red); + } else if (greyNotifs > 0) { + setColor(NotificationColor.Grey); + } else if (!threadId) { + // TODO: No support for `Bold` on threads at the moment + + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + const hasUnread = doesRoomHaveUnreadMessages(room); + setColor(hasUnread ? NotificationColor.Bold : NotificationColor.None); + } + } + }, [room, threadId]); + + useEffect(() => { + updateNotificationState(); + }, [updateNotificationState]); + + return { + symbol, + count, + color, + }; +}; diff --git a/src/stores/notifications/RoomNotificationState.ts b/src/stores/notifications/RoomNotificationState.ts index 9c64b7ec42..49e76bedf8 100644 --- a/src/stores/notifications/RoomNotificationState.ts +++ b/src/stores/notifications/RoomNotificationState.ts @@ -17,6 +17,7 @@ limitations under the License. import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { ClientEvent } from "matrix-js-sdk/src/client"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { NotificationColor } from "./NotificationColor"; import { IDestroyable } from "../../utils/IDestroyable"; @@ -32,15 +33,16 @@ 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.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 +52,17 @@ 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) { + 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 = () => { diff --git a/src/stores/notifications/RoomNotificationStateStore.ts b/src/stores/notifications/RoomNotificationStateStore.ts index 48aa7e7c20..ad9bd9f98d 100644 --- a/src/stores/notifications/RoomNotificationStateStore.ts +++ b/src/stores/notifications/RoomNotificationStateStore.ts @@ -17,6 +17,7 @@ limitations under the License. import { Room } from "matrix-js-sdk/src/models/room"; import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync"; import { ClientEvent } from "matrix-js-sdk/src/client"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { ActionPayload } from "../../dispatcher/payloads"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -39,9 +40,9 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { instance.start(); return instance; })(); - private roomMap = new Map(); - private roomThreadsMap = new Map(); + + private roomThreadsMap: Map = new Map(); private listMap = new Map(); private _globalState = new SummarizedNotificationState(); @@ -86,18 +87,25 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient { */ public getRoomState(room: Room): RoomNotificationState { if (!this.roomMap.has(room)) { - // Not very elegant, but that way we ensure that we start tracking - // threads notification at the same time at rooms. - // There are multiple entry points, and it's unclear which one gets - // called first - const threadState = new ThreadsRoomNotificationState(room); - this.roomThreadsMap.set(room, threadState); + let threadState; + if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) === ServerSupport.Unsupported) { + // Not very elegant, but that way we ensure that we start tracking + // threads notification at the same time at rooms. + // There are multiple entry points, and it's unclear which one gets + // called first + const threadState = new ThreadsRoomNotificationState(room); + this.roomThreadsMap.set(room, threadState); + } this.roomMap.set(room, new RoomNotificationState(room, threadState)); } return this.roomMap.get(room); } - public getThreadsRoomState(room: Room): ThreadsRoomNotificationState { + public getThreadsRoomState(room: Room): ThreadsRoomNotificationState | null { + if (room.client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported) { + return null; + } + if (!this.roomThreadsMap.has(room)) { this.roomThreadsMap.set(room, new ThreadsRoomNotificationState(room)); } diff --git a/test/RoomNotifs-test.ts b/test/RoomNotifs-test.ts index 3f486205df..8ab37e6945 100644 --- a/test/RoomNotifs-test.ts +++ b/test/RoomNotifs-test.ts @@ -16,10 +16,15 @@ limitations under the License. import { mocked } from 'jest-mock'; import { ConditionKind, PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules"; +import { NotificationCountType, Room } from 'matrix-js-sdk/src/models/room'; -import { stubClient } from "./test-utils"; +import { mkEvent, stubClient } from "./test-utils"; import { MatrixClientPeg } from "../src/MatrixClientPeg"; -import { getRoomNotifsState, RoomNotifState } from "../src/RoomNotifs"; +import { + getRoomNotifsState, + RoomNotifState, + getUnreadNotificationCount, +} from "../src/RoomNotifs"; describe("RoomNotifs test", () => { beforeEach(() => { @@ -83,4 +88,74 @@ describe("RoomNotifs test", () => { }); expect(getRoomNotifsState("!roomId:server")).toBe(RoomNotifState.AllMessagesLoud); }); + + describe("getUnreadNotificationCount", () => { + const ROOM_ID = "!roomId:example.org"; + const THREAD_ID = "$threadId"; + + let cli; + let room: Room; + beforeEach(() => { + cli = MatrixClientPeg.get(); + room = new Room(ROOM_ID, cli, cli.getUserId()); + }); + + it("counts room notification type", () => { + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(0); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(0); + }); + + it("counts notifications type", () => { + room.setUnreadNotificationCount(NotificationCountType.Total, 2); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(2); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(1); + }); + + it("counts predecessor highlight", () => { + room.setUnreadNotificationCount(NotificationCountType.Total, 2); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + + const OLD_ROOM_ID = "!oldRoomId:example.org"; + const oldRoom = new Room(OLD_ROOM_ID, cli, cli.getUserId()); + oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10); + oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6); + + cli.getRoom.mockReset().mockReturnValue(oldRoom); + + const predecessorEvent = mkEvent({ + event: true, + type: "m.room.create", + room: ROOM_ID, + user: cli.getUserId(), + content: { + creator: cli.getUserId(), + room_version: "5", + predecessor: { + room_id: OLD_ROOM_ID, + event_id: "$someevent", + }, + }, + ts: Date.now(), + }); + room.addLiveEvents([predecessorEvent]); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total)).toBe(8); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight)).toBe(7); + }); + + it("counts thread notification type", () => { + expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(0); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(0); + }); + + it("counts notifications type", () => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 2); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1); + + expect(getUnreadNotificationCount(room, NotificationCountType.Total, THREAD_ID)).toBe(2); + expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(1); + }); + }); }); diff --git a/test/components/structures/RoomStatusBar-test.tsx b/test/components/structures/RoomStatusBar-test.tsx new file mode 100644 index 0000000000..db8b0e03ff --- /dev/null +++ b/test/components/structures/RoomStatusBar-test.tsx @@ -0,0 +1,91 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { getUnsentMessages } from "../../../src/components/structures/RoomStatusBar"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { mkEvent, stubClient } from "../../test-utils/test-utils"; +import { mkThread } from "../../test-utils/threads"; + +describe("RoomStatusBar", () => { + const ROOM_ID = "!roomId:example.org"; + let room: Room; + let client: MatrixClient; + let event: MatrixEvent; + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.get(); + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + event = mkEvent({ + event: true, + type: "m.room.message", + user: "@user1:server", + room: "!room1:server", + content: {}, + }); + event.status = EventStatus.NOT_SENT; + }); + + describe("getUnsentMessages", () => { + it("returns no unsent messages", () => { + expect(getUnsentMessages(room)).toHaveLength(0); + }); + + it("checks the event status", () => { + room.addPendingEvent(event, "123"); + + expect(getUnsentMessages(room)).toHaveLength(1); + event.status = EventStatus.SENT; + + expect(getUnsentMessages(room)).toHaveLength(0); + }); + + it("only returns events related to a thread", () => { + room.addPendingEvent(event, "123"); + + const { rootEvent, events } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@alice:example.org"], + length: 2, + }); + rootEvent.status = EventStatus.NOT_SENT; + room.addPendingEvent(rootEvent, rootEvent.getId()); + for (const event of events) { + event.status = EventStatus.NOT_SENT; + room.addPendingEvent(event, Date.now() + Math.random() + ""); + } + + const pendingEvents = getUnsentMessages(room, rootEvent.getId()); + + expect(pendingEvents[0].threadRootId).toBe(rootEvent.getId()); + expect(pendingEvents[1].threadRootId).toBe(rootEvent.getId()); + expect(pendingEvents[2].threadRootId).toBe(rootEvent.getId()); + + // Filters out the non thread events + expect(pendingEvents.every(ev => ev.getId() !== event.getId())).toBe(true); + }); + }); +}); diff --git a/test/components/views/right_panel/RoomHeaderButtons-test.tsx b/test/components/views/right_panel/RoomHeaderButtons-test.tsx new file mode 100644 index 0000000000..5d873f4b86 --- /dev/null +++ b/test/components/views/right_panel/RoomHeaderButtons-test.tsx @@ -0,0 +1,97 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { render } from "@testing-library/react"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import React from "react"; + +import RoomHeaderButtons from "../../../../src/components/views/right_panel/RoomHeaderButtons"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import SettingsStore from "../../../../src/settings/SettingsStore"; +import { stubClient } from "../../../test-utils"; + +describe("RoomHeaderButtons-test.tsx", function() { + const ROOM_ID = "!roomId:example.org"; + let room: Room; + let client: MatrixClient; + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.get(); + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { + if (name === "feature_thread") return true; + }); + }); + + function getComponent(room: Room) { + return render(); + } + + function getThreadButton(container) { + return container.querySelector(".mx_RightPanel_threadsButton"); + } + + function isIndicatorOfType(container, type: "red" | "gray") { + return container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator") + .className + .includes(type); + } + + it("shows the thread button", () => { + const { container } = getComponent(room); + expect(getThreadButton(container)).not.toBeNull(); + }); + + it("hides the thread button", () => { + jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false); + const { container } = getComponent(room); + expect(getThreadButton(container)).toBeNull(); + }); + + it("room wide notification does not change the thread button", () => { + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + + const { container } = getComponent(room); + + expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); + }); + + it("room wide notification does not change the thread button", () => { + const { container } = getComponent(room); + + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1); + expect(isIndicatorOfType(container, "gray")).toBe(true); + + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 1); + expect(isIndicatorOfType(container, "red")).toBe(true); + + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 0); + room.setThreadUnreadNotificationCount("$123", NotificationCountType.Highlight, 0); + + expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); + }); +}); diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx new file mode 100644 index 0000000000..6de3a262cd --- /dev/null +++ b/test/components/views/rooms/EventTile-test.tsx @@ -0,0 +1,112 @@ +/* +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 { act, render } from "@testing-library/react"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import React from "react"; + +import EventTile, { EventTileProps } from "../../../../src/components/views/rooms/EventTile"; +import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { getRoomContext, mkMessage, stubClient } from "../../../test-utils"; +import { mkThread } from "../../../test-utils/threads"; + +describe("EventTile", () => { + const ROOM_ID = "!roomId:example.org"; + let mxEvent: MatrixEvent; + let room: Room; + let client: MatrixClient; + // let changeEvent: (event: MatrixEvent) => void; + + function TestEventTile(props: Partial) { + // const [event] = useState(mxEvent); + // Give a way for a test to update the event prop. + // changeEvent = setEvent; + + return ; + } + + function getComponent( + overrides: Partial = {}, + renderingType: TimelineRenderingType = TimelineRenderingType.Room, + ) { + const context = getRoomContext(room, { + timelineRenderingType: renderingType, + }); + return render( + + + , + ); + } + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + client = MatrixClientPeg.get(); + + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + + jest.spyOn(client, "getRoom").mockReturnValue(room); + + mxEvent = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + }); + + describe("EventTile renderingType: ThreadsList", () => { + beforeEach(() => { + const { rootEvent } = mkThread({ + room, + client, + authorId: "@alice:example.org", + participantUserIds: ["@alice:example.org"], + }); + mxEvent = rootEvent; + }); + + it("shows an unread notification bage", () => { + const { container } = getComponent({}, TimelineRenderingType.ThreadsList); + + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0); + + act(() => { + room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Total, 3); + }); + + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); + expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(0); + + act(() => { + room.setThreadUnreadNotificationCount(mxEvent.getId(), NotificationCountType.Highlight, 1); + }); + + expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(1); + expect(container.getElementsByClassName("mx_NotificationBadge_highlighted")).toHaveLength(1); + }); + }); +}); diff --git a/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx new file mode 100644 index 0000000000..95d598a704 --- /dev/null +++ b/test/components/views/rooms/NotificationBadge/NotificationBadge-test.tsx @@ -0,0 +1,49 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { fireEvent, render } from "@testing-library/react"; +import React from "react"; + +import { + StatelessNotificationBadge, +} from "../../../../../src/components/views/rooms/NotificationBadge/StatelessNotificationBadge"; +import { NotificationColor } from "../../../../../src/stores/notifications/NotificationColor"; + +describe("NotificationBadge", () => { + describe("StatelessNotificationBadge", () => { + it("lets you click it", () => { + const cb = jest.fn(); + + const { container } = render(); + + fireEvent.click(container.firstChild); + expect(cb).toHaveBeenCalledTimes(1); + + fireEvent.mouseEnter(container.firstChild); + expect(cb).toHaveBeenCalledTimes(2); + + fireEvent.mouseLeave(container.firstChild); + expect(cb).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx new file mode 100644 index 0000000000..20289dc6b9 --- /dev/null +++ b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx @@ -0,0 +1,132 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; +import "jest-mock"; +import { screen, act, render } from "@testing-library/react"; +import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { mocked } from "jest-mock"; +import { EventStatus } from "matrix-js-sdk/src/models/event-status"; + +import { + UnreadNotificationBadge, +} from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge"; +import { mkMessage, stubClient } from "../../../../test-utils/test-utils"; +import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; +import * as RoomNotifs from "../../../../../src/RoomNotifs"; + +jest.mock("../../../../../src/RoomNotifs"); +jest.mock('../../../../../src/RoomNotifs', () => ({ + ...(jest.requireActual('../../../../../src/RoomNotifs') as Object), + getRoomNotifsState: jest.fn(), +})); + +const ROOM_ID = "!roomId:example.org"; +let THREAD_ID; + +describe("UnreadNotificationBadge", () => { + let mockClient: MatrixClient; + let room: Room; + + function getComponent(threadId?: string) { + return ; + } + + beforeEach(() => { + jest.clearAllMocks(); + + stubClient(); + mockClient = mocked(MatrixClientPeg.get()); + + room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); + room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + + jest.spyOn(RoomNotifs, "getRoomNotifsState").mockReturnValue(RoomNotifs.RoomNotifState.AllMessages); + }); + + it("renders unread notification badge", () => { + const { container } = render(getComponent()); + + expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy(); + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy(); + + act(() => { + room.setUnreadNotificationCount(NotificationCountType.Highlight, 1); + }); + + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeTruthy(); + }); + + it("renders unread thread notification badge", () => { + const { container } = render(getComponent(THREAD_ID)); + + expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy(); + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy(); + + act(() => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 1); + }); + + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeTruthy(); + }); + + it("hides unread notification badge", () => { + act(() => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + const { container } = render(getComponent(THREAD_ID)); + expect(container.querySelector(".mx_NotificationBadge_visible")).toBeFalsy(); + }); + }); + + it("adds a warning for unsent messages", () => { + const evt = mkMessage({ + room: room.roomId, + user: "@alice:example.org", + msg: "Hello world!", + event: true, + }); + evt.status = EventStatus.NOT_SENT; + + room.addPendingEvent(evt, "123"); + + render(getComponent()); + + expect(screen.queryByText("!")).not.toBeNull(); + }); + + it("adds a warning for invites", () => { + jest.spyOn(room, "getMyMembership").mockReturnValue("invite"); + render(getComponent()); + expect(screen.queryByText("!")).not.toBeNull(); + }); + + it("hides counter for muted rooms", () => { + jest.spyOn(RoomNotifs, "getRoomNotifsState") + .mockReset() + .mockReturnValue(RoomNotifs.RoomNotifState.Mute); + + const { container } = render(getComponent()); + expect(container.querySelector(".mx_NotificationBadge")).toBeNull(); + }); +}); diff --git a/test/stores/notifications/RoomNotificationState-test.ts b/test/stores/notifications/RoomNotificationState-test.ts index 904e068909..c9ee6dd497 100644 --- a/test/stores/notifications/RoomNotificationState-test.ts +++ b/test/stores/notifications/RoomNotificationState-test.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; -import { MatrixEventEvent, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixEventEvent, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix"; import { stubClient } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; @@ -24,12 +24,16 @@ import * as testUtils from "../../test-utils"; import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState"; describe("RoomNotificationState", () => { - stubClient(); - const client = MatrixClientPeg.get(); + let testRoom: Room; + let client: MatrixClient; + + beforeEach(() => { + stubClient(); + client = MatrixClientPeg.get(); + testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client); + }); it("Updates on event decryption", () => { - const testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client); - const roomNotifState = new RoomNotificationState(testRoom as any as Room); const listener = jest.fn(); roomNotifState.addListener(NotificationStateEvents.Update, listener); @@ -40,4 +44,9 @@ describe("RoomNotificationState", () => { client.emit(MatrixEventEvent.Decrypted, testEvent); expect(listener).toHaveBeenCalled(); }); + + it("removes listeners", () => { + const roomNotifState = new RoomNotificationState(testRoom as any as Room); + expect(() => roomNotifState.destroy()).not.toThrow(); + }); }); diff --git a/test/stores/notifications/RoomNotificationStateStore-test.ts b/test/stores/notifications/RoomNotificationStateStore-test.ts new file mode 100644 index 0000000000..e5d24881ae --- /dev/null +++ b/test/stores/notifications/RoomNotificationStateStore-test.ts @@ -0,0 +1,60 @@ +/* +Copyright 2022 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; +import { Room } from "matrix-js-sdk/src/models/room"; + +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore"; +import { stubClient } from "../../test-utils"; + +describe("RoomNotificationStateStore", () => { + const ROOM_ID = "!roomId:example.org"; + + let room; + let client; + + beforeEach(() => { + stubClient(); + client = MatrixClientPeg.get(); + room = new Room(ROOM_ID, client, client.getUserId(), { + pendingEventOrdering: PendingEventOrdering.Detached, + }); + }); + + it("does not use legacy thread notification store", () => { + client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable); + expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull(); + }); + + it("use legacy thread notification store", () => { + client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported); + expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull(); + }); + + it("does not use legacy thread notification store", () => { + client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable); + RoomNotificationStateStore.instance.getRoomState(room); + expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull(); + }); + + it("use legacy thread notification store", () => { + client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported); + RoomNotificationStateStore.instance.getRoomState(room); + expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull(); + }); +}); diff --git a/test/test-utils/threads.ts b/test/test-utils/threads.ts index 419b09b2b8..2259527178 100644 --- a/test/test-utils/threads.ts +++ b/test/test-utils/threads.ts @@ -106,7 +106,7 @@ export const mkThread = ({ participantUserIds, length = 2, ts = 1, -}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent } => { +}: MakeThreadProps): { thread: Thread, rootEvent: MatrixEvent, events: MatrixEvent[] } => { const { rootEvent, events } = makeThreadEvents({ roomId: room.roomId, authorId, @@ -120,5 +120,5 @@ export const mkThread = ({ // So that we do not have to mock the thread loading thread.initialEventsFetched = true; - return { thread, rootEvent }; + return { thread, rootEvent, events }; }; From 913af09e619a07471e989a2ad5acde2e3ae806a8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 24 Oct 2022 09:06:20 +0100 Subject: [PATCH 17/79] Convert some tests from Enzyme to RTL (#9483) --- .../views/beacon/ShareLatestLocation.tsx | 2 +- .../views/beacon/BeaconListItem-test.tsx | 67 +++--- .../beacon/LeftPanelLiveShareWarning-test.tsx | 71 +++--- .../views/beacon/ShareLatestLocation-test.tsx | 21 +- .../beacon/StyledLiveBeaconIcon-test.tsx | 10 +- .../BeaconListItem-test.tsx.snap | 67 +++++- .../__snapshots__/DialogSidebar-test.tsx.snap | 2 +- .../LeftPanelLiveShareWarning-test.tsx.snap | 80 ++----- .../ShareLatestLocation-test.tsx.snap | 97 +++------ .../StyledLiveBeaconIcon-test.tsx.snap | 9 + .../views/elements/StyledRadioGroup-test.tsx | 38 ++-- .../StyledRadioGroup-test.tsx.snap | 203 ++++++------------ test/modules/ModuleComponents-test.tsx | 11 +- .../ModuleComponents-test.tsx.snap | 85 +++----- 14 files changed, 313 insertions(+), 450 deletions(-) create mode 100644 test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap diff --git a/src/components/views/beacon/ShareLatestLocation.tsx b/src/components/views/beacon/ShareLatestLocation.tsx index 09c179f6d6..be8bc6f977 100644 --- a/src/components/views/beacon/ShareLatestLocation.tsx +++ b/src/components/views/beacon/ShareLatestLocation.tsx @@ -47,7 +47,7 @@ const ShareLatestLocation: React.FC = ({ latestLocationState }) => { return <> ', () => { beacon: new Beacon(aliceBeaconEvent), }; - const getComponent = (props = {}) => - mount(, { - wrappingComponent: MatrixClientContext.Provider, - wrappingComponentProps: { value: mockClient }, - }); + const getComponent = (props = {}) => render( + + ); const setupRoomWithBeacons = (beaconInfoEvents: MatrixEvent[], locationEvents?: MatrixEvent[]): Beacon[] => { const beacons = makeRoomWithBeacons(roomId, mockClient, beaconInfoEvents, locationEvents); @@ -104,71 +100,72 @@ describe('', () => { { isLive: false }, ); const [beacon] = setupRoomWithBeacons([notLiveBeacon]); - const component = getComponent({ beacon }); - expect(component.html()).toBeNull(); + const { container } = getComponent({ beacon }); + expect(container.innerHTML).toBeFalsy(); }); it('renders null when beacon has no location', () => { const [beacon] = setupRoomWithBeacons([aliceBeaconEvent]); - const component = getComponent({ beacon }); - expect(component.html()).toBeNull(); + const { container } = getComponent({ beacon }); + expect(container.innerHTML).toBeFalsy(); }); describe('when a beacon is live and has locations', () => { it('renders beacon info', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.html()).toMatchSnapshot(); + const { asFragment } = getComponent({ beacon }); + expect(asFragment()).toMatchSnapshot(); }); describe('non-self beacons', () => { it('uses beacon description as beacon name', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('BeaconStatus').props().label).toEqual("Alice's car"); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent("Alice's car"); }); it('uses beacon owner mxid as beacon name for a beacon without description', () => { const [beacon] = setupRoomWithBeacons([pinBeaconWithoutDescription], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('BeaconStatus').props().label).toEqual(aliceId); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent(aliceId); }); it('renders location icon', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('StyledLiveBeaconIcon').length).toBeTruthy(); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_StyledLiveBeaconIcon')).toBeTruthy(); }); }); describe('self locations', () => { it('renders beacon owner avatar', () => { const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('MemberAvatar').length).toBeTruthy(); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_BaseAvatar')).toBeTruthy(); }); it('uses beacon owner name as beacon name', () => { const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation1]); - const component = getComponent({ beacon }); - expect(component.find('BeaconStatus').props().label).toEqual('Alice'); + const { container } = getComponent({ beacon }); + expect(container.querySelector('.mx_BeaconStatus_label')).toHaveTextContent("Alice"); }); }); describe('on location updates', () => { it('updates last updated time on location updated', () => { const [beacon] = setupRoomWithBeacons([aliceBeaconEvent], [aliceLocation2]); - const component = getComponent({ beacon }); + const { container } = getComponent({ beacon }); - expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated 9 minutes ago'); + expect(container.querySelector('.mx_BeaconListItem_lastUpdated')) + .toHaveTextContent('Updated 9 minutes ago'); // update to a newer location act(() => { beacon.addLocations([aliceLocation1]); - component.setProps({}); }); - expect(component.find('.mx_BeaconListItem_lastUpdated').text()).toEqual('Updated a few seconds ago'); + expect(container.querySelector('.mx_BeaconListItem_lastUpdated')) + .toHaveTextContent('Updated a few seconds ago'); }); }); @@ -176,23 +173,19 @@ describe('', () => { it('does not call onClick handler when clicking share button', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); const onClick = jest.fn(); - const component = getComponent({ beacon, onClick }); + const { getByTestId } = getComponent({ beacon, onClick }); - act(() => { - findByTestId(component, 'open-location-in-osm').at(0).simulate('click'); - }); + fireEvent.click(getByTestId('open-location-in-osm')); expect(onClick).not.toHaveBeenCalled(); }); it('calls onClick handler when clicking outside of share buttons', () => { const [beacon] = setupRoomWithBeacons([alicePinBeaconEvent], [aliceLocation1]); const onClick = jest.fn(); - const component = getComponent({ beacon, onClick }); + const { container } = getComponent({ beacon, onClick }); - act(() => { - // click the beacon name - component.find('.mx_BeaconStatus_description').simulate('click'); - }); + // click the beacon name + fireEvent.click(container.querySelector(".mx_BeaconStatus_description")); expect(onClick).toHaveBeenCalled(); }); }); diff --git a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx index 0369fa5cc1..b8ab33b044 100644 --- a/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx +++ b/test/components/views/beacon/LeftPanelLiveShareWarning-test.tsx @@ -16,8 +16,7 @@ limitations under the License. import React from 'react'; import { mocked } from 'jest-mock'; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; +import { fireEvent, render } from "@testing-library/react"; import { act } from 'react-dom/test-utils'; import { Beacon, BeaconIdentifier } from 'matrix-js-sdk/src/matrix'; @@ -48,9 +47,7 @@ jest.mock('../../../../src/stores/OwnBeaconStore', () => { ); describe('', () => { - const defaultProps = {}; - const getComponent = (props = {}) => - mount(); + const getComponent = (props = {}) => render(); const roomId1 = '!room1:server'; const roomId2 = '!room2:server'; @@ -85,8 +82,8 @@ describe('', () => { )); it('renders nothing when user has no live beacons', () => { - const component = getComponent(); - expect(component.html()).toBe(null); + const { container } = getComponent(); + expect(container.innerHTML).toBeFalsy(); }); describe('when user has live location monitor', () => { @@ -110,17 +107,15 @@ describe('', () => { }); it('renders correctly when not minimized', () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); + const { asFragment } = getComponent(); + expect(asFragment()).toMatchSnapshot(); }); it('goes to room of latest beacon when clicked', () => { - const component = getComponent(); + const { container } = getComponent(); const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); - act(() => { - component.simulate('click'); - }); + fireEvent.click(container.querySelector("[role=button]")); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, @@ -134,28 +129,26 @@ describe('', () => { }); it('renders correctly when minimized', () => { - const component = getComponent({ isMinimized: true }); - expect(component).toMatchSnapshot(); + const { asFragment } = getComponent({ isMinimized: true }); + expect(asFragment()).toMatchSnapshot(); }); it('renders location publish error', () => { mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue( [beacon1.identifier], ); - const component = getComponent(); - expect(component).toMatchSnapshot(); + const { asFragment } = getComponent(); + expect(asFragment()).toMatchSnapshot(); }); it('goes to room of latest beacon with location publish error when clicked', () => { mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue( [beacon1.identifier], ); - const component = getComponent(); + const { container } = getComponent(); const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); - act(() => { - component.simulate('click'); - }); + fireEvent.click(container.querySelector("[role=button]")); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, @@ -172,9 +165,9 @@ describe('', () => { mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithLocationPublishError.mockReturnValue( [beacon1.identifier], ); - const component = getComponent(); + const { container, rerender } = getComponent(); // error mode - expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( + expect(container.querySelector('.mx_LeftPanelLiveShareWarning').textContent).toEqual( 'An error occurred whilst sharing your live location', ); @@ -183,18 +176,18 @@ describe('', () => { OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.LocationPublishError, 'abc'); }); - component.setProps({}); + rerender(); // default mode - expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( + expect(container.querySelector('.mx_LeftPanelLiveShareWarning').textContent).toEqual( 'You are sharing your live location', ); }); it('removes itself when user stops having live beacons', async () => { - const component = getComponent({ isMinimized: true }); + const { container, rerender } = getComponent({ isMinimized: true }); // started out rendered - expect(component.html()).toBeTruthy(); + expect(container.innerHTML).toBeTruthy(); act(() => { mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; @@ -202,9 +195,9 @@ describe('', () => { }); await flushPromises(); - component.setProps({}); + rerender(); - expect(component.html()).toBe(null); + expect(container.innerHTML).toBeFalsy(); }); it('refreshes beacon liveness monitors when pagevisibilty changes to visible', () => { @@ -228,21 +221,21 @@ describe('', () => { describe('stopping errors', () => { it('renders stopping error', () => { OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error')); - const component = getComponent(); - expect(component.text()).toEqual('An error occurred while stopping your live location'); + const { container } = getComponent(); + expect(container.textContent).toEqual('An error occurred while stopping your live location'); }); it('starts rendering stopping error on beaconUpdateError emit', () => { - const component = getComponent(); + const { container } = getComponent(); // no error - expect(component.text()).toEqual('You are sharing your live location'); + expect(container.textContent).toEqual('You are sharing your live location'); act(() => { OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error')); OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.BeaconUpdateError, beacon2.identifier, true); }); - expect(component.text()).toEqual('An error occurred while stopping your live location'); + expect(container.textContent).toEqual('An error occurred while stopping your live location'); }); it('renders stopping error when beacons have stopping and location errors', () => { @@ -250,8 +243,8 @@ describe('', () => { [beacon1.identifier], ); OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error')); - const component = getComponent(); - expect(component.text()).toEqual('An error occurred while stopping your live location'); + const { container } = getComponent(); + expect(container.textContent).toEqual('An error occurred while stopping your live location'); }); it('goes to room of latest beacon with stopping error when clicked', () => { @@ -259,12 +252,10 @@ describe('', () => { [beacon1.identifier], ); OwnBeaconStore.instance.beaconUpdateErrors.set(beacon2.identifier, new Error('error')); - const component = getComponent(); + const { container } = getComponent(); const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); - act(() => { - component.simulate('click'); - }); + fireEvent.click(container.querySelector("[role=button]")); expect(dispatchSpy).toHaveBeenCalledWith({ action: Action.ViewRoom, diff --git a/test/components/views/beacon/ShareLatestLocation-test.tsx b/test/components/views/beacon/ShareLatestLocation-test.tsx index 767c712042..1712d7d57c 100644 --- a/test/components/views/beacon/ShareLatestLocation-test.tsx +++ b/test/components/views/beacon/ShareLatestLocation-test.tsx @@ -15,9 +15,7 @@ limitations under the License. */ import React from 'react'; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; -import { act } from 'react-dom/test-utils'; +import { fireEvent, render } from "@testing-library/react"; import ShareLatestLocation from '../../../../src/components/views/beacon/ShareLatestLocation'; import { copyPlaintext } from '../../../../src/utils/strings'; @@ -34,26 +32,23 @@ describe('', () => { timestamp: 123, }, }; - const getComponent = (props = {}) => - mount(); + const getComponent = (props = {}) => render(); beforeEach(() => { jest.clearAllMocks(); }); it('renders null when no location', () => { - const component = getComponent({ latestLocationState: undefined }); - expect(component.html()).toBeNull(); + const { container } = getComponent({ latestLocationState: undefined }); + expect(container.innerHTML).toBeFalsy(); }); it('renders share buttons when there is a location', async () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); + const { container, asFragment } = getComponent(); + expect(asFragment()).toMatchSnapshot(); - await act(async () => { - component.find('.mx_CopyableText_copyButton').at(0).simulate('click'); - await flushPromises(); - }); + fireEvent.click(container.querySelector('.mx_CopyableText_copyButton')); + await flushPromises(); expect(copyPlaintext).toHaveBeenCalledWith('51,42'); }); diff --git a/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx b/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx index d6be878a25..e04289c7db 100644 --- a/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx +++ b/test/components/views/beacon/StyledLiveBeaconIcon-test.tsx @@ -15,18 +15,16 @@ limitations under the License. */ import React from 'react'; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; +import { render } from "@testing-library/react"; import StyledLiveBeaconIcon from '../../../../src/components/views/beacon/StyledLiveBeaconIcon'; describe('', () => { const defaultProps = {}; - const getComponent = (props = {}) => - mount(); + const getComponent = (props = {}) => render(); it('renders', () => { - const component = getComponent(); - expect(component).toBeTruthy(); + const { asFragment } = getComponent(); + expect(asFragment()).toMatchSnapshot(); }); }); diff --git a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap index 9ddc5dd44c..dd1d607dd4 100644 --- a/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/BeaconListItem-test.tsx.snap @@ -1,3 +1,68 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` when a beacon is live and has locations renders beacon info 1`] = `"
  • Updated a few seconds ago
  • "`; +exports[` when a beacon is live and has locations renders beacon info 1`] = ` + +
  • +
    +
    +
    +
    + + Alice's car + + + Live until 16:04 + +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + + Updated a few seconds ago + +
    +
  • +
    +`; diff --git a/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap b/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap index a92079d2c8..22199fbc91 100644 --- a/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/DialogSidebar-test.tsx.snap @@ -75,7 +75,7 @@ exports[` renders sidebar correctly with beacons 1`] = ` tabindex="0" > when user has live location monitor renders correctly when minimized 1`] = ` - - +
    -
    -
    - - + height="10" + /> +
    + `; exports[` when user has live location monitor renders correctly when not minimized 1`] = ` - - +
    -
    - You are sharing your live location -
    - - + You are sharing your live location +
    + `; exports[` when user has live location monitor renders location publish error 1`] = ` - - +
    -
    - An error occurred whilst sharing your live location -
    - - + An error occurred whilst sharing your live location +
    + `; diff --git a/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap b/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap index 5f55d3103d..1162786e30 100644 --- a/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/ShareLatestLocation-test.tsx.snap @@ -1,79 +1,30 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders share buttons when there is a location 1`] = ` - - +
    + +
    + +
    +
    - -
    - -
    - - -
    - - -
    - - -
    - - + aria-label="Copy" + class="mx_AccessibleButton mx_CopyableText_copyButton" + role="button" + tabindex="0" + /> +
    + `; diff --git a/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap b/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap new file mode 100644 index 0000000000..e1e2bf1faa --- /dev/null +++ b/test/components/views/beacon/__snapshots__/StyledLiveBeaconIcon-test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders 1`] = ` + +
    + +`; diff --git a/test/components/views/elements/StyledRadioGroup-test.tsx b/test/components/views/elements/StyledRadioGroup-test.tsx index 3fa5dd9c53..8868b741bd 100644 --- a/test/components/views/elements/StyledRadioGroup-test.tsx +++ b/test/components/views/elements/StyledRadioGroup-test.tsx @@ -15,9 +15,7 @@ limitations under the License. */ import React from 'react'; -// eslint-disable-next-line deprecate/import -import { mount } from 'enzyme'; -import { act } from "react-dom/test-utils"; +import { fireEvent, render } from "@testing-library/react"; import StyledRadioGroup from "../../../../src/components/views/elements/StyledRadioGroup"; @@ -44,16 +42,16 @@ describe('', () => { definitions: defaultDefinitions, onChange: jest.fn(), }; - const getComponent = (props = {}) => mount(); + const getComponent = (props = {}) => render(); - const getInputByValue = (component, value) => component.find(`input[value="${value}"]`); - const getCheckedInput = component => component.find('input[checked=true]'); + const getInputByValue = (component, value) => component.container.querySelector(`input[value="${value}"]`); + const getCheckedInput = component => component.container.querySelector('input[checked]'); it('renders radios correctly when no value is provided', () => { const component = getComponent(); - expect(component).toMatchSnapshot(); - expect(getCheckedInput(component).length).toBeFalsy(); + expect(component.asFragment()).toMatchSnapshot(); + expect(getCheckedInput(component)).toBeFalsy(); }); it('selects correct button when value is provided', () => { @@ -61,7 +59,7 @@ describe('', () => { value: optionC.value, }); - expect(getCheckedInput(component).at(0).props().value).toEqual(optionC.value); + expect(getCheckedInput(component).value).toEqual(optionC.value); }); it('selects correct buttons when definitions have checked prop', () => { @@ -74,10 +72,10 @@ describe('', () => { value: optionC.value, definitions, }); - expect(getInputByValue(component, optionA.value).props().checked).toBeTruthy(); - expect(getInputByValue(component, optionB.value).props().checked).toBeFalsy(); + expect(getInputByValue(component, optionA.value)).toBeChecked(); + expect(getInputByValue(component, optionB.value)).not.toBeChecked(); // optionC.checked = false overrides value matching - expect(getInputByValue(component, optionC.value).props().checked).toBeFalsy(); + expect(getInputByValue(component, optionC.value)).not.toBeChecked(); }); it('disables individual buttons based on definition.disabled', () => { @@ -87,16 +85,16 @@ describe('', () => { { ...optionC, disabled: true }, ]; const component = getComponent({ definitions }); - expect(getInputByValue(component, optionA.value).props().disabled).toBeFalsy(); - expect(getInputByValue(component, optionB.value).props().disabled).toBeTruthy(); - expect(getInputByValue(component, optionC.value).props().disabled).toBeTruthy(); + expect(getInputByValue(component, optionA.value)).not.toBeDisabled(); + expect(getInputByValue(component, optionB.value)).toBeDisabled(); + expect(getInputByValue(component, optionC.value)).toBeDisabled(); }); it('disables all buttons with disabled prop', () => { const component = getComponent({ disabled: true }); - expect(getInputByValue(component, optionA.value).props().disabled).toBeTruthy(); - expect(getInputByValue(component, optionB.value).props().disabled).toBeTruthy(); - expect(getInputByValue(component, optionC.value).props().disabled).toBeTruthy(); + expect(getInputByValue(component, optionA.value)).toBeDisabled(); + expect(getInputByValue(component, optionB.value)).toBeDisabled(); + expect(getInputByValue(component, optionC.value)).toBeDisabled(); }); it('calls onChange on click', () => { @@ -106,9 +104,7 @@ describe('', () => { onChange, }); - act(() => { - getInputByValue(component, optionB.value).simulate('change'); - }); + fireEvent.click(getInputByValue(component, optionB.value)); expect(onChange).toHaveBeenCalledWith(optionB.value); }); diff --git a/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap b/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap index 423c006a72..cb3c3374fd 100644 --- a/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/StyledRadioGroup-test.tsx.snap @@ -1,152 +1,83 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders radios correctly when no value is provided 1`] = ` - - Anteater label - , - "value": "Anteater", - }, - Object { - "label": - Badger label - , - "value": "Badger", - }, - Object { - "description": - Canary description - , - "label": - Canary label - , - "value": "Canary", - }, - ] - } - name="test" - onChange={[MockFunction]} -> - +