828 lines
28 KiB
TypeScript
828 lines
28 KiB
TypeScript
import React, {
|
||
KeyboardEventHandler,
|
||
MouseEvent as ReactMouseEvent,
|
||
RefObject,
|
||
forwardRef,
|
||
useCallback,
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from 'react';
|
||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||
import { isKeyHotkey } from 'is-hotkey';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
|
||
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';
|
||
import { pendingShareAtom } from '../../state/pendingShare';
|
||
|
||
// 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 `<svg viewBox="0 0 24 24" fill="none">`,
|
||
// 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: () => (
|
||
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||
),
|
||
Smile: () => (
|
||
<>
|
||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||
<path
|
||
d="M8 14s1.5 2 4 2 4-2 4-2M9 10h.01M15 10h.01"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
/>
|
||
</>
|
||
),
|
||
Send: () => (
|
||
<>
|
||
<path
|
||
d="M22 2L11 13"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
<path
|
||
d="M22 2L15 22L11 13L2 9L22 2Z"
|
||
stroke="currentColor"
|
||
strokeWidth="1.6"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</>
|
||
),
|
||
};
|
||
|
||
interface RoomInputProps {
|
||
editor: Editor;
|
||
fileDropContainerRef: RefObject<HTMLElement>;
|
||
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<HTMLDivElement, RoomInputProps>(
|
||
({ 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<HTMLButtonElement>(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<UploadBoardImperativeHandlers>();
|
||
|
||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||
|
||
const [autocompleteQuery, setAutocompleteQuery] =
|
||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||
|
||
// 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();
|
||
|
||
// Subscribe write-side only: the value is read inside the effect below
|
||
// via the snapshot the effect captures from its closure deps.
|
||
const pendingShare = useAtomValue(pendingShareAtom);
|
||
const setPendingShare = useSetAtom(pendingShareAtom);
|
||
|
||
useEffect(() => {
|
||
Transforms.insertFragment(editor, msgDraft);
|
||
}, [editor, msgDraft]);
|
||
|
||
// Drain the global share-target hand-off into THIS chat. The system
|
||
// share-sheet flow doesn't open a picker — it just lights up the
|
||
// ShareTargetStrip banner and lets the user pick a chat by navigating
|
||
// normally. The first RoomInput that mounts (or, if the user was
|
||
// already inside a chat when the share arrived, the current RoomInput
|
||
// re-running this effect with a non-null `pendingShare`) consumes the
|
||
// payload: files into the upload board, text into the composer. The
|
||
// user can still bail by tapping the [×] on each upload card before
|
||
// pressing Send.
|
||
//
|
||
// Declared AFTER the msgDraft restore so the share text appends to
|
||
// any saved draft instead of getting overwritten by it.
|
||
//
|
||
// Thread composers (threadId set) deliberately skip — sharing into a
|
||
// thread isn't a flow users ask for, and would silently consume the
|
||
// share leaving the main composer empty.
|
||
useEffect(() => {
|
||
if (threadId) return;
|
||
if (!pendingShare) return;
|
||
// Clear first so a re-render mid-handleFiles can't queue another
|
||
// run for the same payload.
|
||
const consumed = pendingShare;
|
||
setPendingShare(null);
|
||
if (consumed.files.length > 0) {
|
||
handleFiles(consumed.files);
|
||
}
|
||
const { text } = consumed;
|
||
if (text) {
|
||
Transforms.insertText(editor, text);
|
||
}
|
||
}, [threadId, pendingShare, setPendingShare, handleFiles, editor]);
|
||
|
||
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 RoomMessageEventContent)
|
||
);
|
||
};
|
||
|
||
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 RoomMessageEventContent);
|
||
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<AutocompletePrefix>(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 = (
|
||
<IconButton
|
||
onClick={() => pickFile('*')}
|
||
variant="SurfaceVariant"
|
||
fill="None"
|
||
size="300"
|
||
radii="300"
|
||
>
|
||
<Icon src={StreamComposerIcons.Plus} />
|
||
</IconButton>
|
||
);
|
||
|
||
const emojiButton = (
|
||
<UseStateProvider initial={undefined}>
|
||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||
<PopOut
|
||
offset={16}
|
||
alignOffset={-44}
|
||
position="Top"
|
||
align="End"
|
||
anchor={
|
||
emojiBoardTab === undefined
|
||
? undefined
|
||
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
|
||
}
|
||
content={
|
||
<EmojiBoard
|
||
tab={emojiBoardTab ?? EmojiBoardTab.Emoji}
|
||
onTabChange={setEmojiBoardTab}
|
||
imagePackRooms={imagePackRooms}
|
||
returnFocusOnDeactivate={false}
|
||
onEmojiSelect={handleEmoticonSelect}
|
||
onCustomEmojiSelect={handleEmoticonSelect}
|
||
onStickerSelect={handleStickerSelect}
|
||
requestClose={() => {
|
||
setEmojiBoardTab((tab) => {
|
||
if (tab) {
|
||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||
return undefined;
|
||
}
|
||
return tab;
|
||
});
|
||
}}
|
||
/>
|
||
}
|
||
>
|
||
<IconButton
|
||
ref={emojiBtnRef}
|
||
aria-pressed={!!emojiBoardTab}
|
||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||
variant="SurfaceVariant"
|
||
fill="None"
|
||
size="300"
|
||
radii="300"
|
||
>
|
||
<Icon src={StreamComposerIcons.Smile} />
|
||
</IconButton>
|
||
</PopOut>
|
||
)}
|
||
</UseStateProvider>
|
||
);
|
||
|
||
const sendButton = (
|
||
<IconButton
|
||
onClick={(evt: ReactMouseEvent<HTMLButtonElement>) => {
|
||
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"
|
||
>
|
||
<Icon src={StreamComposerIcons.Send} />
|
||
</IconButton>
|
||
);
|
||
|
||
// 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 (re-memoize on
|
||
// chat/thread navigation), not values read inside the body.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [roomId, threadId]);
|
||
|
||
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
|
||
// eslint-disable-next-line react/no-array-index-key
|
||
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">
|
||
{t('Room.drop_files', { name: room?.name || 'Room' })}
|
||
</Text>
|
||
<Text align="Center">{t('Room.drag_drop_desc')}</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}
|
||
/>
|
||
)}
|
||
<CustomEditor
|
||
editableName="RoomInput"
|
||
editor={editor}
|
||
placeholder={t(placeholderKey)}
|
||
onKeyDown={handleKeyDown}
|
||
onKeyUp={handleKeyUp}
|
||
onPaste={handlePaste}
|
||
top={
|
||
replyDraft && (
|
||
<div>
|
||
<Box
|
||
alignItems="Center"
|
||
gap="300"
|
||
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||
>
|
||
<IconButton
|
||
onClick={() => setReplyDraft(undefined)}
|
||
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>
|
||
{getMemberDisplayName(room, replyDraft.userId) ??
|
||
getMxIdLocalPart(replyDraft.userId) ??
|
||
replyDraft.userId}
|
||
</b>
|
||
</Text>
|
||
}
|
||
>
|
||
<Text size="T300" truncate>
|
||
{trimReplyFromBody(replyDraft.body)}
|
||
</Text>
|
||
</ReplyLayout>
|
||
</Box>
|
||
</Box>
|
||
</div>
|
||
)
|
||
}
|
||
bottom={
|
||
<Box
|
||
alignItems="Center"
|
||
gap="200"
|
||
style={{ padding: `${toRem(2)} ${toRem(8)} ${toRem(4)}` }}
|
||
>
|
||
{plusButton}
|
||
<Box grow="Yes" />
|
||
{emojiButton}
|
||
{sendButton}
|
||
</Box>
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
);
|