Files
ThreadNet-Web/src/components/views/rooms/MessageComposerButtons.tsx
T

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

364 lines
12 KiB
TypeScript
Raw Normal View History

/*
2024-09-09 14:57:16 +01:00
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 OR LicenseRef-Element-Commercial
2024-09-09 14:57:16 +01:00
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
2025-02-05 13:25:06 +00:00
import {
type IEventRelation,
type Room,
type MatrixClient,
THREAD_RELATION_TYPE,
M_POLL_START,
} from "matrix-js-sdk/src/matrix";
import React, { createContext, type ReactElement, type ReactNode, useContext, useRef } from "react";
import { _t } from "../../../languageHandler";
import { CollapsibleButton } from "./CollapsibleButton";
2025-02-05 13:25:06 +00:00
import { type 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";
2025-02-05 13:25:06 +00:00
import AccessibleButton, { type 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;
2022-10-26 10:42:18 +02:00
isRichTextEnabled: boolean;
2022-10-25 17:26:14 +02:00
onComposerModeClick: () => void;
}
type OverflowMenuCloser = () => void;
export const OverflowMenuContext = createContext<OverflowMenuCloser | null>(null);
const MessageComposerButtons: React.FC<IProps> = (props: IProps) => {
const matrixClient = useContext(MatrixClientContext);
const { room, narrow } = useScopedRoomContext("room", "narrow");
2024-12-23 20:25:15 +00:00
const isWysiwygLabEnabled = useSettingValue("feature_wysiwyg_composer");
if (!matrixClient || !room || props.haveRecording) {
2022-02-02 09:30:53 +00:00
return null;
}
let mainButtons: ReactNode[];
let moreButtons: ReactNode[];
if (narrow) {
2022-02-02 09:30:53 +00:00
mainButtons = [
isWysiwygLabEnabled ? (
<ComposerModeButton
key="composerModeButton"
isRichTextEnabled={props.isRichTextEnabled}
onClick={props.onComposerModeClick}
/>
) : (
emojiButton(props)
),
2022-02-02 09:30:53 +00:00
];
moreButtons = [
uploadButton(), // props passed via UploadButtonContext
2022-02-02 09:30:53 +00:00
showStickersButton(props),
voiceRecordingButton(props, narrow),
props.showPollsButton ? pollButton(room, props.relation) : null,
showLocationButton(props, room, matrixClient),
2022-02-02 09:30:53 +00:00
];
} else {
mainButtons = [
isWysiwygLabEnabled ? (
<ComposerModeButton
key="composerModeButton"
isRichTextEnabled={props.isRichTextEnabled}
onClick={props.onComposerModeClick}
/>
) : (
emojiButton(props)
),
uploadButton(), // props passed via UploadButtonContext
2022-02-02 09:30:53 +00:00
];
moreButtons = [
showStickersButton(props),
voiceRecordingButton(props, narrow),
props.showPollsButton ? pollButton(room, props.relation) : null,
showLocationButton(props, room, matrixClient),
2022-02-02 09:30:53 +00:00
];
}
mainButtons = filterBoolean(mainButtons);
moreButtons = filterBoolean(moreButtons);
2022-01-31 09:19:26 +00:00
const moreOptionsClasses = classNames({
mx_MessageComposer_button: true,
mx_MessageComposer_buttonMenu: true,
mx_MessageComposer_closeButtonMenu: props.isMenuOpen,
});
return (
<UploadButtonContextProvider roomId={room.roomId} relation={props.relation}>
2022-02-02 09:30:53 +00:00
{mainButtons}
{moreButtons.length > 0 && (
<AccessibleButton
2022-01-31 09:19:26 +00:00
className={moreOptionsClasses}
onClick={props.toggleButtonMenu}
title={_t("quick_settings|sidebar_settings")}
/>
)}
{props.isMenuOpen && (
<IconizedContextMenu
onFinished={props.toggleButtonMenu}
{...props.menuPosition}
wrapperClassName="mx_MessageComposer_Menu"
compact={true}
>
<OverflowMenuContext.Provider value={props.toggleButtonMenu}>
<IconizedContextMenuOptionList>{moreButtons}</IconizedContextMenuOptionList>
</OverflowMenuContext.Provider>
</IconizedContextMenu>
)}
</UploadButtonContextProvider>
);
2022-02-02 09:30:53 +00:00
};
2022-01-31 09:19:26 +00:00
function emojiButton(props: IProps): ReactElement {
return (
<EmojiButton
key="emoji_button"
addEmoji={props.addEmoji}
menuPosition={props.menuPosition}
className="mx_MessageComposer_button"
2022-01-31 09:19:26 +00:00
/>
);
}
function uploadButton(): ReactElement {
return <UploadButton key="controls_upload" />;
2022-01-31 09:19:26 +00:00
}
type UploadButtonFn = () => void;
export const UploadButtonContext = createContext<UploadButtonFn | null>(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<IUploadButtonProps> = ({ roomId, relation, children }) => {
const cli = useContext(MatrixClientContext);
const roomContext = useScopedRoomContext("timelineRenderingType");
const uploadInput = useRef<HTMLInputElement>(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<HTMLInputElement>): 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 (
<UploadButtonContext.Provider value={onUploadClick}>
{children}
<input
ref={uploadInput}
type="file"
style={uploadInputStyle}
multiple
onClick={chromeFileInputFix}
onChange={onUploadFileInputChange}
/>
</UploadButtonContext.Provider>
);
};
// 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 (
<CollapsibleButton
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_upload"
onClick={onClick}
title={_t("common|attachment")}
/>
);
};
2022-01-31 09:19:26 +00:00
function showStickersButton(props: IProps): ReactElement | null {
2022-01-31 09:19:26 +00:00
return props.showStickersButton ? (
<CollapsibleButton
2022-01-31 09:19:26 +00:00
id="stickersButton"
key="controls_stickers"
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_stickers"
2022-01-31 09:19:26 +00:00
onClick={() => props.setStickerPickerOpen(!props.isStickerPickerOpen)}
title={props.isStickerPickerOpen ? _t("composer|close_sticker_picker") : _t("common|sticker")}
2022-01-31 09:19:26 +00:00
/>
) : null;
}
function voiceRecordingButton(props: IProps, narrow: boolean): ReactElement | null {
2022-01-31 09:19:26 +00:00
// XXX: recording UI does not work well in narrow mode, so hide for now
return narrow ? null : (
<CollapsibleButton
key="voice_message_send"
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_voiceMessage"
2022-01-31 09:19:26 +00:00
onClick={props.onRecordStartEndClick}
title={_t("composer|voice_message_button")}
2022-01-31 09:19:26 +00:00
/>
);
}
2022-02-02 17:55:41 +00:00
function pollButton(room: Room, relation?: IEventRelation): ReactElement {
return <PollButton key="polls" room={room} relation={relation} />;
2022-01-31 09:19:26 +00:00
}
interface IPollButtonProps {
room: Room;
2022-02-02 17:55:41 +00:00
relation?: IEventRelation;
}
class PollButton extends React.PureComponent<IPollButtonProps> {
public static contextType = OverflowMenuContext;
2024-12-02 09:39:36 +00:00
declare public context: React.ContextType<typeof OverflowMenuContext>;
private onCreateClick = (): void => {
this.context?.(); // close overflow menu
const canSend = this.props.room.currentState.maySendEvent(
M_POLL_START.name,
MatrixClientPeg.safeGet().getSafeUserId(),
);
if (!canSend) {
2022-06-14 17:51:51 +01:00
Modal.createDialog(ErrorDialog, {
title: _t("composer|poll_button_no_perms_title"),
description: _t("composer|poll_button_no_perms_description"),
2022-01-31 09:19:26 +00:00
});
} else {
const threadId =
this.props.relation?.rel_type === THREAD_RELATION_TYPE.name ? this.props.relation.event_id : undefined;
2022-02-02 17:55:41 +00:00
2022-06-14 17:51:51 +01:00
Modal.createDialog(
PollCreateDialog,
{
room: this.props.room,
2022-02-02 17:55:41 +00:00
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 (
<CollapsibleButton
className="mx_MessageComposer_button"
iconClassName="mx_MessageComposer_poll"
onClick={this.onCreateClick}
title={_t("composer|poll_button")}
/>
);
}
}
function showLocationButton(props: IProps, room: Room, matrixClient: MatrixClient): ReactElement | null {
const sender = room.getMember(matrixClient.getSafeUserId());
return props.showLocationButton && sender ? (
2022-01-31 09:19:26 +00:00
<LocationButton
key="location"
roomId={room.roomId}
relation={props.relation}
sender={sender}
2022-01-31 09:19:26 +00:00
menuPosition={props.menuPosition}
/>
) : null;
}
2022-10-25 17:26:14 +02:00
interface WysiwygToggleButtonProps {
2022-10-26 10:42:18 +02:00
isRichTextEnabled: boolean;
onClick: (ev: ButtonEvent) => void;
2022-10-25 17:26:14 +02:00
}
function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonProps): JSX.Element {
const title = isRichTextEnabled ? _t("composer|mode_plain") : _t("composer|mode_rich_text");
2022-10-26 10:42:18 +02:00
2022-10-25 17:26:14 +02:00
return (
<CollapsibleButton
className="mx_MessageComposer_button"
iconClassName={classNames({
2023-01-10 15:34:17 +01:00
mx_MessageComposer_plain_text: !isRichTextEnabled,
mx_MessageComposer_rich_text: isRichTextEnabled,
2022-10-25 17:26:14 +02:00
})}
onClick={onClick}
2022-10-26 10:42:18 +02:00
title={title}
2022-10-25 17:26:14 +02:00
/>
);
}
export default MessageComposerButtons;