diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index 6cd4f6b685..d4ebf720b1 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -19,7 +19,6 @@ import { MatrixEvent, IEventRelation } from "matrix-js-sdk/src/models/event"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RelationType } from 'matrix-js-sdk/src/@types/event'; -import { M_POLL_START } from "matrix-events-sdk"; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -27,15 +26,9 @@ import dis from '../../../dispatcher/dispatcher'; import { ActionPayload } from "../../../dispatcher/payloads"; import Stickerpicker from './Stickerpicker'; import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks'; -import ContentMessages from '../../../ContentMessages'; import E2EIcon from './E2EIcon'; import SettingsStore from "../../../settings/SettingsStore"; -import ContextMenu, { - aboveLeftOf, - useContextMenu, - MenuItem, - AboveLeftOf, -} from "../../structures/ContextMenu"; +import { aboveLeftOf, AboveLeftOf } from "../../structures/ContextMenu"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ReplyPreview from "./ReplyPreview"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; @@ -50,15 +43,10 @@ import SendMessageComposer, { SendMessageComposer as SendMessageComposerClass } import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from "../../../dispatcher/actions"; import EditorModel from "../../../editor/model"; -import EmojiPicker from '../emojipicker/EmojiPicker'; import UIStore, { UI_EVENTS } from '../../../stores/UIStore'; -import Modal from "../../../Modal"; import RoomContext from '../../../contexts/RoomContext'; -import ErrorDialog from "../dialogs/ErrorDialog"; -import PollCreateDialog from "../elements/PollCreateDialog"; import { SettingUpdatedPayload } from "../../../dispatcher/payloads/SettingUpdatedPayload"; -import { CollapsibleButton, ICollapsibleButtonProps } from './CollapsibleButton'; -import LocationButton from '../location/LocationButton'; +import MessageComposerButtons from './MessageComposerButtons'; let instanceCount = 0; const NARROW_MODE_BREAKPOINT = 500; @@ -78,164 +66,6 @@ function SendButton(props: ISendButtonProps) { ); } -interface IEmojiButtonProps extends Pick { - addEmoji: (unicode: string) => boolean; - menuPosition: AboveLeftOf; -} - -const EmojiButton: React.FC = ({ addEmoji, menuPosition, narrowMode }) => { - const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); - - let contextMenu; - if (menuDisplayed) { - const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect()); - contextMenu = - - ; - } - - const className = classNames( - "mx_MessageComposer_button", - "mx_MessageComposer_emoji", - { - "mx_MessageComposer_button_highlight": menuDisplayed, - }, - ); - - // TODO: replace ContextMenuTooltipButton with a unified representation of - // the header buttons and the right panel buttons - return - - - { contextMenu } - ; -}; - -interface IUploadButtonProps { - roomId: string; - relation?: IEventRelation | null; -} - -class UploadButton extends React.Component { - private uploadInput = React.createRef(); - private dispatcherRef: string; - - constructor(props: IUploadButtonProps) { - super(props); - - this.dispatcherRef = dis.register(this.onAction); - } - - componentWillUnmount() { - dis.unregister(this.dispatcherRef); - } - - private onAction = (payload: ActionPayload) => { - if (payload.action === "upload_file") { - this.onUploadClick(); - } - }; - - private onUploadClick = () => { - if (MatrixClientPeg.get().isGuest()) { - dis.dispatch({ action: 'require_registration' }); - return; - } - this.uploadInput.current.click(); - }; - - private onUploadFileInputChange = (ev: React.ChangeEvent) => { - if (ev.target.files.length === 0) return; - - // take a copy so we can safely reset the value of the form control - // (Note it is a FileList: we can't use slice or sensible iteration). - const tfiles = []; - for (let i = 0; i < ev.target.files.length; ++i) { - tfiles.push(ev.target.files[i]); - } - - ContentMessages.sharedInstance().sendContentListToRoom( - tfiles, - this.props.roomId, - this.props.relation, - MatrixClientPeg.get(), - this.context.timelineRenderingType, - ); - - // This is the onChange handler for a file form control, but we're - // not keeping any state, so reset the value of the form control - // to empty. - // NB. we need to set 'value': the 'files' property is immutable. - ev.target.value = ''; - }; - - render() { - const uploadInputStyle = { display: 'none' }; - return ( - - - - ); - } -} - -interface IPollButtonProps extends Pick { - room: Room; -} - -class PollButton extends React.PureComponent { - private onCreateClick = () => { - const canSend = this.props.room.currentState.maySendEvent( - M_POLL_START.name, - MatrixClientPeg.get().getUserId(), - ); - if (!canSend) { - Modal.createTrackedDialog('Polls', 'permissions error: cannot start', ErrorDialog, { - title: _t("Permission Required"), - description: _t("You do not have permission to start polls in this room."), - }); - } else { - Modal.createTrackedDialog( - 'Polls', - 'create', - PollCreateDialog, - { - room: this.props.room, - }, - 'mx_CompoundDialog', - false, // isPriorityModal - true, // isStaticModal - ); - } - }; - - render() { - return ( - - ); - } -} - interface IProps { room: Room; resizeNotifier: ResizeNotifier; @@ -509,108 +339,6 @@ export default class MessageComposer extends React.Component { }); }; - private renderButtons(menuPosition: AboveLeftOf): JSX.Element | JSX.Element[] { - if (this.state.haveRecording) { - return []; - } - - let uploadButtonIndex = 0; - const buttons: JSX.Element[] = []; - buttons.push( - , - ); - uploadButtonIndex = buttons.length; - buttons.push( - , - ); - if (this.state.showLocationButton) { - const sender = this.props.room.getMember( - MatrixClientPeg.get().getUserId(), - ); - buttons.push( - , - ); - } - buttons.push( - , - ); - if (this.state.showStickersButton) { - let title: string; - if (!this.state.narrowMode) { - title = this.state.isStickerPickerOpen ? _t("Hide Stickers") : _t("Show Stickers"); - } - - buttons.push( - this.setStickerPickerOpen(!this.state.isStickerPickerOpen)} - title={title} - label={this.state.narrowMode ? _t("Send a sticker") : null} - />, - ); - } - - // XXX: the recording UI does not work well in narrow mode, so we hide this button for now - if (!this.state.narrowMode) { - buttons.push( - this.voiceRecordingButton.current?.onRecordStartEndClick()} - title={_t("Send voice message")} - narrowMode={this.state.narrowMode} - />, - ); - } - - if (!this.state.narrowMode) { - return buttons; - } - - const classnames = classNames({ - mx_MessageComposer_button: true, - mx_MessageComposer_buttonMenu: true, - mx_MessageComposer_closeButtonMenu: this.state.isMenuOpen, - }); - - // we render the uploadButton at top level as it is a very common interaction, splice it out of the rest - const [uploadButton] = buttons.splice(uploadButtonIndex, 1); - return <> - { uploadButton } - - { this.state.isMenuOpen && ( - - { buttons.map((button, index) => ( - - { button } - - )) } - - ) } - ; - } - render() { const controls = [ this.props.e2eStatus ? @@ -717,7 +445,20 @@ export default class MessageComposer extends React.Component { permalinkCreator={this.props.permalinkCreator} />
{ controls } - { this.renderButtons(menuPosition) } + this.voiceRecordingButton.current?.onRecordStartEndClick()} + setStickerPickerOpen={this.setStickerPickerOpen} + showLocationButton={this.state.showLocationButton} + showStickersButton={this.state.showStickersButton} + toggleButtonMenu={this.toggleButtonMenu} + /> { showSendButton && ( boolean; + haveRecording: boolean; + isMenuOpen: boolean; + isStickerPickerOpen: boolean; + menuPosition: AboveLeftOf; + narrowMode?: boolean; + onRecordStartEndClick: () => void; + relation?: IEventRelation; + setStickerPickerOpen: (isStickerPickerOpen: boolean) => void; + showLocationButton: boolean; + showStickersButton: boolean; + toggleButtonMenu: () => void; +} + +const MessageComposerButtons: React.FC = (props: IProps) => { + const matrixClient: MatrixClient = useContext(MatrixClientContext); + const { room, roomId } = useContext(RoomContext); + + if (props.haveRecording) { + return null; + } + + let uploadButtonIndex = 0; + const buttons: JSX.Element[] = []; + buttons.push( + , + ); + uploadButtonIndex = buttons.length; + buttons.push( + , + ); + if (props.showLocationButton) { + const sender = room.getMember(matrixClient.getUserId()); + buttons.push( + , + ); + } + buttons.push( + , + ); + if (props.showStickersButton) { + let title: string; + if (!props.narrowMode) { + title = props.isStickerPickerOpen ? _t("Hide Stickers") : _t("Show Stickers"); + } + + buttons.push( + props.setStickerPickerOpen(!props.isStickerPickerOpen)} + title={title} + label={props.narrowMode ? _t("Send a sticker") : null} + />, + ); + } + + // XXX: the recording UI does not work well in narrow mode, so we hide this button for now + if (!props.narrowMode) { + buttons.push( + , + ); + } + + if (!props.narrowMode) { + return <>{ buttons }; + } + + const classnames = classNames({ + mx_MessageComposer_button: true, + mx_MessageComposer_buttonMenu: true, + mx_MessageComposer_closeButtonMenu: props.isMenuOpen, + }); + + // we render the uploadButton at top level as it is a very common interaction, splice it out of the rest + const [uploadButton] = buttons.splice(uploadButtonIndex, 1); + return <> + { uploadButton } + + { props.isMenuOpen && ( + + { buttons.map((button, index) => ( + + { button } + + )) } + + ) } + ; +}; + +interface IEmojiButtonProps extends Pick { + addEmoji: (unicode: string) => boolean; + menuPosition: AboveLeftOf; +} + +const EmojiButton: React.FC = ({ addEmoji, menuPosition, narrowMode }) => { + const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + + let contextMenu: React.ReactElement | null = null; + if (menuDisplayed) { + const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect()); + contextMenu = + + ; + } + + const className = classNames( + "mx_MessageComposer_button", + "mx_MessageComposer_emoji", + { + "mx_MessageComposer_button_highlight": menuDisplayed, + }, + ); + + // TODO: replace ContextMenuTooltipButton with a unified representation of + // the header buttons and the right panel buttons + return + + + { contextMenu } + ; +}; + +interface IUploadButtonProps { + roomId: string; + relation?: IEventRelation | null; +} + +class UploadButton extends React.Component { + private uploadInput = React.createRef(); + private dispatcherRef: string; + + constructor(props: IUploadButtonProps) { + super(props); + + this.dispatcherRef = dis.register(this.onAction); + } + + componentWillUnmount() { + dis.unregister(this.dispatcherRef); + } + + private onAction = (payload: ActionPayload) => { + if (payload.action === "upload_file") { + this.onUploadClick(); + } + }; + + private onUploadClick = () => { + if (MatrixClientPeg.get().isGuest()) { + dis.dispatch({ action: 'require_registration' }); + return; + } + this.uploadInput.current.click(); + }; + + private onUploadFileInputChange = (ev: React.ChangeEvent) => { + if (ev.target.files.length === 0) return; + + // take a copy so we can safely reset the value of the form control + // (Note it is a FileList: we can't use slice or sensible iteration). + const tfiles = []; + for (let i = 0; i < ev.target.files.length; ++i) { + tfiles.push(ev.target.files[i]); + } + + ContentMessages.sharedInstance().sendContentListToRoom( + tfiles, + this.props.roomId, + this.props.relation, + MatrixClientPeg.get(), + this.context.timelineRenderingType, + ); + + // This is the onChange handler for a file form control, but we're + // not keeping any state, so reset the value of the form control + // to empty. + // NB. we need to set 'value': the 'files' property is immutable. + ev.target.value = ''; + }; + + render() { + const uploadInputStyle = { display: 'none' }; + return ( + + + + ); + } +} +interface IPollButtonProps extends Pick { + room: Room; +} + +class PollButton extends React.PureComponent { + private onCreateClick = () => { + const canSend = this.props.room.currentState.maySendEvent( + M_POLL_START.name, + MatrixClientPeg.get().getUserId(), + ); + if (!canSend) { + Modal.createTrackedDialog('Polls', 'permissions error: cannot start', ErrorDialog, { + title: _t("Permission Required"), + description: _t("You do not have permission to start polls in this room."), + }); + } else { + Modal.createTrackedDialog( + 'Polls', + 'create', + PollCreateDialog, + { + room: this.props.room, + }, + 'mx_CompoundDialog', + false, // isPriorityModal + true, // isStaticModal + ); + } + }; + + render() { + return ( + + ); + } +} + +export default MessageComposerButtons; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 3af0a8e2cb..9861e92b73 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1683,24 +1683,24 @@ "Filter room members": "Filter room members", "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", "Send message": "Send message", - "Add emoji": "Add emoji", - "Upload file": "Upload file", - "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", - "Create poll": "Create poll", "Reply to encrypted thread…": "Reply to encrypted thread…", "Reply to thread…": "Reply to thread…", "Send an encrypted reply…": "Send an encrypted reply…", "Send a reply…": "Send a reply…", "Send an encrypted message…": "Send an encrypted message…", "Send a message…": "Send a message…", - "Hide Stickers": "Hide Stickers", - "Show Stickers": "Show Stickers", - "Send a sticker": "Send a sticker", - "Send voice message": "Send voice message", "The conversation continues here.": "The conversation continues here.", "This room has been replaced and is no longer active.": "This room has been replaced and is no longer active.", "You do not have permission to post to this room": "You do not have permission to post to this room", "%(seconds)ss left": "%(seconds)ss left", + "Send voice message": "Send voice message", + "Hide Stickers": "Hide Stickers", + "Show Stickers": "Show Stickers", + "Send a sticker": "Send a sticker", + "Add emoji": "Add emoji", + "Upload file": "Upload file", + "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", + "Create poll": "Create poll", "Bold": "Bold", "Italics": "Italics", "Strikethrough": "Strikethrough", diff --git a/test/components/views/rooms/MessageComposerButtons-test.tsx b/test/components/views/rooms/MessageComposerButtons-test.tsx new file mode 100644 index 0000000000..915c66eb48 --- /dev/null +++ b/test/components/views/rooms/MessageComposerButtons-test.tsx @@ -0,0 +1,186 @@ +/* +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 { mount, ReactWrapper } from "enzyme"; +import { Room } from "matrix-js-sdk/src/models/room"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; + +import * as TestUtils from "../../../test-utils"; +import sdk from "../../../skinned-sdk"; +import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; +import { Layout } from "../../../../src/settings/enums/Layout"; +import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; +import { createTestClient } from "../../../test-utils"; +import { IRoomState } from "../../../../src/components/structures/RoomView"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; + +const _MessageComposerButtons = sdk.getComponent("views.rooms.MessageComposerButtons"); +const MessageComposerButtons = TestUtils.wrapInMatrixClientContext( + _MessageComposerButtons, +); + +describe("MessageComposerButtons", () => { + it("Renders all buttons in wide mode", () => { + const buttons = wrapAndRender( + , + ); + + expect(buttonLabels(buttons)).toEqual([ + "Create poll", + "Upload file", + "Share location", + "Add emoji", + "Show Stickers", + "Send voice message", + ]); + }); + + it("Renders only some buttons in narrow mode", () => { + const buttons = wrapAndRender( + , + ); + + expect(buttonLabels(buttons)).toEqual([ + "Upload file", + "More options", + ]); + }); + + it("Renders other buttons in menu (except voice messages) in narrow mode", () => { + const buttons = wrapAndRender( + , + ); + + expect(buttonLabels(buttons)).toEqual([ + "Upload file", + "More options", + [ + "Create poll", + "Share location", + "Add emoji", + "Send a sticker", + ], + ]); + }); +}); + +function wrapAndRender(component: React.ReactElement): ReactWrapper { + const mockClient = MatrixClientPeg.matrixClient = createTestClient(); + const roomId = "myroomid"; + const mockRoom: any = { + currentState: undefined, + roomId, + client: mockClient, + getMember: function(userId: string): RoomMember { + return new RoomMember(roomId, userId); + }, + }; + const roomState = createRoomState(mockRoom); + + return mount( + + + { component } + + , + ); +} + +function createRoomState(room: Room): IRoomState { + return { + room: room, + roomId: room.roomId, + roomLoading: true, + peekLoading: false, + shouldPeek: true, + membersLoaded: false, + numUnreadMessages: 0, + draggingFile: false, + searching: false, + guestsCanJoin: false, + canPeek: false, + showApps: false, + isPeeking: false, + showRightPanel: true, + joining: false, + atEndOfLiveTimeline: true, + atEndOfLiveTimelineInit: false, + showTopUnreadMessagesBar: false, + statusBarVisible: false, + canReact: false, + canReply: false, + layout: Layout.Group, + lowBandwidth: false, + alwaysShowTimestamps: false, + showTwelveHourTimestamps: false, + readMarkerInViewThresholdMs: 3000, + readMarkerOutOfViewThresholdMs: 30000, + showHiddenEventsInTimeline: false, + showReadReceipts: true, + showRedactions: true, + showJoinLeaves: true, + showAvatarChanges: true, + showDisplaynameChanges: true, + matrixClientIsReady: false, + dragCounter: 0, + timelineRenderingType: TimelineRenderingType.Room, + liveTimeline: undefined, + }; +} + +function buttonLabels(buttons: ReactWrapper): any[] { + // Note: Depends on the fact that the mini buttons use aria-label + // and the labels under More options use label + const mainButtons = ( + buttons + .find('div') + .map((button: ReactWrapper) => button.prop("aria-label")) + .filter(x => x) + ); + + let extraButtons = ( + buttons + .find('div') + .map((button: ReactWrapper) => button.prop("label")) + .filter(x => x) + ); + if (extraButtons.length === 0) { + extraButtons = []; + } else { + extraButtons = [extraButtons]; + } + + return [ + ...mainButtons, + ...extraButtons, + ]; +}