import React, { KeyboardEventHandler, MouseEvent as ReactMouseEvent, RefObject, forwardRef, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; import { useTranslation } from 'react-i18next'; 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, Overlay, OverlayBackdrop, OverlayCenter, PopOut, Scroll, Text, config, toRem, } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { CustomEditor, toMatrixCustomHTML, toPlainText, AUTOCOMPLETE_PREFIXES, AutocompletePrefix, AutocompleteQuery, getAutocompleteQuery, getPrevWorldRange, resetEditor, RoomMentionAutocomplete, UserMentionAutocomplete, EmoticonAutocomplete, createEmoticonElement, moveCursor, resetEditorHistory, customHtmlEqualsPlainText, trimCustomHtml, isEmptyEditor, getBeginCommand, trimCommand, getMentions, } from '../../components/editor'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { UseStateProvider } from '../../components/UseStateProvider'; import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp, } from '../../utils/matrix'; import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater'; import { useFilePicker } from '../../hooks/useFilePicker'; import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; import { useFileDropZone } from '../../hooks/useFileDrop'; import { TUploadItem, TUploadMetadata, draftKey, 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 { settingsAtom } from '../../state/settings'; import { getAudioMsgContent, getFileMsgContent, getImageMsgContent, getVideoMsgContent, } from './msgContent'; import { getMemberDisplayName, 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 { 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 { useIsOneOnOne } 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'; // Placeholder rotation set — 12 i18n keys (1 default + 11 alternates) under // the `Room` namespace; the hour-of-day slot picks one. With 12 variants // over 24 hours each variant occupies two specific hour slots per day, so // the placeholder is stable within an hour and a user is most likely to // notice the change when they navigate to another room across an hour // boundary. Keep this list in sync with `public/locales/{en,ru}.json` // `Room.send_message*` entries. const COMPOSER_PLACEHOLDER_KEYS = [ 'Room.send_message', 'Room.send_message_alt_1', 'Room.send_message_alt_2', 'Room.send_message_alt_3', 'Room.send_message_alt_4', 'Room.send_message_alt_5', 'Room.send_message_alt_6', 'Room.send_message_alt_7', 'Room.send_message_alt_8', 'Room.send_message_alt_9', 'Room.send_message_alt_10', 'Room.send_message_alt_11', ] as const; // Composer action-row icons — stroke-based outline style from the Dawn // canon (docs/design/new-direct-messages-design/project/shared.jsx line // 3-22). folds Icon wraps these in ``, // so each IconSrc returns just the path/shape elements. Matches the // Gemini-style flat composer aesthetic: thin 1.6-1.8 strokes, round caps, // no fills. const StreamComposerIcons = { Plus: () => ( ), Smile: () => ( <> ), Send: () => ( <> ), }; interface RoomInputProps { editor: Editor; fileDropContainerRef: RefObject; roomId: string; room: Room; // M2: when set, all sendMessage / sendEvent paths emit with this // threadId so the SDK attaches the m.thread relation. Channel and DM // composers leave this undefined so messages land in the main timeline // unchanged. The drawer composer passes threadId={rootId}. threadId?: string; } export const RoomInput = forwardRef( ({ editor, fileDropContainerRef, roomId, room, threadId }, ref) => { const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const isOneOnOne = useIsOneOnOne(); const commands = useCommands(mx, room); const emojiBtnRef = useRef(null); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); // Per-(room, thread) draft scope: drawer composer (threadId={rootId}) // and channel/DM composer (threadId=undefined → 'main') no longer // clobber each other's drafts. See `roomInputDrafts.ts::DraftKey`. const inputDraftKey = draftKey(roomId, threadId); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(inputDraftKey)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(inputDraftKey)); 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 = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor; const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom( roomIdToUploadItemsAtomFamily(inputDraftKey) ); const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( roomUploadAtomFamily, selectedFiles.map((f) => f.file) ); const uploadBoardHandlers = useRef(); const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); const [autocompleteQuery, setAutocompleteQuery] = useState>(); // Suppress typing notifications from the drawer composer — see // useTypingStatusUpdater for the spec-level reason (m.typing has // no thread_id, so a thread-composer typing event would surface // in the main channel chat as if the user were typing there). const sendTypingStatus = useTypingStatusUpdater(mx, roomId, !!threadId); 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 isComposing = useComposingCheck(); useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); useEffect( () => () => { if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); } else { setMsgDraft([]); } resetEditor(editor); resetEditorHistory(editor); }, // `threadId` included so a future caller passing dynamic // threadId without remount still routes the cleanup-on-unmount // setter to the correct atom. Today the drawer is fully // remounted on rootId change via Room.tsx key, but the dep // pins the invariant explicitly. [roomId, threadId, editor, setMsgDraft] ); 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 = (uploads: Upload[]) => { uploads.forEach((upload) => { if (upload.status === UploadStatus.Loading) { mx.cancelUpload(upload.promise); } }); handleRemoveUpload(uploads.map((upload) => upload.file)); }; const handleSendUpload = 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'); if (fileItem.file.type.startsWith('image')) { return getImageMsgContent(mx, fileItem, upload.mxc); } if (fileItem.file.type.startsWith('video')) { return getVideoMsgContent(mx, fileItem, upload.mxc); } if (fileItem.file.type.startsWith('audio')) { return getAudioMsgContent(fileItem, upload.mxc); } return getFileMsgContent(fileItem, upload.mxc); }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); contents.forEach((content) => mx.sendMessage(roomId, threadId ?? null, content as any)); }; const submit = useCallback(() => { uploadBoardHandlers.current?.handleSend(); const commandName = getBeginCommand(editor); let plainText = toPlainText(editor.children, isMarkdown).trim(); let customHtml = trimCustomHtml( toMatrixCustomHTML(editor.children, { allowTextFormatting: true, allowBlockMarkdown: isMarkdown, allowInlineMarkdown: isMarkdown, }) ); 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) { commandContent.exe(plainText); } resetEditor(editor); resetEditorHistory(editor); 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, threadId ?? null, content as any); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); }, [ mx, roomId, threadId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands, ]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { if ( (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && !isComposing(evt) ) { evt.preventDefault(); submit(); } if (isKeyHotkey('escape', evt)) { evt.preventDefault(); if (autocompleteQuery) { setAutocompleteQuery(undefined); return; } setReplyDraft(undefined); } }, [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing] ); const handleKeyUp: KeyboardEventHandler = useCallback( (evt) => { if (isKeyHotkey('escape', evt)) { evt.preventDefault(); return; } if (!hideActivity) { sendTypingStatus(!isEmptyEditor(editor)); } const prevWordRange = getPrevWorldRange(editor); const query = prevWordRange ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) : undefined; setAutocompleteQuery(query); }, [editor, sendTypingStatus, hideActivity] ); const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery(undefined); ReactEditor.focus(editor); }, [editor]); const handleEmoticonSelect = (key: string, shortcode: string) => { editor.insertNode(createEmoticonElement(key, shortcode)); moveCursor(editor); }; const handleStickerSelect = 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, threadId ?? null, EventType.Sticker, { body: label, url: mxc, info, }); }; // Action-row buttons extracted into JSX consts so the bottom-slot // markup stays readable. Single source for each button across whatever // layout the composer evolves into; today the layout is a two-row // strip — textarea on top, this trio below — on every platform. const plusButton = ( pickFile('*')} variant="SurfaceVariant" fill="None" size="300" radii="300" > ); const emojiButton = ( {(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => ( { setEmojiBoardTab((tab) => { if (tab) { if (!mobileOrTablet()) ReactEditor.focus(editor); return undefined; } return tab; }); }} /> } > setEmojiBoardTab(EmojiBoardTab.Emoji)} variant="SurfaceVariant" fill="None" size="300" radii="300" > )} ); const sendButton = ( ) => { submit(); // Defense-in-depth against the Android WebView's synthetic // :hover/:focus-visible persistence (the CSS gate in // RoomView.css.ts handles the same class of bug, but // explicitly blurring the tap target also clears any // lingering :focus state regardless of input-mode // attribution). evt.currentTarget.blur(); }} variant="SurfaceVariant" fill="None" size="300" radii="300" > ); // Hour-of-day picks the placeholder variant. Memoised against // (roomId, threadId) so the placeholder stays stable while the user is // in one chat/thread and only re-rolls when they navigate elsewhere — // and even then it's stable within the same wall-clock hour, so the // change is most likely to surface to the user when they cross an // hour boundary AND switch rooms (or reload the app). That cadence // keeps the joke surprising without flipping mid-session. const placeholderKey = useMemo(() => { const idx = new Date().getHours() % COMPOSER_PLACEHOLDER_KEYS.length; return COMPOSER_PLACEHOLDER_KEYS[idx]; // roomId and threadId are intentional re-roll triggers; the // exhaustive-deps lint is happy with them too. }, [roomId, threadId]); return (
{selectedFiles.length > 0 && ( setUploadBoard(!uploadBoard)} uploadFamilyObserverAtom={uploadFamilyObserverAtom} onSend={handleSendUpload} imperativeHandlerRef={uploadBoardHandlers} onCancel={handleCancelUpload} /> } > {uploadBoard && ( {Array.from(selectedFiles) .reverse() .map((fileItem, index) => ( ))} )} )} } style={{ pointerEvents: 'none' }} > {t('Room.drop_files', { name: room?.name || 'Room' })} {t('Room.drag_drop_desc')} {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( )} {autocompleteQuery?.prefix === AutocompletePrefix.Command && ( )} setReplyDraft(undefined)} variant="SurfaceVariant" size="300" radii="300" > {replyDraft.relation?.rel_type === RelationType.Thread && } {getMemberDisplayName(room, replyDraft.userId) ?? getMxIdLocalPart(replyDraft.userId) ?? replyDraft.userId} } > {trimReplyFromBody(replyDraft.body)}
) } bottom={ {plusButton} {emojiButton} {sendButton} } /> ); } );