/* Copyright 2024 New Vector Ltd. Copyright 2022 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; import { IEventRelation, Room, MatrixClient, THREAD_RELATION_TYPE, M_POLL_START } from "matrix-js-sdk/src/matrix"; import React, { createContext, ReactElement, ReactNode, useContext, useRef } from "react"; import { _t } from "../../../languageHandler"; import { CollapsibleButton } from "./CollapsibleButton"; import { MenuProps } from "../../structures/ContextMenu"; import dis from "../../../dispatcher/dispatcher"; import ErrorDialog from "../dialogs/ErrorDialog"; import { LocationButton } from "../location"; import Modal from "../../../Modal"; import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import ContentMessages from "../../../ContentMessages"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useDispatcher } from "../../../hooks/useDispatcher"; import { chromeFileInputFix } from "../../../utils/BrowserWorkarounds"; import IconizedContextMenu, { IconizedContextMenuOptionList } from "../context_menus/IconizedContextMenu"; import { EmojiButton } from "./EmojiButton"; import { filterBoolean } from "../../../utils/arrays"; import { useSettingValue } from "../../../hooks/useSettings"; import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext.tsx"; interface IProps { addEmoji: (emoji: string) => boolean; haveRecording: boolean; isMenuOpen: boolean; isStickerPickerOpen: boolean; menuPosition?: MenuProps; onRecordStartEndClick: () => void; relation?: IEventRelation; setStickerPickerOpen: (isStickerPickerOpen: boolean) => void; showLocationButton: boolean; showPollsButton: boolean; showStickersButton: boolean; toggleButtonMenu: () => void; isRichTextEnabled: boolean; onComposerModeClick: () => void; } type OverflowMenuCloser = () => void; export const OverflowMenuContext = createContext(null); const MessageComposerButtons: React.FC = (props: IProps) => { const matrixClient = useContext(MatrixClientContext); const { room, narrow } = useScopedRoomContext("room", "narrow"); const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer"); if (!matrixClient || !room || props.haveRecording) { return null; } let mainButtons: ReactNode[]; let moreButtons: ReactNode[]; if (narrow) { mainButtons = [ isWysiwygLabEnabled ? ( ) : ( emojiButton(props) ), ]; moreButtons = [ uploadButton(), // props passed via UploadButtonContext showStickersButton(props), voiceRecordingButton(props, narrow), props.showPollsButton ? pollButton(room, props.relation) : null, showLocationButton(props, room, matrixClient), ]; } else { mainButtons = [ isWysiwygLabEnabled ? ( ) : ( emojiButton(props) ), uploadButton(), // props passed via UploadButtonContext ]; moreButtons = [ showStickersButton(props), voiceRecordingButton(props, narrow), props.showPollsButton ? pollButton(room, props.relation) : null, showLocationButton(props, room, matrixClient), ]; } mainButtons = filterBoolean(mainButtons); moreButtons = filterBoolean(moreButtons); const moreOptionsClasses = classNames({ mx_MessageComposer_button: true, mx_MessageComposer_buttonMenu: true, mx_MessageComposer_closeButtonMenu: props.isMenuOpen, }); return ( {mainButtons} {moreButtons.length > 0 && ( )} {props.isMenuOpen && ( {moreButtons} )} ); }; function emojiButton(props: IProps): ReactElement { return ( ); } function uploadButton(): ReactElement { return ; } type UploadButtonFn = () => void; export const UploadButtonContext = createContext(null); interface IUploadButtonProps { roomId: string; relation?: IEventRelation; children: ReactNode; } // We put the file input outside the UploadButton component so that it doesn't get killed when the context menu closes. const UploadButtonContextProvider: React.FC = ({ roomId, relation, children }) => { const cli = useContext(MatrixClientContext); const roomContext = useScopedRoomContext("timelineRenderingType"); const uploadInput = useRef(null); const onUploadClick = (): void => { if (cli?.isGuest()) { dis.dispatch({ action: "require_registration" }); return; } uploadInput.current?.click(); }; useDispatcher(dis, (payload) => { if (roomContext.timelineRenderingType === payload.context && payload.action === "upload_file") { onUploadClick(); } }); const onUploadFileInputChange = (ev: React.ChangeEvent): void => { if (ev.target.files?.length === 0) return; // Take a copy, so we can safely reset the value of the form control ContentMessages.sharedInstance().sendContentListToRoom( Array.from(ev.target.files!), roomId, relation, cli, roomContext.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 = ""; }; const uploadInputStyle = { display: "none" }; return ( {children} ); }; // Must be rendered within an UploadButtonContextProvider const UploadButton: React.FC = () => { const overflowMenuCloser = useContext(OverflowMenuContext); const uploadButtonFn = useContext(UploadButtonContext); const onClick = (): void => { uploadButtonFn?.(); overflowMenuCloser?.(); // close overflow menu }; return ( ); }; function showStickersButton(props: IProps): ReactElement | null { return props.showStickersButton ? ( props.setStickerPickerOpen(!props.isStickerPickerOpen)} title={props.isStickerPickerOpen ? _t("composer|close_sticker_picker") : _t("common|sticker")} /> ) : null; } function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement | null { // XXX: recording UI does not work well in narrow mode, so hide for now return narrow ? null : ( ); } function pollButton(room: Room, relation?: IEventRelation): ReactElement { return ; } interface IPollButtonProps { room: Room; relation?: IEventRelation; } class PollButton extends React.PureComponent { public static contextType = OverflowMenuContext; declare public context: React.ContextType; private onCreateClick = (): void => { this.context?.(); // close overflow menu const canSend = this.props.room.currentState.maySendEvent( M_POLL_START.name, MatrixClientPeg.safeGet().getSafeUserId(), ); if (!canSend) { Modal.createDialog(ErrorDialog, { title: _t("composer|poll_button_no_perms_title"), description: _t("composer|poll_button_no_perms_description"), }); } else { const threadId = this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : undefined; Modal.createDialog( PollCreateDialog, { room: this.props.room, threadId, }, "mx_CompoundDialog", false, // isPriorityModal true, // isStaticModal ); } }; public render(): React.ReactNode { // do not allow sending polls within threads at this time if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) return null; return ( ); } } function showLocationButton(props: IProps, room: Room, matrixClient: MatrixClient): ReactElement | null { const sender = room.getMember(matrixClient.getSafeUserId()); return props.showLocationButton && sender ? ( ) : null; } interface WysiwygToggleButtonProps { isRichTextEnabled: boolean; onClick: (ev: ButtonEvent) => void; } function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps): JSX.Element { const title = isRichTextEnabled ? _t("composer|mode_plain") : _t("composer|mode_rich_text"); return ( ); } export default MessageComposerButtons;