CI / Build & Quality Checks (push) Successful in 1m52s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 10m45s
When a multi-line paste looks like code, a chip above the composer asks "That looks like code. Format it as a code block (js)?" with Format as code / No thanks. It never converts on its own; typing, No, or 8 s dismiss it, and Settings → Editor → "Offer to Format Pasted Code" turns it off. - utils/looksLikeCode.ts (pure, tested): ≥ 3 lines and ≥ 2 of indentation with depth changes, statement terminators, operators + brackets, keywords, monospace/<pre> clipboard HTML, or SQL clause lines. Prose guards: quoted replies, URL lists, long sentence-punctuated lines, plain-word lines, markdown lists. Language guess only when fairly sure (js/ts/python/sql/ rust/c/php). - Accept rebuilds the pasted paragraphs as one code block from their plain text (code lines hold text only) and leaves the caret after it. - Code blocks carry an optional lang → <code class="language-js"> (whitelisted identifier only). Verified in Chromium: pasting a JS function shows the chip; Format as code → sent formatted_body is <pre><code class="language-js"> with indentation intact; a three-line prose paste shows no chip; typing after a paste dismisses it. Unit tests: 14 detector fixtures + 2 output tests; chromium e2e 19 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
1769 lines
68 KiB
TypeScript
1769 lines
68 KiB
TypeScript
import React, {
|
||
KeyboardEventHandler,
|
||
ReactNode,
|
||
RefObject,
|
||
forwardRef,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||
import { isKeyHotkey } from 'is-hotkey';
|
||
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||
import { ReactEditor } from 'slate-react';
|
||
import { Transforms, Editor, Element as SlateElement, Node as SlateNode, Range } from 'slate';
|
||
import {
|
||
Button,
|
||
Box,
|
||
Dialog,
|
||
Icon,
|
||
IconButton,
|
||
Icons,
|
||
Line,
|
||
Overlay,
|
||
OverlayBackdrop,
|
||
OverlayCenter,
|
||
PopOut,
|
||
Scroll,
|
||
Spinner,
|
||
Text,
|
||
color,
|
||
config,
|
||
toRem,
|
||
} from 'folds';
|
||
|
||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||
import {
|
||
CustomEditor,
|
||
Toolbar,
|
||
toMatrixCustomHTML,
|
||
toPlainText,
|
||
AUTOCOMPLETE_PREFIXES,
|
||
AutocompletePrefix,
|
||
AutocompleteQuery,
|
||
getAutocompleteQuery,
|
||
getPrevWordRange,
|
||
resetEditor,
|
||
RoomMentionAutocomplete,
|
||
UserMentionAutocomplete,
|
||
EmoticonAutocomplete,
|
||
createEmoticonElement,
|
||
moveCursor,
|
||
resetEditorHistory,
|
||
customHtmlEqualsPlainText,
|
||
trimCustomHtml,
|
||
isEmptyEditor,
|
||
getBeginCommand,
|
||
trimCommand,
|
||
getMentions,
|
||
BlockType,
|
||
} from '../../components/editor';
|
||
import { EmojiBoardTab } from '../../components/emoji-board/types';
|
||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||
import {
|
||
TUploadContent,
|
||
encryptFile,
|
||
getImageInfo,
|
||
mxcUrlToHttp,
|
||
tryDeleteMxcContent,
|
||
} from '../../utils/matrix';
|
||
import { compressImage, isCompressible } from '../../utils/imageCompression';
|
||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||
import { useFileDropZone } from '../../hooks/useFileDrop';
|
||
import {
|
||
TUploadItem,
|
||
TUploadMetadata,
|
||
roomIdToMsgDraftAtomFamily,
|
||
roomIdToReplyDraftAtomFamily,
|
||
roomIdToUploadItemsAtomFamily,
|
||
roomUploadAtomFamily,
|
||
} from '../../state/room/roomInputDrafts';
|
||
import { UploadCardRenderer } from '../../components/upload-card';
|
||
import {
|
||
UploadBoard,
|
||
UploadBoardContent,
|
||
UploadBoardHeader,
|
||
UploadBoardImperativeHandlers,
|
||
} from '../../components/upload-board';
|
||
import {
|
||
Upload,
|
||
UploadStatus,
|
||
UploadSuccess,
|
||
createUploadFamilyObserverAtom,
|
||
} from '../../state/upload';
|
||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||
import { filesToUploadItems } from '../../utils/uploadItems';
|
||
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
|
||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||
import { useSetting } from '../../state/hooks/settings';
|
||
import { looksLikeCode } from '../../utils/looksLikeCode';
|
||
import { useAlive } from '../../hooks/useAlive';
|
||
import {
|
||
ComposerToolbarButtonKey,
|
||
normalizeComposerToolbarOrder,
|
||
settingsAtom,
|
||
} from '../../state/settings';
|
||
import {
|
||
buildCompressedUploadItem,
|
||
getAudioMsgContent,
|
||
getFileMsgContent,
|
||
getImageMsgContent,
|
||
getVideoMsgContent,
|
||
} from './msgContent';
|
||
import {
|
||
getMemberName,
|
||
getMentionContent,
|
||
sendRoomMessage,
|
||
trimReplyFromBody,
|
||
} from '../../utils/room';
|
||
import { CommandAutocomplete } from './CommandAutocomplete';
|
||
import {
|
||
Command,
|
||
CommandError,
|
||
SHRUG,
|
||
TABLEFLIP,
|
||
UNFLIP,
|
||
useCommands,
|
||
} from '../../hooks/useCommands';
|
||
import { mobileOrTablet } from '../../utils/user-agent';
|
||
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
||
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||
import colorMXID from '../../../util/colorMXID';
|
||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||
import { useTheme } from '../../hooks/useTheme';
|
||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
||
import { PollCreator } from './PollCreator';
|
||
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||
import { DraftIndicator } from './DraftIndicator';
|
||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||
import { getThreadDraftKey } from '../../state/room/thread';
|
||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||
|
||
const GifPicker = React.lazy(() =>
|
||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||
);
|
||
const EmojiBoard = React.lazy(() =>
|
||
import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })),
|
||
);
|
||
|
||
/** [Gitea #37] Debounce for persisting the composer draft while typing. */
|
||
/** [Gitea #129] Same picked file, allowing for re-wrapped File objects. */
|
||
const isAutoFocusTarget = (file: TUploadContent, target: File | undefined): boolean =>
|
||
!!target &&
|
||
file instanceof File &&
|
||
file.name === target.name &&
|
||
file.lastModified === target.lastModified;
|
||
|
||
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
||
|
||
interface RoomInputProps {
|
||
editor: Editor;
|
||
fileDropContainerRef: RefObject<HTMLElement>;
|
||
roomId: string;
|
||
room: Room;
|
||
threadRootId?: string;
|
||
// Identifies this composer to global key handlers (e.g. the up-arrow "edit last
|
||
// message" handler). Threads pass a distinct name so the main timeline's handler
|
||
// doesn't fire for the thread composer, and vice-versa.
|
||
editableName?: string;
|
||
// [Gitea #218] Force the one-row "+ | input | emoji | send" layout regardless
|
||
// of viewport — the 360 px thread panel can't fit the full toolbar.
|
||
compactLayout?: boolean;
|
||
}
|
||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||
(
|
||
{
|
||
editor,
|
||
fileDropContainerRef,
|
||
roomId,
|
||
room,
|
||
threadRootId,
|
||
editableName = 'RoomInput',
|
||
compactLayout = false,
|
||
},
|
||
ref,
|
||
) => {
|
||
const mx = useMatrixClient();
|
||
const useAuthentication = useMediaAuthentication();
|
||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||
const [warnOnUnverifiedDevices] = useSetting(settingsAtom, 'warnOnUnverifiedDevices');
|
||
const crypto = mx.getCrypto();
|
||
const roomUnverifiedDeviceCount = useRoomUnverifiedDeviceCount(crypto, room);
|
||
const isEncrypted = room.hasEncryptionStateEvent();
|
||
const showUnverifiedWarning =
|
||
warnOnUnverifiedDevices &&
|
||
isEncrypted &&
|
||
roomUnverifiedDeviceCount !== undefined &&
|
||
roomUnverifiedDeviceCount > 0;
|
||
const direct = useIsDirectRoom();
|
||
const commands = useCommands(mx, room);
|
||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||
const powerLevels = usePowerLevelsContext();
|
||
const creators = useRoomCreators(room);
|
||
|
||
const [charCount, setCharCount] = useState(0);
|
||
useEffect(() => {
|
||
setCharCount(0);
|
||
}, [roomId]);
|
||
const [pollOpen, setPollOpen] = useState(false);
|
||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
|
||
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
||
const setToast = useSetAtom(toastQueueAtom);
|
||
|
||
const alive = useAlive();
|
||
// Scope drafts/replies/uploads by thread so a thread composer stays fully
|
||
// isolated from the main room composer (and from other threads).
|
||
const draftKey = threadRootId ? getThreadDraftKey(roomId, threadRootId) : roomId;
|
||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
|
||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
|
||
const replyUserID = replyDraft?.userId;
|
||
// [Gitea #151] The quoted event, for a media thumbnail in the draft preview.
|
||
const replyDraftEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined;
|
||
|
||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||
const creatorsTag = useRoomCreatorsTag();
|
||
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
||
const theme = useTheme();
|
||
const accessibleTagColors = useAccessiblePowerTagColors(
|
||
theme.kind,
|
||
creatorsTag,
|
||
powerLevelTags,
|
||
);
|
||
|
||
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
|
||
const replyPowerColor = replyPowerTag?.color
|
||
? accessibleTagColors.get(replyPowerTag.color)
|
||
: undefined;
|
||
const replyUsernameColor =
|
||
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||
|
||
const [uploadBoard, setUploadBoard] = useState(true);
|
||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
|
||
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
||
roomUploadAtomFamily,
|
||
selectedFiles.map((f) => f.file),
|
||
);
|
||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(undefined);
|
||
// [Gitea #129] The file whose caption input should take focus once its card mounts.
|
||
// (Matched by name + mtime: the metadata strip and safeFile may re-wrap the File.)
|
||
const autoFocusCaptionRef = useRef<File | undefined>(undefined);
|
||
|
||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||
|
||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
||
const isMobile = mobileOrTablet();
|
||
const screenSize = useScreenSizeContext();
|
||
// Compact composer: the secondary action buttons (attach, GIF, poll,
|
||
// location, voice, formatting, schedule) collapse behind a "+" toggle so the
|
||
// input stays ONE row — [ + | input | emoji | send ]. Keyed on the viewport,
|
||
// not only the user agent: a phone-width window on a desktop UA (iPad in
|
||
// desktop mode, a split-screen PWA, a docked window) otherwise renders all
|
||
// ten controls inline and clips the Send button behind the editor's
|
||
// overflow:hidden. Touch-sized (44px) targets stay UA-gated: a narrow
|
||
// desktop window still has a mouse.
|
||
const compact = isMobile || screenSize === ScreenSize.Mobile || compactLayout;
|
||
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||
// Both gates on purpose: the class covers phone-width viewports (matches
|
||
// VoiceMessageRecorder's idle button, which lives in the same row), the
|
||
// inline style covers touch UAs with a wider viewport (tablet landscape).
|
||
const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||
// [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the
|
||
// toolbar button entirely when it's off so it never opens an empty popover.
|
||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
|
||
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||
// [Gitea #107] "Paste as code block?" offer for the top-level blocks a
|
||
// paste just filled. Dismissed by typing, by No, or after 8 s.
|
||
const [offerCodePaste] = useSetting(settingsAtom, 'offerCodePaste');
|
||
const [codeOffer, setCodeOffer] = useState<{ start: number; end: number; lang?: string }>();
|
||
useEffect(() => {
|
||
if (!codeOffer) return undefined;
|
||
const t = window.setTimeout(() => setCodeOffer(undefined), 8000);
|
||
return () => window.clearTimeout(t);
|
||
}, [codeOffer]);
|
||
const acceptCodeOffer = useCallback(() => {
|
||
if (!codeOffer) return;
|
||
const { start, end, lang } = codeOffer;
|
||
setCodeOffer(undefined);
|
||
if (end >= editor.children.length || start > end) return;
|
||
const blocks = editor.children.slice(start, end + 1);
|
||
// Only plain paragraphs (what a paste produces); anything else, leave it.
|
||
if (blocks.some((b) => !SlateElement.isElement(b) || b.type !== BlockType.Paragraph)) return;
|
||
// Rebuild from plain text: code lines hold text only, and a paste may
|
||
// have produced links/mentions.
|
||
const lines = blocks.map((b) => SlateNode.string(b));
|
||
Editor.withoutNormalizing(editor, () => {
|
||
for (let i = end; i >= start; i -= 1) Transforms.removeNodes(editor, { at: [i] });
|
||
Transforms.insertNodes(
|
||
editor,
|
||
{
|
||
type: BlockType.CodeBlock,
|
||
lang,
|
||
children: lines.map((text) => ({ type: BlockType.CodeLine, children: [{ text }] })),
|
||
},
|
||
{ at: [start] },
|
||
);
|
||
if (start + 1 >= editor.children.length) {
|
||
Transforms.insertNodes(
|
||
editor,
|
||
{ type: BlockType.Paragraph, children: [{ text: '' }] },
|
||
{ at: [start + 1] },
|
||
);
|
||
}
|
||
});
|
||
Transforms.select(editor, Editor.start(editor, [start + 1]));
|
||
ReactEditor.focus(editor);
|
||
}, [codeOffer, editor]);
|
||
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||
// Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms:
|
||
// MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the
|
||
// SDK's encryption pipeline, so scheduling in an E2EE room would leak the body.
|
||
const showSchedule =
|
||
(composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted;
|
||
const composerButtonOrder = useMemo(
|
||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||
[composerToolbarButtons?.order],
|
||
);
|
||
const [locating, setLocating] = React.useState(false);
|
||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||
const handleShareLocation = useCallback(() => {
|
||
if (!navigator.geolocation) {
|
||
setLocationError('Geolocation not supported.');
|
||
setTimeout(() => setLocationError(null), 4000);
|
||
return;
|
||
}
|
||
setLocating(true);
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
setLocating(false);
|
||
const { latitude, longitude } = pos.coords;
|
||
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
||
const ts = Date.now();
|
||
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
|
||
// m.location/m.asset/m.ts blocks so Element and other clients render a
|
||
// proper "shared location" pin instead of falling back to plain text.
|
||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||
msgtype: 'm.location',
|
||
body: `Shared a location: ${geoUri}`,
|
||
geo_uri: geoUri,
|
||
'org.matrix.msc3488.location': { uri: geoUri },
|
||
'org.matrix.msc3488.asset': { type: 'm.self' },
|
||
'org.matrix.msc3488.ts': ts,
|
||
'm.ts': ts,
|
||
});
|
||
},
|
||
(err) => {
|
||
setLocating(false);
|
||
const msg =
|
||
err.code === 1
|
||
? 'Location access denied.'
|
||
: err.code === 3
|
||
? 'Location timed out.'
|
||
: 'Failed to get location.';
|
||
setLocationError(msg);
|
||
setTimeout(() => setLocationError(null), 4000);
|
||
},
|
||
{ timeout: 10000 },
|
||
);
|
||
}, [mx, roomId, threadRootId]);
|
||
|
||
const handleVoiceSend = useCallback(
|
||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||
const baseContent: IContent = {
|
||
msgtype: MsgType.Audio,
|
||
body: 'Voice message',
|
||
filename: 'voice-message.ogg',
|
||
'org.matrix.msc3245.voice': {},
|
||
'org.matrix.msc1767.audio': { duration: durationMs, waveform },
|
||
info: { mimetype: mimeType, size: blob.size, duration: durationMs },
|
||
};
|
||
|
||
if (room.hasEncryptionStateEvent()) {
|
||
const { encInfo, file: encBlob } = await encryptFile(blob);
|
||
const uploadResult = await mx.uploadContent(encBlob);
|
||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||
...baseContent,
|
||
file: { ...encInfo, url: uploadResult.content_uri },
|
||
});
|
||
} else {
|
||
const uploadResult = await mx.uploadContent(blob, {
|
||
name: 'voice-message.ogg',
|
||
type: mimeType,
|
||
});
|
||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||
...baseContent,
|
||
url: uploadResult.content_uri,
|
||
});
|
||
}
|
||
},
|
||
[mx, room, roomId, threadRootId],
|
||
);
|
||
|
||
const [autocompleteQuery, setAutocompleteQuery] =
|
||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||
|
||
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
|
||
|
||
const handleFiles = useCallback(
|
||
async (files: File[]) => {
|
||
setUploadBoard(true);
|
||
// [Gitea #129] One image into an empty composer: land in its caption
|
||
// field so "here's the bug" is one motion (Enter sends, Esc returns).
|
||
const focusCaption =
|
||
files.length === 1 && files[0].type.startsWith('image/') && isEmptyEditor(editor);
|
||
setSelectedFiles({
|
||
type: 'PUT',
|
||
item: await filesToUploadItems(room, files, stripImageMetadata),
|
||
});
|
||
if (focusCaption) autoFocusCaptionRef.current = files[0];
|
||
},
|
||
[setSelectedFiles, room, stripImageMetadata, editor],
|
||
);
|
||
const pickFile = useFilePicker(handleFiles, true);
|
||
const handleFilePaste = useFilePasteHandler(handleFiles);
|
||
const handlePaste = useCallback<React.ClipboardEventHandler>(
|
||
(evt) => {
|
||
handleFilePaste(evt);
|
||
if (evt.defaultPrevented) return;
|
||
if (offerCodePaste && editor.selection) {
|
||
const pasted = evt.clipboardData?.getData('text/plain') ?? '';
|
||
const startBlock = Range.start(editor.selection).path[0];
|
||
const startNode = editor.children[startBlock];
|
||
const inCode =
|
||
SlateElement.isElement(startNode) && startNode.type === BlockType.CodeBlock;
|
||
const guess =
|
||
!inCode && pasted
|
||
? looksLikeCode(pasted, evt.clipboardData?.getData('text/html'))
|
||
: undefined;
|
||
if (guess?.isCode) {
|
||
// Slate inserts after this handler returns; read where it ended.
|
||
window.setTimeout(() => {
|
||
const endBlock = editor.selection ? Range.end(editor.selection).path[0] : startBlock;
|
||
setCodeOffer({ start: startBlock, end: endBlock, lang: guess.lang });
|
||
}, 0);
|
||
}
|
||
}
|
||
if (!stripTracking) return;
|
||
const text = evt.clipboardData?.getData('text/plain');
|
||
if (!text) return;
|
||
const cleaned = stripTrackingParamsInText(text);
|
||
if (cleaned === text) return;
|
||
// Re-run Slate's own plain-text insertion with the cleaned string so
|
||
// multi-line pastes still split into paragraphs exactly as before.
|
||
if (typeof DataTransfer === 'undefined') return;
|
||
evt.preventDefault();
|
||
const dt = new DataTransfer();
|
||
dt.setData('text/plain', cleaned);
|
||
ReactEditor.insertData(editor, dt);
|
||
},
|
||
[handleFilePaste, stripTracking, editor, offerCodePaste],
|
||
);
|
||
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
||
const { gifApiKey } = useClientConfig();
|
||
const gifBtnRef = useRef<HTMLButtonElement>(null);
|
||
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
|
||
const [gifError, setGifError] = React.useState<string | null>(null);
|
||
const [gifUploading, setGifUploading] = React.useState(false);
|
||
|
||
const isComposing = useComposingCheck();
|
||
|
||
useElementSizeObserver(
|
||
useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]),
|
||
useCallback((width) => setHideStickerBtn(width < 500), []),
|
||
);
|
||
|
||
const didRestoreDraft = React.useRef(false);
|
||
useEffect(() => {
|
||
if (didRestoreDraft.current) return;
|
||
didRestoreDraft.current = true;
|
||
if (msgDraft.length > 0) {
|
||
Transforms.insertFragment(editor, msgDraft);
|
||
} else {
|
||
// Jotai draft is empty (page reload) — try localStorage fallback
|
||
try {
|
||
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
|
||
if (stored) {
|
||
const parsed = JSON.parse(stored);
|
||
// [Gitea #41] Only restore a draft this same account wrote. A legacy
|
||
// draft (stored as a bare array, pre-dating user-scoping) or one
|
||
// written by a different userId is foreign — drop it rather than
|
||
// risk pre-filling another account's unsent text into the composer.
|
||
const foreign =
|
||
!parsed ||
|
||
typeof parsed !== 'object' ||
|
||
Array.isArray(parsed) ||
|
||
parsed.userId !== mx.getUserId();
|
||
if (foreign) {
|
||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||
} else {
|
||
const nodes = parsed.nodes;
|
||
if (Array.isArray(nodes) && nodes.length > 0) {
|
||
Transforms.insertFragment(editor, nodes);
|
||
// Mirror the restored draft into the atom so the draft indicator
|
||
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||
// after a page reload — not only on same-session room re-entry.
|
||
setMsgDraft(nodes);
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// Ignore malformed stored draft
|
||
}
|
||
}
|
||
}, [editor, msgDraft, draftKey, setMsgDraft, mx]);
|
||
|
||
// Persist the current editor state to the draft atom + localStorage; an empty
|
||
// editor clears both so the draft indicators never claim a draft that isn't there.
|
||
const persistDraft = useCallback(() => {
|
||
if (!isEmptyEditor(editor)) {
|
||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||
setMsgDraft(parsedDraft);
|
||
// [Gitea #41] Tag the persisted draft with the writing user's id so a
|
||
// different account logging into this browser can't have it hydrated
|
||
// into their composer (see useHydrateMsgDrafts / clearPlaintextCaches).
|
||
localStorage.setItem(
|
||
`draft-msg-${draftKey}`,
|
||
JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }),
|
||
);
|
||
} else {
|
||
setMsgDraft([]);
|
||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||
}
|
||
}, [draftKey, editor, setMsgDraft, mx]);
|
||
|
||
// [Gitea #37] Drafts used to be written only in the unmount cleanup below, so
|
||
// a reload/tab-close in the open room lost them (and DraftIndicator never
|
||
// showed for the room being typed in). Debounce a persist while typing and
|
||
// flush it on pagehide; the cleanup still persists on unmount / draftKey change.
|
||
const persistDraftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const schedulePersistDraft = useCallback(() => {
|
||
if (persistDraftTimer.current) clearTimeout(persistDraftTimer.current);
|
||
persistDraftTimer.current = setTimeout(() => {
|
||
persistDraftTimer.current = null;
|
||
persistDraft();
|
||
}, DRAFT_PERSIST_DEBOUNCE_MS);
|
||
}, [persistDraft]);
|
||
|
||
useEffect(() => {
|
||
const flush = () => {
|
||
if (persistDraftTimer.current) {
|
||
clearTimeout(persistDraftTimer.current);
|
||
persistDraftTimer.current = null;
|
||
}
|
||
persistDraft();
|
||
};
|
||
window.addEventListener('pagehide', flush);
|
||
return () => {
|
||
window.removeEventListener('pagehide', flush);
|
||
flush();
|
||
resetEditor(editor);
|
||
resetEditorHistory(editor);
|
||
};
|
||
}, [editor, persistDraft]);
|
||
|
||
const handleFileMetadata = useCallback(
|
||
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
|
||
setSelectedFiles({
|
||
type: 'REPLACE',
|
||
item: fileItem,
|
||
replacement: { ...fileItem, metadata },
|
||
});
|
||
},
|
||
[setSelectedFiles],
|
||
);
|
||
|
||
const handleRemoveUpload = useCallback(
|
||
(upload: TUploadContent | TUploadContent[]) => {
|
||
const uploads = Array.isArray(upload) ? upload : [upload];
|
||
setSelectedFiles({
|
||
type: 'DELETE',
|
||
item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
|
||
});
|
||
uploads.forEach((u) => roomUploadAtomFamily.remove(u));
|
||
},
|
||
[setSelectedFiles, selectedFiles],
|
||
);
|
||
|
||
const handleCancelUpload = useCallback(
|
||
(uploads: Upload[]) => {
|
||
uploads.forEach((upload) => {
|
||
if (upload.status === UploadStatus.Loading) {
|
||
mx.cancelUpload(upload.promise);
|
||
}
|
||
});
|
||
handleRemoveUpload(uploads.map((upload) => upload.file));
|
||
},
|
||
[mx, handleRemoveUpload],
|
||
);
|
||
|
||
const handleSendUpload = useCallback(
|
||
async (uploads: UploadSuccess[]) => {
|
||
const contentsPromises = uploads.map(async (upload) => {
|
||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||
if (!fileItem) throw new Error('Broken upload');
|
||
|
||
// Resolve the MXC URL to use — may be overridden if compression is enabled
|
||
let mxc = upload.mxc;
|
||
|
||
if (fileItem.metadata.compressImage && isCompressible(fileItem.originalFile)) {
|
||
// Use the cached compression result if available, otherwise compute it now
|
||
let compressionResult = fileItem.metadata.compressionResult;
|
||
if (compressionResult === undefined) {
|
||
compressionResult = await compressImage(fileItem.originalFile);
|
||
}
|
||
|
||
if (compressionResult) {
|
||
const originalFile = fileItem.originalFile as File;
|
||
// compressImage re-encodes as JPEG; swap the extension so the file
|
||
// name and MIME type agree (avoids e.g. a JPEG named "photo.png").
|
||
const compressedType = compressionResult.type;
|
||
const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`;
|
||
const compressedFile = new File([compressionResult.blob], compressedName, {
|
||
type: compressedType,
|
||
});
|
||
// Compression re-encodes the image, so in an encrypted room the new
|
||
// bytes must be encrypted before upload (and the event must carry the
|
||
// *new* encInfo) — reusing the original's encInfo would publish the
|
||
// image in the clear and yield an undecryptable attachment.
|
||
const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined;
|
||
const uploadRes = encrypted
|
||
? await mx.uploadContent(encrypted.file)
|
||
: await mx.uploadContent(compressedFile, {
|
||
name: compressedName,
|
||
type: compressedType,
|
||
});
|
||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||
if (compressedMxc) {
|
||
// Delete the pre-uploaded original so only one copy lives on the server.
|
||
tryDeleteMxcContent(mx, upload.mxc);
|
||
mxc = compressedMxc;
|
||
// Synthetic fileItem referring to the compressed file so
|
||
// getImageMsgContent picks up the correct dimensions, type and encInfo.
|
||
const compressedItem = buildCompressedUploadItem(
|
||
fileItem,
|
||
compressedFile,
|
||
encrypted,
|
||
);
|
||
return getImageMsgContent(mx, compressedItem, mxc);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (fileItem.file.type.startsWith('image')) {
|
||
return getImageMsgContent(mx, fileItem, mxc);
|
||
}
|
||
if (fileItem.file.type.startsWith('video')) {
|
||
return getVideoMsgContent(mx, fileItem, mxc);
|
||
}
|
||
if (fileItem.file.type.startsWith('audio')) {
|
||
return getAudioMsgContent(fileItem, mxc);
|
||
}
|
||
return getFileMsgContent(fileItem, mxc);
|
||
});
|
||
handleCancelUpload(uploads);
|
||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
||
contents.forEach((content) => sendRoomMessage(mx, roomId, threadRootId ?? null, content));
|
||
},
|
||
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
|
||
);
|
||
|
||
const submit = useCallback(() => {
|
||
uploadBoardHandlers.current?.handleSend();
|
||
|
||
// Slash commands work in threads too: content-transform commands (/me,
|
||
// /notice, /shrug, /tableflip, /unflip) flow into the normal send below,
|
||
// which routes to the thread via `threadRootId`; the rest (/invite, /kick,
|
||
// …) are room-level actions. This also matches the command autocomplete,
|
||
// which is already shown in the thread composer.
|
||
const commandName = getBeginCommand(editor);
|
||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||
let customHtml = trimCustomHtml(
|
||
toMatrixCustomHTML(editor.children, {
|
||
allowTextFormatting: true,
|
||
allowBlockMarkdown: isMarkdown,
|
||
allowInlineMarkdown: isMarkdown,
|
||
allowMath: true,
|
||
}),
|
||
);
|
||
let msgType = MsgType.Text;
|
||
|
||
if (commandName) {
|
||
plainText = trimCommand(commandName, plainText);
|
||
customHtml = trimCommand(commandName, customHtml);
|
||
}
|
||
if (stripTracking) {
|
||
plainText = stripTrackingParamsInText(plainText);
|
||
customHtml = stripTrackingParamsInHtml(customHtml);
|
||
}
|
||
if (commandName === Command.Me) {
|
||
msgType = MsgType.Emote;
|
||
} else if (commandName === Command.Notice) {
|
||
msgType = MsgType.Notice;
|
||
} else if (commandName === Command.Shrug) {
|
||
plainText = `${SHRUG} ${plainText}`;
|
||
customHtml = `${SHRUG} ${customHtml}`;
|
||
} else if (commandName === Command.TableFlip) {
|
||
plainText = `${TABLEFLIP} ${plainText}`;
|
||
customHtml = `${TABLEFLIP} ${customHtml}`;
|
||
} else if (commandName === Command.UnFlip) {
|
||
plainText = `${UNFLIP} ${plainText}`;
|
||
customHtml = `${UNFLIP} ${customHtml}`;
|
||
} else if (commandName) {
|
||
const commandContent = commands[commandName as Command];
|
||
if (commandContent) {
|
||
// Fire-and-forget by design (the editor resets immediately for UX), but
|
||
// surface a rejection instead of failing silently. /kick and /ban
|
||
// throw a CommandError whose message already names who and why
|
||
// (#216); anything else gets the generic sentence.
|
||
commandContent.exe(plainText).catch((err) => {
|
||
console.error(`Failed to run /${commandName} command:`, err);
|
||
setToast(
|
||
createErrorToast(
|
||
err instanceof CommandError
|
||
? err.message
|
||
: `The /${commandName} command failed. Please try again.`,
|
||
Icons.Warning,
|
||
'Command failed',
|
||
),
|
||
);
|
||
});
|
||
}
|
||
resetEditor(editor);
|
||
resetEditorHistory(editor);
|
||
setCharCount(0);
|
||
sendTypingStatus(false);
|
||
return;
|
||
}
|
||
|
||
if (plainText === '') return;
|
||
|
||
const body = plainText;
|
||
const formattedBody = customHtml;
|
||
const mentionData = getMentions(mx, roomId, editor);
|
||
|
||
const content: IContent = {
|
||
msgtype: msgType,
|
||
body,
|
||
};
|
||
|
||
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
|
||
mentionData.users.add(replyDraft.userId);
|
||
}
|
||
|
||
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
|
||
content['m.mentions'] = mMentions;
|
||
|
||
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
|
||
content.format = 'org.matrix.custom.html';
|
||
content.formatted_body = formattedBody;
|
||
}
|
||
if (replyDraft) {
|
||
content['m.relates_to'] = {
|
||
'm.in_reply_to': {
|
||
event_id: replyDraft.eventId,
|
||
},
|
||
};
|
||
if (replyDraft.relation?.rel_type === RelationType.Thread) {
|
||
content['m.relates_to'].event_id = replyDraft.relation.event_id;
|
||
content['m.relates_to'].rel_type = RelationType.Thread;
|
||
content['m.relates_to'].is_falling_back = false;
|
||
}
|
||
}
|
||
sendRoomMessage(mx, roomId, threadRootId ?? null, content);
|
||
resetEditor(editor);
|
||
resetEditorHistory(editor);
|
||
setCharCount(0);
|
||
setMsgDraft([]);
|
||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||
setReplyDraft(undefined);
|
||
sendTypingStatus(false);
|
||
}, [
|
||
mx,
|
||
roomId,
|
||
threadRootId,
|
||
draftKey,
|
||
editor,
|
||
replyDraft,
|
||
sendTypingStatus,
|
||
setReplyDraft,
|
||
setMsgDraft,
|
||
isMarkdown,
|
||
commands,
|
||
setToast,
|
||
stripTracking,
|
||
]);
|
||
|
||
/**
|
||
* Build a text message content object from the current editor state.
|
||
* Returns null if the editor is empty or the input is a command.
|
||
*/
|
||
const buildCurrentTextContent = useCallback((): IContent | null => {
|
||
const commandName = getBeginCommand(editor);
|
||
// Don't schedule commands
|
||
if (commandName) return null;
|
||
|
||
const plainText = toPlainText(editor.children, isMarkdown).trim();
|
||
const customHtml = trimCustomHtml(
|
||
toMatrixCustomHTML(editor.children, {
|
||
allowTextFormatting: true,
|
||
allowBlockMarkdown: isMarkdown,
|
||
allowInlineMarkdown: isMarkdown,
|
||
allowMath: true,
|
||
}),
|
||
);
|
||
if (plainText === '') return null;
|
||
|
||
const body = stripTracking ? stripTrackingParamsInText(plainText) : plainText;
|
||
const formattedBody = stripTracking ? stripTrackingParamsInHtml(customHtml) : customHtml;
|
||
const mentionData = getMentions(mx, roomId, editor);
|
||
|
||
const content: IContent = {
|
||
msgtype: MsgType.Text,
|
||
body,
|
||
};
|
||
|
||
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
|
||
mentionData.users.add(replyDraft.userId);
|
||
}
|
||
content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room);
|
||
|
||
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
|
||
content.format = 'org.matrix.custom.html';
|
||
content.formatted_body = formattedBody;
|
||
}
|
||
if (replyDraft) {
|
||
content['m.relates_to'] = {
|
||
'm.in_reply_to': { event_id: replyDraft.eventId },
|
||
};
|
||
if (replyDraft.relation?.rel_type === RelationType.Thread) {
|
||
content['m.relates_to'].event_id = replyDraft.relation.event_id;
|
||
content['m.relates_to'].rel_type = RelationType.Thread;
|
||
content['m.relates_to'].is_falling_back = false;
|
||
}
|
||
}
|
||
return content;
|
||
}, [editor, isMarkdown, mx, roomId, replyDraft, stripTracking]);
|
||
|
||
const handleScheduleClick = useCallback(() => {
|
||
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
|
||
// open the modal for an encrypted room even if the button somehow renders.
|
||
if (isEncrypted) return;
|
||
// Pre-fill from editor if there's content; open blank if editor is empty.
|
||
const content = buildCurrentTextContent();
|
||
setScheduleContent(content);
|
||
setScheduleOpen(true);
|
||
}, [buildCurrentTextContent, isEncrypted]);
|
||
|
||
const handleScheduled = useCallback(
|
||
(delayId: string, sendAt: number, content: IContent) => {
|
||
setScheduledMessages((prev) => {
|
||
const next = new Map(prev);
|
||
const current = next.get(roomId) ?? [];
|
||
next.set(roomId, [...current, { delayId, roomId, content, sendAt }]);
|
||
return next;
|
||
});
|
||
// [Gitea #36] Only reached after scheduleMessage() succeeded, so the reply
|
||
// draft (already baked into `content['m.relates_to']`) can be cleared here;
|
||
// a failed/cancelled schedule keeps the composer and reply target intact.
|
||
resetEditor(editor);
|
||
resetEditorHistory(editor);
|
||
setMsgDraft([]);
|
||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||
setReplyDraft(undefined);
|
||
sendTypingStatus(false);
|
||
},
|
||
[
|
||
setScheduledMessages,
|
||
roomId,
|
||
draftKey,
|
||
editor,
|
||
setReplyDraft,
|
||
setMsgDraft,
|
||
sendTypingStatus,
|
||
],
|
||
);
|
||
|
||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||
(evt) => {
|
||
// Typing (not Tab/arrows/modifiers) dismisses the paste-as-code offer.
|
||
if (
|
||
evt.key.length === 1 ||
|
||
evt.key === 'Backspace' ||
|
||
evt.key === 'Delete' ||
|
||
evt.key === 'Enter'
|
||
) {
|
||
setCodeOffer(undefined);
|
||
}
|
||
if (
|
||
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
|
||
!isComposing(evt)
|
||
) {
|
||
evt.preventDefault();
|
||
submit();
|
||
}
|
||
if (isKeyHotkey('escape', evt)) {
|
||
// Only consume Escape (and stop it bubbling to the thread panel / room
|
||
// window handlers) when the composer actually has something to dismiss.
|
||
// If we did nothing, let Escape propagate so those handlers can run.
|
||
if (autocompleteQuery) {
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
setAutocompleteQuery(undefined);
|
||
return;
|
||
}
|
||
if (replyDraft) {
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
setReplyDraft(undefined);
|
||
}
|
||
}
|
||
},
|
||
[submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
||
);
|
||
|
||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||
(evt) => {
|
||
if (isKeyHotkey('escape', evt)) {
|
||
evt.preventDefault();
|
||
return;
|
||
}
|
||
|
||
if (!hideActivity) {
|
||
sendTypingStatus(!isEmptyEditor(editor));
|
||
}
|
||
|
||
const prevWordRange = getPrevWordRange(editor);
|
||
const query = prevWordRange
|
||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
||
: undefined;
|
||
setAutocompleteQuery(query);
|
||
},
|
||
[editor, sendTypingStatus, hideActivity],
|
||
);
|
||
|
||
const handleCloseAutocomplete = useCallback(() => {
|
||
setAutocompleteQuery(undefined);
|
||
ReactEditor.focus(editor);
|
||
}, [editor]);
|
||
|
||
const handleEmoticonSelect = useCallback(
|
||
(key: string, shortcode: string) => {
|
||
editor.insertNode(createEmoticonElement(key, shortcode));
|
||
moveCursor(editor);
|
||
},
|
||
[editor],
|
||
);
|
||
|
||
const handleGifSelect = useCallback(
|
||
async (gifUrl: string, w: number, h: number) => {
|
||
setGifUploading(true);
|
||
try {
|
||
// Only fetch from trusted Giphy CDN domains (match any *.giphy.com subdomain)
|
||
const { hostname } = new URL(gifUrl);
|
||
if (!hostname.endsWith('.giphy.com') && hostname !== 'giphy.com') {
|
||
setGifError('GIF source not trusted.');
|
||
setTimeout(() => setGifError(null), 4000);
|
||
return;
|
||
}
|
||
|
||
const res = await fetch(gifUrl);
|
||
if (!res.ok) {
|
||
setGifError('Failed to download GIF from Giphy.');
|
||
setTimeout(() => setGifError(null), 4000);
|
||
return;
|
||
}
|
||
const contentType = res.headers.get('content-type') ?? '';
|
||
if (!contentType.startsWith('image/')) {
|
||
setGifError('Unexpected GIF format. Please try another.');
|
||
setTimeout(() => setGifError(null), 4000);
|
||
return;
|
||
}
|
||
|
||
const blob = await res.blob();
|
||
if (blob.size > 20 * 1024 * 1024) {
|
||
setGifError('GIF is too large (max 20 MB).');
|
||
setTimeout(() => setGifError(null), 4000);
|
||
return;
|
||
}
|
||
|
||
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' });
|
||
const baseContent = {
|
||
msgtype: MsgType.Image,
|
||
body: 'image.gif',
|
||
info: { mimetype: 'image/gif', w, h, size: blob.size },
|
||
};
|
||
|
||
// Mirror the attachment/voice paths: in an encrypted room the media
|
||
// itself must be encrypted, otherwise the homeserver (and anyone with
|
||
// the mxc URI) can see the GIF even though the event body is encrypted.
|
||
if (room.hasEncryptionStateEvent()) {
|
||
const { encInfo, file: encBlob } = await encryptFile(gifFile);
|
||
const uploadRes = await mx.uploadContent(encBlob);
|
||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||
if (!mxcUrl) return;
|
||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||
...baseContent,
|
||
file: { ...encInfo, url: mxcUrl },
|
||
});
|
||
} else {
|
||
const uploadRes = await mx.uploadContent(gifFile, {
|
||
type: 'image/gif',
|
||
name: 'image.gif',
|
||
includeFilename: false,
|
||
});
|
||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
||
if (!mxcUrl) return;
|
||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||
...baseContent,
|
||
url: mxcUrl,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
|
||
if (!alive()) return;
|
||
setGifError('Failed to send GIF. Please try again.');
|
||
setTimeout(() => setGifError(null), 4000);
|
||
} finally {
|
||
if (alive()) setGifUploading(false);
|
||
}
|
||
},
|
||
[mx, room, roomId, threadRootId, alive],
|
||
);
|
||
|
||
const handleStickerSelect = useCallback(
|
||
async (mxc: string, shortcode: string, label: string) => {
|
||
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
||
if (!stickerUrl) return;
|
||
|
||
const info = await getImageInfo(
|
||
await loadImageElement(stickerUrl),
|
||
await getImageUrlBlob(stickerUrl),
|
||
);
|
||
|
||
mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
|
||
body: label,
|
||
url: mxc,
|
||
info,
|
||
});
|
||
},
|
||
[mx, roomId, threadRootId, useAuthentication],
|
||
);
|
||
|
||
if (room.getType() === 'm.server_notice') {
|
||
return (
|
||
<Box
|
||
ref={ref as React.Ref<HTMLDivElement>}
|
||
direction="Column"
|
||
alignItems="Center"
|
||
justifyContent="Center"
|
||
style={{ padding: config.space.S300 }}
|
||
>
|
||
<Text size="T300" priority="300">
|
||
This room contains system messages from your homeserver. Replies are not permitted.
|
||
</Text>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// Mobile "+" overflow: the `after` builder stashes the collapsed secondary
|
||
// buttons here and the `bottom` slot renders them when the toggle is open.
|
||
// React evaluates JSX props in source order (before → after → bottom), so
|
||
// `after` assigns this before `bottom` reads it within the same render.
|
||
let composerOverflow: ReactNode = null;
|
||
|
||
return (
|
||
<div ref={ref}>
|
||
{selectedFiles.length > 0 && (
|
||
<UploadBoard
|
||
header={
|
||
<UploadBoardHeader
|
||
open={uploadBoard}
|
||
onToggle={() => setUploadBoard(!uploadBoard)}
|
||
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
|
||
onSend={handleSendUpload}
|
||
imperativeHandlerRef={uploadBoardHandlers}
|
||
onCancel={handleCancelUpload}
|
||
/>
|
||
}
|
||
>
|
||
{uploadBoard && (
|
||
<Scroll size="300" hideTrack visibility="Hover">
|
||
<UploadBoardContent>
|
||
{Array.from(selectedFiles)
|
||
.reverse()
|
||
.map((fileItem, index) => (
|
||
<UploadCardRenderer
|
||
key={index}
|
||
isEncrypted={!!fileItem.encInfo}
|
||
fileItem={fileItem}
|
||
setMetadata={handleFileMetadata}
|
||
onRemove={handleRemoveUpload}
|
||
autoFocusCaption={isAutoFocusTarget(
|
||
fileItem.originalFile,
|
||
autoFocusCaptionRef.current,
|
||
)}
|
||
onCaptionSubmit={submit}
|
||
onCaptionEscape={() => ReactEditor.focus(editor)}
|
||
/>
|
||
))}
|
||
</UploadBoardContent>
|
||
</Scroll>
|
||
)}
|
||
</UploadBoard>
|
||
)}
|
||
<Overlay
|
||
open={dropZoneVisible}
|
||
backdrop={<OverlayBackdrop />}
|
||
style={{ pointerEvents: 'none' }}
|
||
>
|
||
<OverlayCenter>
|
||
<Dialog variant="Primary">
|
||
<Box
|
||
direction="Column"
|
||
justifyContent="Center"
|
||
alignItems="Center"
|
||
gap="500"
|
||
style={{ padding: toRem(60) }}
|
||
>
|
||
<Icon size="600" src={Icons.File} />
|
||
<Text size="H4" align="Center">
|
||
{`Drop Files in "${room?.name || 'Room'}"`}
|
||
</Text>
|
||
<Text align="Center">Drag and drop files here or click for selection dialog</Text>
|
||
</Box>
|
||
</Dialog>
|
||
</OverlayCenter>
|
||
</Overlay>
|
||
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
|
||
<RoomMentionAutocomplete
|
||
roomId={roomId}
|
||
editor={editor}
|
||
query={autocompleteQuery}
|
||
requestClose={handleCloseAutocomplete}
|
||
/>
|
||
)}
|
||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
||
<UserMentionAutocomplete
|
||
room={room}
|
||
editor={editor}
|
||
query={autocompleteQuery}
|
||
requestClose={handleCloseAutocomplete}
|
||
/>
|
||
)}
|
||
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
||
<EmoticonAutocomplete
|
||
imagePackRooms={imagePackRooms}
|
||
editor={editor}
|
||
query={autocompleteQuery}
|
||
requestClose={handleCloseAutocomplete}
|
||
/>
|
||
)}
|
||
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
|
||
<CommandAutocomplete
|
||
room={room}
|
||
editor={editor}
|
||
query={autocompleteQuery}
|
||
requestClose={handleCloseAutocomplete}
|
||
/>
|
||
)}
|
||
{showUnverifiedWarning && (
|
||
<Box
|
||
alignItems="Center"
|
||
gap="200"
|
||
style={{
|
||
margin: `0 ${config.space.S300} ${config.space.S100}`,
|
||
padding: `${config.space.S100} ${config.space.S200}`,
|
||
borderRadius: config.radii.R300,
|
||
background: color.Warning.Container,
|
||
border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
|
||
}}
|
||
>
|
||
<Icon
|
||
size="100"
|
||
src={Icons.Shield}
|
||
style={{ color: color.Warning.OnContainer, flexShrink: 0 }}
|
||
/>
|
||
<Text size="T200" style={{ color: color.Warning.OnContainer }}>
|
||
{roomUnverifiedDeviceCount}{' '}
|
||
{roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this
|
||
room
|
||
</Text>
|
||
</Box>
|
||
)}
|
||
{codeOffer && (
|
||
<Box
|
||
role="group"
|
||
aria-label="Paste as code block"
|
||
alignItems="Center"
|
||
gap="200"
|
||
wrap="Wrap"
|
||
style={{
|
||
margin: `0 ${config.space.S300} ${config.space.S100}`,
|
||
padding: `${config.space.S100} ${config.space.S200}`,
|
||
borderRadius: config.radii.R300,
|
||
background: color.SurfaceVariant.Container,
|
||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||
}}
|
||
>
|
||
<Icon size="100" src={Icons.BlockCode} style={{ flexShrink: 0 }} />
|
||
<Text size="T200" style={{ flexGrow: 1 }}>
|
||
That looks like code. Format it as a code block
|
||
{codeOffer.lang ? ` (${codeOffer.lang})` : ''}?
|
||
</Text>
|
||
<Button size="300" variant="Primary" radii="300" onClick={acceptCodeOffer}>
|
||
<Text size="B300">Format as code</Text>
|
||
</Button>
|
||
<Button
|
||
size="300"
|
||
variant="Secondary"
|
||
fill="None"
|
||
radii="300"
|
||
onClick={() => {
|
||
setCodeOffer(undefined);
|
||
ReactEditor.focus(editor);
|
||
}}
|
||
>
|
||
<Text size="B300">No thanks</Text>
|
||
</Button>
|
||
</Box>
|
||
)}
|
||
<ScheduledMessagesTray roomId={roomId} />
|
||
<CustomEditor
|
||
editableName={editableName}
|
||
editor={editor}
|
||
placeholder="Send a message..."
|
||
onKeyDown={handleKeyDown}
|
||
onKeyUp={handleKeyUp}
|
||
onPaste={handlePaste}
|
||
onChange={(value) => {
|
||
setCharCount(toPlainText(value, isMarkdown).trim().length);
|
||
schedulePersistDraft();
|
||
}}
|
||
top={
|
||
replyDraft && (
|
||
<div>
|
||
<Box
|
||
alignItems="Center"
|
||
gap="300"
|
||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||
>
|
||
<IconButton
|
||
onClick={() => setReplyDraft(undefined)}
|
||
aria-label="Dismiss reply"
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
>
|
||
<Icon src={Icons.Cross} size="50" />
|
||
</IconButton>
|
||
<Box direction="Row" gap="200" alignItems="Center">
|
||
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
||
<ReplyLayout
|
||
userColor={replyUsernameColor}
|
||
username={
|
||
<Text size="T300" truncate>
|
||
<b>{getMemberName(room, replyDraft.userId)}</b>
|
||
</Text>
|
||
}
|
||
>
|
||
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||
{replyDraftEvent && hasReplyMedia(replyDraftEvent) && (
|
||
<ReplyMediaThumb mEvent={replyDraftEvent} />
|
||
)}
|
||
<Text size="T300" truncate>
|
||
{trimReplyFromBody(replyDraft.body)}
|
||
</Text>
|
||
</Box>
|
||
</ReplyLayout>
|
||
</Box>
|
||
</Box>
|
||
</div>
|
||
)
|
||
}
|
||
before={
|
||
compact ? (
|
||
<IconButton
|
||
onClick={() => setMobileToolsOpen((open) => !open)}
|
||
aria-label="More actions"
|
||
aria-expanded={mobileToolsOpen}
|
||
aria-controls="composer-more-actions"
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
>
|
||
<Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
|
||
</IconButton>
|
||
) : (
|
||
<IconButton
|
||
onClick={() => pickFile('*')}
|
||
aria-label="Attach file"
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
>
|
||
<Icon src={Icons.PlusCircle} />
|
||
</IconButton>
|
||
)
|
||
}
|
||
after={(() => {
|
||
const formatButton = showFormat ? (
|
||
<IconButton
|
||
key="showFormat"
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||
aria-pressed={toolbar}
|
||
onClick={() => setToolbar(!toolbar)}
|
||
>
|
||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||
</IconButton>
|
||
) : null;
|
||
|
||
// Emoji and Sticker share a single EmojiBoard PopOut anchored to the
|
||
// emoji button, so they are rendered together as one unit. Their
|
||
// relative order still follows the saved order.
|
||
const emojiStickerBlock =
|
||
showEmoji || showSticker ? (
|
||
<UseStateProvider key="showEmojiSticker" initial={undefined}>
|
||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => {
|
||
const stickerBtn =
|
||
showSticker && !hideStickerBtn ? (
|
||
<IconButton
|
||
key="showSticker"
|
||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||
aria-label="Insert sticker"
|
||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
>
|
||
<Icon
|
||
src={Icons.Sticker}
|
||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||
/>
|
||
</IconButton>
|
||
) : null;
|
||
const emojiBtn = showEmoji ? (
|
||
<IconButton
|
||
key="showEmoji"
|
||
ref={emojiBtnRef}
|
||
aria-label="Insert emoji"
|
||
aria-pressed={
|
||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||
}
|
||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
>
|
||
<Icon
|
||
src={Icons.Smile}
|
||
filled={
|
||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||
}
|
||
/>
|
||
</IconButton>
|
||
) : null;
|
||
const emojiFirst =
|
||
composerButtonOrder.indexOf('showEmoji') <
|
||
composerButtonOrder.indexOf('showSticker');
|
||
return (
|
||
<PopOut
|
||
offset={16}
|
||
alignOffset={-44}
|
||
position="Top"
|
||
align="End"
|
||
anchor={
|
||
emojiBoardTab === undefined
|
||
? undefined
|
||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||
}
|
||
content={
|
||
<React.Suspense fallback={null}>
|
||
<EmojiBoard
|
||
tab={emojiBoardTab}
|
||
onTabChange={setEmojiBoardTab}
|
||
imagePackRooms={imagePackRooms}
|
||
returnFocusOnDeactivate={false}
|
||
onEmojiSelect={handleEmoticonSelect}
|
||
onCustomEmojiSelect={handleEmoticonSelect}
|
||
onStickerSelect={handleStickerSelect}
|
||
requestClose={() => {
|
||
setEmojiBoardTab((t) => {
|
||
if (t) {
|
||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||
return undefined;
|
||
}
|
||
return t;
|
||
});
|
||
}}
|
||
/>
|
||
</React.Suspense>
|
||
}
|
||
>
|
||
{emojiFirst ? [emojiBtn, stickerBtn] : [stickerBtn, emojiBtn]}
|
||
</PopOut>
|
||
);
|
||
}}
|
||
</UseStateProvider>
|
||
) : null;
|
||
|
||
const gifButton =
|
||
!!gifApiKey && showGif ? (
|
||
<UseStateProvider key="showGif" initial={false}>
|
||
{(gifOpen: boolean, setGifOpen) => (
|
||
<PopOut
|
||
offset={16}
|
||
// [Gitea #147] In the compact overflow row the GIF button sits
|
||
// near the left edge; end-aligning a 312px picker to it pushed
|
||
// most of it off-screen. Anchor to the row instead.
|
||
alignOffset={compact ? 0 : -44}
|
||
position="Top"
|
||
align="End"
|
||
anchor={
|
||
gifOpen
|
||
? ((compact
|
||
? gifBtnRef.current?.closest('#composer-more-actions')
|
||
: gifBtnRef.current
|
||
)?.getBoundingClientRect() ??
|
||
gifBtnRef.current?.getBoundingClientRect() ??
|
||
undefined)
|
||
: undefined
|
||
}
|
||
content={
|
||
<React.Suspense fallback={null}>
|
||
<GifPicker
|
||
apiKey={gifApiKey}
|
||
onSelect={handleGifSelect}
|
||
requestClose={() => setGifOpen(false)}
|
||
/>
|
||
</React.Suspense>
|
||
}
|
||
>
|
||
<IconButton
|
||
ref={gifBtnRef}
|
||
aria-label="Insert GIF"
|
||
aria-pressed={gifOpen}
|
||
onClick={() => !gifUploading && setGifOpen(!gifOpen)}
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
disabled={gifUploading}
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
>
|
||
{/* Sized like a default Icon (1.5rem) so the button
|
||
matches its 32px neighbours instead of a 28×19 stub. */}
|
||
<Box
|
||
alignItems="Center"
|
||
justifyContent="Center"
|
||
style={{ width: '1.5rem', height: '1.5rem' }}
|
||
>
|
||
{gifUploading ? (
|
||
<Spinner variant="Secondary" size="100" />
|
||
) : (
|
||
<Text
|
||
as="span"
|
||
size="T200"
|
||
style={{
|
||
fontWeight: 800,
|
||
fontSize: '12px',
|
||
letterSpacing: '0.04em',
|
||
lineHeight: 1,
|
||
}}
|
||
>
|
||
GIF
|
||
</Text>
|
||
)}
|
||
</Box>
|
||
</IconButton>
|
||
</PopOut>
|
||
)}
|
||
</UseStateProvider>
|
||
) : null;
|
||
|
||
const locationButton = showLocation ? (
|
||
<IconButton
|
||
key="showLocation"
|
||
onClick={handleShareLocation}
|
||
disabled={locating}
|
||
aria-label="Share location"
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
title="Share location"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
>
|
||
{locating ? (
|
||
<Spinner variant="Secondary" size="400" />
|
||
) : (
|
||
<Icon src={Icons.SpaceGlobe} />
|
||
)}
|
||
</IconButton>
|
||
) : null;
|
||
|
||
const pollButton = showPoll ? (
|
||
<IconButton
|
||
key="showPoll"
|
||
onClick={() => setPollOpen(true)}
|
||
aria-label="Create poll"
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
title="Create poll"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
>
|
||
<Icon src={Icons.OrderList} />
|
||
</IconButton>
|
||
) : null;
|
||
|
||
const voiceButton = showVoice ? (
|
||
<VoiceMessageRecorder
|
||
key="showVoice"
|
||
onSend={handleVoiceSend}
|
||
onError={(err) => {
|
||
setLocationError(err);
|
||
setTimeout(() => setLocationError(null), 4000);
|
||
}}
|
||
/>
|
||
) : null;
|
||
|
||
const scheduleButton = showSchedule ? (
|
||
<IconButton
|
||
key="showSchedule"
|
||
onClick={handleScheduleClick}
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
aria-label="Schedule message"
|
||
title="Schedule message"
|
||
>
|
||
<Icon src={Icons.Clock} />
|
||
</IconButton>
|
||
) : null;
|
||
|
||
const orderedButtons: ReactNode[] = [];
|
||
let emojiStickerRendered = false;
|
||
composerButtonOrder.forEach((key: ComposerToolbarButtonKey) => {
|
||
switch (key) {
|
||
case 'showFormat':
|
||
if (formatButton) orderedButtons.push(formatButton);
|
||
break;
|
||
case 'showEmoji':
|
||
case 'showSticker':
|
||
// Rendered once as a combined unit at whichever of the two
|
||
// keys comes first in the order.
|
||
if (!emojiStickerRendered) {
|
||
emojiStickerRendered = true;
|
||
if (emojiStickerBlock) orderedButtons.push(emojiStickerBlock);
|
||
}
|
||
break;
|
||
case 'showGif':
|
||
if (gifButton) orderedButtons.push(gifButton);
|
||
break;
|
||
case 'showLocation':
|
||
if (locationButton) orderedButtons.push(locationButton);
|
||
break;
|
||
case 'showPoll':
|
||
if (pollButton) orderedButtons.push(pollButton);
|
||
break;
|
||
case 'showVoice':
|
||
if (voiceButton) orderedButtons.push(voiceButton);
|
||
break;
|
||
case 'showSchedule':
|
||
if (scheduleButton) orderedButtons.push(scheduleButton);
|
||
break;
|
||
default:
|
||
break;
|
||
}
|
||
});
|
||
|
||
// Compact: keep only emoji/sticker inline beside Send; the rest move
|
||
// into the "+" overflow row (rendered via `bottom`), led by the attach
|
||
// button that `before` gives up on, and trailed by the draft label so
|
||
// the inline row stays [ emoji | count | send ]. Wide viewports render
|
||
// everything inline.
|
||
const emojiInline = orderedButtons.filter(
|
||
(node) => React.isValidElement(node) && node.key === 'showEmojiSticker',
|
||
);
|
||
const overflowButtons = orderedButtons.filter(
|
||
(node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'),
|
||
);
|
||
if (compact) {
|
||
composerOverflow = (
|
||
<>
|
||
<IconButton
|
||
key="showAttach"
|
||
onClick={() => pickFile('*')}
|
||
aria-label="Attach file"
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
>
|
||
<Icon src={Icons.PlusCircle} />
|
||
</IconButton>
|
||
{overflowButtons}
|
||
<DraftIndicator roomId={draftKey} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{compact ? emojiInline : orderedButtons}
|
||
{gifError && (
|
||
<Text
|
||
size="T200"
|
||
style={{
|
||
color: color.Critical.Main,
|
||
padding: '2px 6px',
|
||
alignSelf: 'center',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{gifError}
|
||
</Text>
|
||
)}
|
||
{locationError && (
|
||
<Text
|
||
size="T200"
|
||
style={{
|
||
color: color.Critical.Main,
|
||
padding: '2px 6px',
|
||
alignSelf: 'center',
|
||
whiteSpace: 'nowrap',
|
||
}}
|
||
>
|
||
{locationError}
|
||
</Text>
|
||
)}
|
||
{!compact && <DraftIndicator roomId={draftKey} />}
|
||
{charCount > 0 && (
|
||
<Text
|
||
size="T200"
|
||
priority="300"
|
||
style={{
|
||
padding: `0 ${config.space.S100}`,
|
||
alignSelf: 'center',
|
||
userSelect: 'none',
|
||
minWidth: '2rem',
|
||
textAlign: 'right',
|
||
}}
|
||
>
|
||
{charCount}
|
||
</Text>
|
||
)}
|
||
<IconButton
|
||
onClick={submit}
|
||
variant="SurfaceVariant"
|
||
size="300"
|
||
radii="300"
|
||
style={touchTarget}
|
||
className={MobileTouchTarget}
|
||
aria-label="Send message"
|
||
>
|
||
<Icon src={Icons.Send} />
|
||
</IconButton>
|
||
</>
|
||
);
|
||
})()}
|
||
bottom={
|
||
<>
|
||
{compact && mobileToolsOpen && composerOverflow && (
|
||
<div>
|
||
<Line variant="SurfaceVariant" size="300" />
|
||
<Box
|
||
id="composer-more-actions"
|
||
role="group"
|
||
aria-label="More actions"
|
||
alignItems="Center"
|
||
gap="100"
|
||
wrap="Wrap"
|
||
style={{ padding: config.space.S200 }}
|
||
>
|
||
{composerOverflow}
|
||
</Box>
|
||
</div>
|
||
)}
|
||
{toolbar && (
|
||
<div>
|
||
<Line variant="SurfaceVariant" size="300" />
|
||
<Toolbar />
|
||
</div>
|
||
)}
|
||
</>
|
||
}
|
||
/>
|
||
{pollOpen && (
|
||
<PollCreator
|
||
room={room}
|
||
roomId={roomId}
|
||
threadRootId={threadRootId}
|
||
onClose={() => setPollOpen(false)}
|
||
/>
|
||
)}
|
||
{scheduleOpen && (
|
||
<ScheduleMessageModal
|
||
roomId={roomId}
|
||
initialContent={scheduleContent}
|
||
onScheduled={handleScheduled}
|
||
onClose={() => {
|
||
setScheduleOpen(false);
|
||
setScheduleContent(null);
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
);
|