Files
cinny/src/app/features/room/RoomInput.tsx
T
Lotus CIandClaude Opus 5.5 75d55e861b
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
feat(composer): offer to format pasted code as a code block (#107)
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
2026-09-25 11:05:49 -04:00

1769 lines
68 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, 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>
);
},
);