Files
cinny/src/app/features/room/RoomInput.tsx
T
jaredandClaude Opus 5 470b5217ae
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 1m38s
fix(composer): one-row toolbar with uniform 32px buttons on every viewport
The composer looked off in two ways, both confirmed by rendering CustomEditor
with RoomInput's exact props and measuring the buttons headlessly:

Desktop: the Lotus additions (location, poll, voice, schedule) used
`Icon size="100"` (18px) inside the same `IconButton size="300"` as the
upstream Aa/sticker/emoji/send buttons (24px icons), so one row mixed
32×32, 26×26 and a 28×19 "GIF" text stub. Every button is now 32×32: the
four small icons use the default icon size and the GIF label sits in a
1.5rem box, the same footprint as an icon. The mic's idle button in
VoiceMessageRecorder gets the same treatment since it lives in this row.

Phones: d6159997 let the before|editable|after row flex-wrap at <=750px, but
folds' Scroll (the editable's wrapper) is `width: 100%`, so the row ALWAYS
broke into three stacked lines — "+" alone on top, the input flush against
the left edge on its own line (the :first-child padding selectors no longer
matched), and emoji/draft/send left-aligned underneath. e1bb8301's "+"
overflow menu was meant to produce [ + | input | emoji | send ] but never
could while the row wrapped. The row no longer wraps (upstream behaviour);
instead the collapse into the "+" overflow is keyed on the viewport
(ScreenSize.Mobile) as well as the touch UA, so a phone-width window on a
desktop UA — iPad desktop mode, split-screen PWA, docked window — also
collapses instead of rendering ten controls inline and clipping Send behind
the editor's overflow:hidden. The "Draft saved" label moves into the overflow
row in compact mode so the inline row stays [ + | input | emoji | count |
send ]. The editable's vertical padding grows to 19px at phone width (only
when the row actually has buttons) so the text sits level with the 44px
touch targets instead of hugging the top of the row. Those touch targets now
also apply the shared MobileTouchTarget class, matching the recorder button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-17 01:14:48 -04:00

1599 lines
61 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from 'slate';
import {
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,
} 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 { safeFile } from '../../utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '../../utils/common';
import { useSetting } from '../../state/hooks/settings';
import { useAlive } from '../../hooks/useAlive';
import {
ComposerToolbarButtonKey,
normalizeComposerToolbarOrder,
settingsAtom,
} from '../../state/settings';
import {
buildCompressedUploadItem,
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, 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';
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. */
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;
}
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
(
{ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' },
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;
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);
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;
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');
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.
mx.sendMessage(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,
} as any);
},
(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);
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: uploadResult.content_uri },
} as any);
} else {
const uploadResult = await mx.uploadContent(blob, {
name: 'voice-message.ogg',
type: mimeType,
});
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
url: uploadResult.content_uri,
} as any);
}
},
[mx, room, roomId, threadRootId],
);
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
const handleFiles = useCallback(
async (files: File[]) => {
setUploadBoard(true);
const safeFiles = files.map(safeFile);
const fileItems: TUploadItem[] = [];
if (room.hasEncryptionStateEvent()) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f))),
);
encryptFiles.forEach((ef) =>
fileItems.push({
...ef,
metadata: {
markedAsSpoiler: false,
},
}),
);
} else {
safeFiles.forEach((f) =>
fileItems.push({
file: f,
originalFile: f,
encInfo: undefined,
metadata: {
markedAsSpoiler: false,
},
}),
);
}
setSelectedFiles({
type: 'PUT',
item: fileItems,
});
},
[setSelectedFiles, room],
);
const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles);
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) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
},
[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 (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. NOTE: /kick and /ban
// route through rateLimitedActions (utils/matrix.ts), whose to() helper
// swallows non-429 errors, so those two commands can still resolve even
// when the underlying kick/ban failed — this catch only covers errors
// that actually reject out of exe().
commandContent.exe(plainText).catch((err) => {
console.error(`Failed to run /${commandName} command:`, err);
setToast(
createErrorToast(
`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;
}
}
mx.sendMessage(roomId, threadRootId ?? null, content as any);
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,
]);
/**
* 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 = plainText;
const formattedBody = 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]);
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) => {
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;
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: mxcUrl },
} as any);
} 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;
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
url: mxcUrl,
} as any);
}
} 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}
/>
))}
</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>
)}
<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>
}
>
<Text size="T300" truncate>
{trimReplyFromBody(replyDraft.body)}
</Text>
</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}
alignOffset={-44}
position="Top"
align="End"
anchor={
gifOpen
? (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>
);
},
);