diff --git a/res/css/views/rooms/_MessageComposer.scss b/res/css/views/rooms/_MessageComposer.scss index 5e2eff4047..a544bca6a6 100644 --- a/res/css/views/rooms/_MessageComposer.scss +++ b/res/css/views/rooms/_MessageComposer.scss @@ -237,6 +237,15 @@ limitations under the License. mask-image: url('$(res)/img/element-icons/room/composer/sticker.svg'); } +.mx_MessageComposer_buttonMenu::before { + mask-image: url('$(res)/img/feather-customised/chevron-down.svg'); +} + +.mx_MessageComposer_closeButtonMenu::before { + transform: rotate(180deg); + transform-origin: center; +} + .mx_MessageComposer_sendMessage { cursor: pointer; position: relative; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index c39d8bebc8..001966c346 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -13,7 +13,7 @@ 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 React, { createRef } from 'react'; import classNames from 'classnames'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; @@ -27,7 +27,14 @@ import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalin import ContentMessages from '../../../ContentMessages'; import E2EIcon from './E2EIcon'; import SettingsStore from "../../../settings/SettingsStore"; -import { aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; +import { + aboveLeftOf, + ContextMenu, + ContextMenuTooltipButton, + useContextMenu, + MenuItem, + alwaysAboveRightOf, +} from "../../structures/ContextMenu"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import ReplyPreview from "./ReplyPreview"; import { UIFeature } from "../../../settings/UIFeature"; @@ -45,6 +52,9 @@ import { Action } from "../../../dispatcher/actions"; import EditorModel from "../../../editor/model"; import EmojiPicker from '../emojipicker/EmojiPicker'; import MemberStatusMessageAvatar from "../avatars/MemberStatusMessageAvatar"; +import UIStore, { UI_EVENTS } from '../../../stores/UIStore'; + +const NARROW_MODE_BREAKPOINT = 500; interface IComposerAvatarProps { me: object; @@ -71,13 +81,13 @@ function SendButton(props: ISendButtonProps) { ); } -const EmojiButton = ({ addEmoji }) => { +const EmojiButton = ({ addEmoji, menuPosition }) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); let contextMenu; if (menuDisplayed) { - const buttonRect = button.current.getBoundingClientRect(); - contextMenu = + const position = menuPosition ?? aboveLeftOf(button.current.getBoundingClientRect()); + contextMenu = ; } @@ -193,6 +203,9 @@ interface IState { haveRecording: boolean; recordingTimeLeftSeconds?: number; me?: RoomMember; + narrowMode?: boolean; + isMenuOpen: boolean; + showStickers: boolean; } @replaceableComponent("views.rooms.MessageComposer") @@ -200,6 +213,7 @@ export default class MessageComposer extends React.Component { private dispatcherRef: string; private messageComposerInput: SendMessageComposer; private voiceRecordingButton: VoiceRecordComposerTile; + private ref: React.RefObject = createRef(); constructor(props) { super(props); @@ -211,6 +225,8 @@ export default class MessageComposer extends React.Component { isComposerEmpty: true, haveRecording: false, recordingTimeLeftSeconds: null, // when set to a number, shows a toast + isMenuOpen: false, + showStickers: false, }; } @@ -218,8 +234,21 @@ export default class MessageComposer extends React.Component { this.dispatcherRef = dis.register(this.onAction); MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents); this.waitForOwnMember(); + UIStore.instance.trackElementDimensions("MessageComposer", this.ref.current); + UIStore.instance.on("MessageComposer", this.onResize); } + private onResize = (type: UI_EVENTS, entry: ResizeObserverEntry) => { + if (type === UI_EVENTS.Resize) { + const narrowMode = entry.contentRect.width <= NARROW_MODE_BREAKPOINT; + this.setState({ + narrowMode, + isMenuOpen: !narrowMode ? false : this.state.isMenuOpen, + showStickers: false, + }); + } + }; + private onAction = (payload: ActionPayload) => { if (payload.action === 'reply_to_event') { // add a timeout for the reply preview to be rendered, so @@ -254,6 +283,8 @@ export default class MessageComposer extends React.Component { } VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate); dis.unregister(this.dispatcherRef); + UIStore.instance.stopTrackingElementDimensions("MessageComposer"); + UIStore.instance.removeListener("MessageComposer", this.onResize); } private onRoomStateEvents = (ev, state) => { @@ -360,6 +391,91 @@ export default class MessageComposer extends React.Component { } }; + private shouldShowStickerPicker = (): boolean => { + return SettingsStore.getValue(UIFeature.Widgets) + && SettingsStore.getValue("MessageComposerInput.showStickersButton") + && !this.state.haveRecording; + }; + + private showStickers = (showStickers: boolean) => { + this.setState({ showStickers }); + }; + + private toggleButtonMenu = (): void => { + this.setState({ + isMenuOpen: !this.state.isMenuOpen, + }); + }; + + private renderButtons(): JSX.Element | JSX.Element[] { + const buttons = []; + + let menuPosition; + if (this.ref.current) { + const contentRect = this.ref.current.getBoundingClientRect(); + menuPosition = alwaysAboveRightOf(contentRect); + } + + if (!this.state.haveRecording) { + buttons.push( + , + , + ); + } + if (this.shouldShowStickerPicker()) { + buttons.push( this.showStickers(!this.state.showStickers)} + title={this.state.showStickers ? _t("Hide Stickers") : _t("Show Stickers")} + />); + } + if (!this.state.haveRecording) { + buttons.push( + this.voiceRecordingButton?.onRecordStartEndClick()} + title={_t("Send voice message")} + />, + ); + } + + if (!this.state.narrowMode) { + return buttons; + } else { + const classnames = classNames({ + mx_MessageComposer_button: true, + mx_MessageComposer_buttonMenu: true, + mx_MessageComposer_closeButtonMenu: this.state.isMenuOpen, + }); + + return <> + { buttons[0] } + + { this.state.isMenuOpen && ( + + { buttons.slice(1).map((button, index) => ( + setTimeout(this.toggleButtonMenu, 500)}> + { button } + + )) } + + ) } + ; + } + } + render() { const controls = [ this.state.me ? : null, @@ -368,8 +484,6 @@ export default class MessageComposer extends React.Component { null, ]; - const buttons = []; - if (!this.state.tombstone && this.state.canSendMessages) { controls.push( { />, ); - if (!this.state.haveRecording) { - buttons.push( - , - , - ); - } - - if (SettingsStore.getValue(UIFeature.Widgets) && - SettingsStore.getValue("MessageComposerInput.showStickersButton") && - !this.state.haveRecording) { - buttons.push(); - } - controls.push( this.voiceRecordingButton = c} room={this.props.room} />); - - if (!this.state.haveRecording) { - buttons.push( - this.voiceRecordingButton?.onRecordStartEndClick()} - title={_t("Send voice message")} - />, - ); - } - - if (!this.state.isComposerEmpty || this.state.haveRecording) { - buttons.push( - , - ); - } } else if (this.state.tombstone) { const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; @@ -462,14 +543,30 @@ export default class MessageComposer extends React.Component { />; } + controls.push( + , + ); + + const showSendButton = !this.state.isComposerEmpty || this.state.haveRecording; + return ( -
+
{ recordingTooltip }
{ controls } - { buttons } + { this.renderButtons() } + { showSendButton && ( + + ) }
diff --git a/src/components/views/rooms/Stickerpicker.tsx b/src/components/views/rooms/Stickerpicker.tsx index 33367c1151..821fc350a6 100644 --- a/src/components/views/rooms/Stickerpicker.tsx +++ b/src/components/views/rooms/Stickerpicker.tsx @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ import React from 'react'; -import classNames from 'classnames'; import { Room } from 'matrix-js-sdk/src/models/room'; import { _t, _td } from '../../../languageHandler'; import AppTile from '../elements/AppTile'; @@ -27,7 +26,6 @@ import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import SettingsStore from "../../../settings/SettingsStore"; import { ChevronFace, ContextMenu } from "../../structures/ContextMenu"; import { WidgetType } from "../../../widgets/WidgetType"; -import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import { Action } from "../../../dispatcher/actions"; import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -44,10 +42,11 @@ const PERSISTED_ELEMENT_KEY = "stickerPicker"; interface IProps { room: Room; + showStickers: boolean; + setShowStickers: (showStickers: boolean) => void; } interface IState { - showStickers: boolean; imError: string; stickerpickerX: number; stickerpickerY: number; @@ -72,7 +71,6 @@ export default class Stickerpicker extends React.PureComponent { constructor(props: IProps) { super(props); this.state = { - showStickers: false, imError: null, stickerpickerX: null, stickerpickerY: null, @@ -114,7 +112,7 @@ export default class Stickerpicker extends React.PureComponent { console.warn('No widget ID specified, not disabling assets'); } - this.setState({ showStickers: false }); + this.props.setShowStickers(false); WidgetUtils.removeStickerpickerWidgets().then(() => { this.forceUpdate(); }).catch((e) => { @@ -146,15 +144,15 @@ export default class Stickerpicker extends React.PureComponent { } public componentDidUpdate(prevProps: IProps, prevState: IState): void { - this.sendVisibilityToWidget(this.state.showStickers); + this.sendVisibilityToWidget(this.props.showStickers); } private imError(errorMsg: string, e: Error): void { console.error(errorMsg, e); this.setState({ - showStickers: false, imError: _t(errorMsg), }); + this.props.setShowStickers(false); } private updateWidget = (): void => { @@ -194,12 +192,12 @@ export default class Stickerpicker extends React.PureComponent { this.forceUpdate(); break; case "stickerpicker_close": - this.setState({ showStickers: false }); + this.props.setShowStickers(false); break; case Action.AfterRightPanelPhaseChange: case "show_left_panel": case "hide_left_panel": - this.setState({ showStickers: false }); + this.props.setShowStickers(false); break; } }; @@ -338,8 +336,8 @@ export default class Stickerpicker extends React.PureComponent { const y = (buttonRect.top + (buttonRect.height / 2) + window.pageYOffset) - 19; + this.props.setShowStickers(true); this.setState({ - showStickers: true, stickerpickerX: x, stickerpickerY: y, stickerpickerChevronOffset, @@ -351,8 +349,8 @@ export default class Stickerpicker extends React.PureComponent { * @param {Event} ev Event that triggered the function call */ private onHideStickersClick = (ev: React.MouseEvent): void => { - if (this.state.showStickers) { - this.setState({ showStickers: false }); + if (this.props.showStickers) { + this.props.setShowStickers(false); } }; @@ -360,8 +358,8 @@ export default class Stickerpicker extends React.PureComponent { * Called when the window is resized */ private onResize = (): void => { - if (this.state.showStickers) { - this.setState({ showStickers: false }); + if (this.props.showStickers) { + this.props.setShowStickers(false); } }; @@ -369,8 +367,8 @@ export default class Stickerpicker extends React.PureComponent { * The stickers picker was hidden */ private onFinished = (): void => { - if (this.state.showStickers) { - this.setState({ showStickers: false }); + if (this.props.showStickers) { + this.props.setShowStickers(false); } }; @@ -395,54 +393,22 @@ export default class Stickerpicker extends React.PureComponent { }; public render(): JSX.Element { - let stickerPicker; - let stickersButton; - const className = classNames( - "mx_MessageComposer_button", - "mx_MessageComposer_stickers", - "mx_Stickers_hideStickers", - "mx_MessageComposer_button_highlight", - ); - if (this.state.showStickers) { - // Show hide-stickers button - stickersButton = - ; + if (!this.props.showStickers) return null; - stickerPicker = - - ; - } else { - // Show show-stickers button - stickersButton = - ; - } - return - { stickersButton } - { stickerPicker } - ; + return + + ; } } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index b9a6b5e04c..3d5973889f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1559,6 +1559,7 @@ "Send an encrypted message…": "Send an encrypted message…", "Send a message…": "Send a message…", "Send voice message": "Send voice message", + "view more options": "view more options", "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",