2023-06-12 21:15:23 +10:00
|
|
|
|
import React, {
|
|
|
|
|
|
KeyboardEventHandler,
|
2026-07-01 09:07:03 -04:00
|
|
|
|
ReactNode,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
RefObject,
|
|
|
|
|
|
forwardRef,
|
|
|
|
|
|
useCallback,
|
|
|
|
|
|
useEffect,
|
2026-07-01 09:07:03 -04:00
|
|
|
|
useMemo,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
useRef,
|
|
|
|
|
|
useState,
|
|
|
|
|
|
} from 'react';
|
2026-06-04 10:26:08 -04:00
|
|
|
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
2023-10-21 18:14:33 +11:00
|
|
|
|
import { isKeyHotkey } from 'is-hotkey';
|
2024-08-15 16:52:32 +02:00
|
|
|
|
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
2023-06-12 21:15:23 +10:00
|
|
|
|
import { ReactEditor } from 'slate-react';
|
2023-10-18 13:15:30 +11:00
|
|
|
|
import { Transforms, Editor } from 'slate';
|
2023-06-12 21:15:23 +10:00
|
|
|
|
import {
|
|
|
|
|
|
Box,
|
|
|
|
|
|
Dialog,
|
|
|
|
|
|
Icon,
|
|
|
|
|
|
IconButton,
|
|
|
|
|
|
Icons,
|
2023-06-16 11:09:09 +10:00
|
|
|
|
Line,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
Overlay,
|
|
|
|
|
|
OverlayBackdrop,
|
|
|
|
|
|
OverlayCenter,
|
|
|
|
|
|
PopOut,
|
|
|
|
|
|
Scroll,
|
2026-05-24 00:02:19 -04:00
|
|
|
|
Spinner,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
Text,
|
2026-06-03 22:13:22 -04:00
|
|
|
|
color,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
config,
|
|
|
|
|
|
toRem,
|
|
|
|
|
|
} from 'folds';
|
|
|
|
|
|
|
|
|
|
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
2026-05-15 13:37:03 -04:00
|
|
|
|
import { useClientConfig } from '../../hooks/useClientConfig';
|
2023-06-12 21:15:23 +10:00
|
|
|
|
import {
|
|
|
|
|
|
CustomEditor,
|
|
|
|
|
|
Toolbar,
|
|
|
|
|
|
toMatrixCustomHTML,
|
|
|
|
|
|
toPlainText,
|
|
|
|
|
|
AUTOCOMPLETE_PREFIXES,
|
|
|
|
|
|
AutocompletePrefix,
|
|
|
|
|
|
AutocompleteQuery,
|
|
|
|
|
|
getAutocompleteQuery,
|
2026-08-11 00:23:15 -04:00
|
|
|
|
getPrevWordRange,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
resetEditor,
|
|
|
|
|
|
RoomMentionAutocomplete,
|
|
|
|
|
|
UserMentionAutocomplete,
|
|
|
|
|
|
EmoticonAutocomplete,
|
|
|
|
|
|
createEmoticonElement,
|
|
|
|
|
|
moveCursor,
|
2023-06-16 11:11:03 +10:00
|
|
|
|
resetEditorHistory,
|
2023-07-23 18:12:09 +10:00
|
|
|
|
customHtmlEqualsPlainText,
|
|
|
|
|
|
trimCustomHtml,
|
2023-10-14 16:08:43 +11:00
|
|
|
|
isEmptyEditor,
|
2023-10-18 13:15:30 +11:00
|
|
|
|
getBeginCommand,
|
|
|
|
|
|
trimCommand,
|
2025-02-23 11:08:08 +00:00
|
|
|
|
getMentions,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
} from '../../components/editor';
|
2026-05-20 21:39:35 -04:00
|
|
|
|
import { EmojiBoardTab } from '../../components/emoji-board/types';
|
2023-06-12 21:15:23 +10:00
|
|
|
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
2025-02-10 16:49:47 +11:00
|
|
|
|
import {
|
|
|
|
|
|
TUploadContent,
|
|
|
|
|
|
encryptFile,
|
|
|
|
|
|
getImageInfo,
|
|
|
|
|
|
mxcUrlToHttp,
|
2026-06-05 11:25:19 -04:00
|
|
|
|
tryDeleteMxcContent,
|
2025-02-10 16:49:47 +11:00
|
|
|
|
} from '../../utils/matrix';
|
2026-06-05 20:22:38 -04:00
|
|
|
|
import { compressImage, isCompressible } from '../../utils/imageCompression';
|
2023-06-12 21:15:23 +10:00
|
|
|
|
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
|
|
|
|
|
import { useFilePicker } from '../../hooks/useFilePicker';
|
|
|
|
|
|
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
|
|
|
|
|
import { useFileDropZone } from '../../hooks/useFileDrop';
|
|
|
|
|
|
import {
|
|
|
|
|
|
TUploadItem,
|
2025-02-26 21:43:43 +11:00
|
|
|
|
TUploadMetadata,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
roomIdToMsgDraftAtomFamily,
|
|
|
|
|
|
roomIdToReplyDraftAtomFamily,
|
|
|
|
|
|
roomIdToUploadItemsAtomFamily,
|
|
|
|
|
|
roomUploadAtomFamily,
|
2024-05-31 19:49:46 +05:30
|
|
|
|
} from '../../state/room/roomInputDrafts';
|
2023-06-12 21:15:23 +10:00
|
|
|
|
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';
|
2026-05-20 21:26:18 -04:00
|
|
|
|
import { useAlive } from '../../hooks/useAlive';
|
2026-07-01 09:07:03 -04:00
|
|
|
|
import {
|
|
|
|
|
|
ComposerToolbarButtonKey,
|
|
|
|
|
|
normalizeComposerToolbarOrder,
|
|
|
|
|
|
settingsAtom,
|
|
|
|
|
|
} from '../../state/settings';
|
2023-06-12 21:15:23 +10:00
|
|
|
|
import {
|
2026-09-12 02:08:03 -04:00
|
|
|
|
buildCompressedUploadItem,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
getAudioMsgContent,
|
|
|
|
|
|
getFileMsgContent,
|
|
|
|
|
|
getImageMsgContent,
|
|
|
|
|
|
getVideoMsgContent,
|
|
|
|
|
|
} from './msgContent';
|
2026-07-07 23:01:32 -04:00
|
|
|
|
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
2023-10-18 13:15:30 +11:00
|
|
|
|
import { CommandAutocomplete } from './CommandAutocomplete';
|
2026-09-19 12:27:15 -04:00
|
|
|
|
import {
|
|
|
|
|
|
Command,
|
|
|
|
|
|
CommandError,
|
|
|
|
|
|
SHRUG,
|
|
|
|
|
|
TABLEFLIP,
|
|
|
|
|
|
UNFLIP,
|
|
|
|
|
|
useCommands,
|
|
|
|
|
|
} from '../../hooks/useCommands';
|
2023-10-18 13:15:30 +11:00
|
|
|
|
import { mobileOrTablet } from '../../utils/user-agent';
|
2024-05-31 19:49:46 +05:30
|
|
|
|
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
2024-08-15 16:52:32 +02:00
|
|
|
|
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
2024-07-08 16:57:10 +05:30
|
|
|
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
2024-09-09 18:45:20 +10:00
|
|
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
2025-03-19 23:14:54 +11:00
|
|
|
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
2025-08-12 19:42:30 +05:30
|
|
|
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
2025-03-23 22:09:29 +11:00
|
|
|
|
import colorMXID from '../../../util/colorMXID';
|
|
|
|
|
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
2025-08-12 19:42:30 +05:30
|
|
|
|
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';
|
2025-09-24 23:35:42 -04:00
|
|
|
|
import { useComposingCheck } from '../../hooks/useComposingCheck';
|
2026-09-17 01:14:48 -04:00
|
|
|
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|
|
|
|
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
2026-05-23 12:19:06 -04:00
|
|
|
|
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
2026-06-03 00:14:55 -04:00
|
|
|
|
import { PollCreator } from './PollCreator';
|
2026-06-03 22:13:22 -04:00
|
|
|
|
import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationStatus';
|
2026-06-04 10:26:08 -04:00
|
|
|
|
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
|
|
|
|
|
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
2026-07-01 09:07:03 -04:00
|
|
|
|
import { DraftIndicator } from './DraftIndicator';
|
2026-06-04 10:26:08 -04:00
|
|
|
|
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
2026-07-07 22:29:39 -04:00
|
|
|
|
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
2026-07-01 21:45:20 -04:00
|
|
|
|
import { getThreadDraftKey } from '../../state/room/thread';
|
2026-09-17 01:46:21 -04:00
|
|
|
|
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-05-22 12:08:50 -04:00
|
|
|
|
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 })),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-09-15 21:32:06 -04:00
|
|
|
|
/** [Gitea #37] Debounce for persisting the composer draft while typing. */
|
|
|
|
|
|
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
|
|
|
|
|
|
2023-06-12 21:15:23 +10:00
|
|
|
|
interface RoomInputProps {
|
2023-10-06 13:44:06 +11:00
|
|
|
|
editor: Editor;
|
2024-05-31 19:49:46 +05:30
|
|
|
|
fileDropContainerRef: RefObject<HTMLElement>;
|
2023-06-12 21:15:23 +10:00
|
|
|
|
roomId: string;
|
2023-10-18 13:15:30 +11:00
|
|
|
|
room: Room;
|
2026-07-01 21:45:20 -04:00
|
|
|
|
threadRootId?: string;
|
2026-07-10 20:28:26 -04:00
|
|
|
|
// 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;
|
2026-09-19 13:05:10 -04:00
|
|
|
|
// [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;
|
2023-06-12 21:15:23 +10:00
|
|
|
|
}
|
|
|
|
|
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
2026-07-11 13:52:36 -04:00
|
|
|
|
(
|
2026-09-19 13:05:10 -04:00
|
|
|
|
{
|
|
|
|
|
|
editor,
|
|
|
|
|
|
fileDropContainerRef,
|
|
|
|
|
|
roomId,
|
|
|
|
|
|
room,
|
|
|
|
|
|
threadRootId,
|
|
|
|
|
|
editableName = 'RoomInput',
|
|
|
|
|
|
compactLayout = false,
|
|
|
|
|
|
},
|
2026-07-11 13:52:36 -04:00
|
|
|
|
ref,
|
|
|
|
|
|
) => {
|
2023-06-12 21:15:23 +10:00
|
|
|
|
const mx = useMatrixClient();
|
2024-09-09 18:45:20 +10:00
|
|
|
|
const useAuthentication = useMediaAuthentication();
|
2023-10-18 13:15:30 +11:00
|
|
|
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
2023-10-09 22:26:54 +11:00
|
|
|
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
2025-02-26 21:44:53 +11:00
|
|
|
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
2025-03-23 22:09:29 +11:00
|
|
|
|
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
2026-06-03 22:13:22 -04:00
|
|
|
|
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;
|
2025-03-23 22:09:29 +11:00
|
|
|
|
const direct = useIsDirectRoom();
|
2023-10-18 13:15:30 +11:00
|
|
|
|
const commands = useCommands(mx, room);
|
2024-05-31 19:49:46 +05:30
|
|
|
|
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
2024-07-08 16:57:10 +05:30
|
|
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
2025-03-23 22:09:29 +11:00
|
|
|
|
const powerLevels = usePowerLevelsContext();
|
2025-08-12 19:42:30 +05:30
|
|
|
|
const creators = useRoomCreators(room);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-06-02 15:36:45 -04:00
|
|
|
|
const [charCount, setCharCount] = useState(0);
|
2026-06-02 18:43:02 -04:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setCharCount(0);
|
|
|
|
|
|
}, [roomId]);
|
2026-06-03 00:14:55 -04:00
|
|
|
|
const [pollOpen, setPollOpen] = useState(false);
|
2026-06-04 10:26:08 -04:00
|
|
|
|
const [scheduleOpen, setScheduleOpen] = useState(false);
|
|
|
|
|
|
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
|
|
|
|
|
|
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
2026-07-07 22:29:39 -04:00
|
|
|
|
const setToast = useSetAtom(toastQueueAtom);
|
2026-06-02 15:36:45 -04:00
|
|
|
|
|
2026-05-21 20:49:33 -04:00
|
|
|
|
const alive = useAlive();
|
2026-07-01 21:45:20 -04:00
|
|
|
|
// 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));
|
2025-03-23 22:09:29 +11:00
|
|
|
|
const replyUserID = replyDraft?.userId;
|
|
|
|
|
|
|
2025-08-12 19:42:30 +05:30
|
|
|
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
|
|
|
|
|
const creatorsTag = useRoomCreatorsTag();
|
|
|
|
|
|
const getMemberPowerTag = useGetMemberPowerTag(room, creators, powerLevels);
|
|
|
|
|
|
const theme = useTheme();
|
|
|
|
|
|
const accessibleTagColors = useAccessiblePowerTagColors(
|
|
|
|
|
|
theme.kind,
|
|
|
|
|
|
creatorsTag,
|
2026-05-21 23:30:50 -04:00
|
|
|
|
powerLevelTags,
|
2025-08-12 19:42:30 +05:30
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const replyPowerTag = replyUserID ? getMemberPowerTag(replyUserID) : undefined;
|
|
|
|
|
|
const replyPowerColor = replyPowerTag?.color
|
2025-03-23 22:09:29 +11:00
|
|
|
|
? accessibleTagColors.get(replyPowerTag.color)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
const replyUsernameColor =
|
|
|
|
|
|
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
|
|
|
|
|
|
2023-06-12 21:15:23 +10:00
|
|
|
|
const [uploadBoard, setUploadBoard] = useState(true);
|
2026-07-01 21:45:20 -04:00
|
|
|
|
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
|
2023-06-12 21:15:23 +10:00
|
|
|
|
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
|
|
|
|
|
roomUploadAtomFamily,
|
2026-05-21 23:30:50 -04:00
|
|
|
|
selectedFiles.map((f) => f.file),
|
2023-06-12 21:15:23 +10:00
|
|
|
|
);
|
2026-05-22 13:24:07 -04:00
|
|
|
|
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(undefined);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2025-03-19 23:14:54 +11:00
|
|
|
|
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
|
|
|
|
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
2026-06-10 13:20:29 -04:00
|
|
|
|
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
2026-07-24 14:44:13 -04:00
|
|
|
|
const isMobile = mobileOrTablet();
|
2026-09-17 01:14:48 -04:00
|
|
|
|
const screenSize = useScreenSizeContext();
|
|
|
|
|
|
// Compact composer: the secondary action buttons (attach, GIF, poll,
|
2026-07-24 14:44:13 -04:00
|
|
|
|
// location, voice, formatting, schedule) collapse behind a "+" toggle so the
|
2026-09-17 01:14:48 -04:00
|
|
|
|
// 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.
|
2026-09-19 13:05:10 -04:00
|
|
|
|
const compact = isMobile || screenSize === ScreenSize.Mobile || compactLayout;
|
2026-07-24 14:44:13 -04:00
|
|
|
|
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
2026-09-17 01:14:48 -04:00
|
|
|
|
// 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).
|
2026-07-24 14:44:13 -04:00
|
|
|
|
const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
2026-06-10 13:20:29 -04:00
|
|
|
|
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
|
|
|
|
|
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
|
|
|
|
|
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
2026-09-12 20:28:41 -04:00
|
|
|
|
// [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');
|
2026-09-17 01:46:21 -04:00
|
|
|
|
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
|
|
|
|
|
|
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
2026-09-12 20:28:41 -04:00
|
|
|
|
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
2026-06-10 13:20:29 -04:00
|
|
|
|
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
|
|
|
|
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
|
|
|
|
|
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
2026-09-12 02:08:03 -04:00
|
|
|
|
// 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;
|
2026-07-01 09:07:03 -04:00
|
|
|
|
const composerButtonOrder = useMemo(
|
|
|
|
|
|
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
|
|
|
|
|
[composerToolbarButtons?.order],
|
|
|
|
|
|
);
|
2026-05-15 13:37:03 -04:00
|
|
|
|
const [locating, setLocating] = React.useState(false);
|
2026-05-24 00:02:19 -04:00
|
|
|
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
2026-06-24 08:22:00 -04:00
|
|
|
|
const handleShareLocation = useCallback(() => {
|
2026-05-24 00:02:19 -04:00
|
|
|
|
if (!navigator.geolocation) {
|
|
|
|
|
|
setLocationError('Geolocation not supported.');
|
|
|
|
|
|
setTimeout(() => setLocationError(null), 4000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-15 13:37:03 -04:00
|
|
|
|
setLocating(true);
|
|
|
|
|
|
navigator.geolocation.getCurrentPosition(
|
|
|
|
|
|
(pos) => {
|
|
|
|
|
|
setLocating(false);
|
|
|
|
|
|
const { latitude, longitude } = pos.coords;
|
|
|
|
|
|
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
2026-07-10 16:08:32 -04:00
|
|
|
|
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.
|
2026-07-01 21:45:20 -04:00
|
|
|
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
2026-05-15 13:37:03 -04:00
|
|
|
|
msgtype: 'm.location',
|
2026-07-10 16:08:32 -04:00
|
|
|
|
body: `Shared a location: ${geoUri}`,
|
2026-05-15 13:37:03 -04:00
|
|
|
|
geo_uri: geoUri,
|
2026-07-10 16:08:32 -04:00
|
|
|
|
'org.matrix.msc3488.location': { uri: geoUri },
|
|
|
|
|
|
'org.matrix.msc3488.asset': { type: 'm.self' },
|
|
|
|
|
|
'org.matrix.msc3488.ts': ts,
|
|
|
|
|
|
'm.ts': ts,
|
2026-05-15 13:37:03 -04:00
|
|
|
|
} as any);
|
|
|
|
|
|
},
|
2026-05-24 00:02:19 -04:00
|
|
|
|
(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);
|
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
{ timeout: 10000 },
|
2026-05-15 13:37:03 -04:00
|
|
|
|
);
|
2026-07-01 21:45:20 -04:00
|
|
|
|
}, [mx, roomId, threadRootId]);
|
2026-05-15 13:37:03 -04:00
|
|
|
|
|
2026-05-23 12:19:06 -04:00
|
|
|
|
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);
|
2026-07-01 21:45:20 -04:00
|
|
|
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
2026-05-23 12:19:06 -04:00
|
|
|
|
...baseContent,
|
|
|
|
|
|
file: { ...encInfo, url: uploadResult.content_uri },
|
|
|
|
|
|
} as any);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const uploadResult = await mx.uploadContent(blob, {
|
|
|
|
|
|
name: 'voice-message.ogg',
|
|
|
|
|
|
type: mimeType,
|
|
|
|
|
|
});
|
2026-07-01 21:45:20 -04:00
|
|
|
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
2026-05-23 12:19:06 -04:00
|
|
|
|
...baseContent,
|
|
|
|
|
|
url: uploadResult.content_uri,
|
|
|
|
|
|
} as any);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-07-01 21:45:20 -04:00
|
|
|
|
[mx, room, roomId, threadRootId],
|
2026-05-23 12:19:06 -04:00
|
|
|
|
);
|
|
|
|
|
|
|
2023-06-12 21:15:23 +10:00
|
|
|
|
const [autocompleteQuery, setAutocompleteQuery] =
|
|
|
|
|
|
useState<AutocompleteQuery<AutocompletePrefix>>();
|
|
|
|
|
|
|
|
|
|
|
|
const sendTypingStatus = useTypingStatusUpdater(mx, roomId);
|
|
|
|
|
|
|
|
|
|
|
|
const handleFiles = useCallback(
|
|
|
|
|
|
async (files: File[]) => {
|
|
|
|
|
|
setUploadBoard(true);
|
|
|
|
|
|
const safeFiles = files.map(safeFile);
|
|
|
|
|
|
const fileItems: TUploadItem[] = [];
|
|
|
|
|
|
|
2025-02-10 16:49:47 +11:00
|
|
|
|
if (room.hasEncryptionStateEvent()) {
|
2023-06-12 21:15:23 +10:00
|
|
|
|
const encryptFiles = fulfilledPromiseSettledResult(
|
2026-05-21 23:30:50 -04:00
|
|
|
|
await Promise.allSettled(safeFiles.map((f) => encryptFile(f))),
|
2023-06-12 21:15:23 +10:00
|
|
|
|
);
|
2025-02-22 03:55:13 -05:00
|
|
|
|
encryptFiles.forEach((ef) =>
|
|
|
|
|
|
fileItems.push({
|
|
|
|
|
|
...ef,
|
|
|
|
|
|
metadata: {
|
|
|
|
|
|
markedAsSpoiler: false,
|
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
}),
|
2025-02-22 03:55:13 -05:00
|
|
|
|
);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
} else {
|
|
|
|
|
|
safeFiles.forEach((f) =>
|
2025-02-22 03:55:13 -05:00
|
|
|
|
fileItems.push({
|
|
|
|
|
|
file: f,
|
|
|
|
|
|
originalFile: f,
|
|
|
|
|
|
encInfo: undefined,
|
|
|
|
|
|
metadata: {
|
|
|
|
|
|
markedAsSpoiler: false,
|
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
}),
|
2023-06-12 21:15:23 +10:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
setSelectedFiles({
|
|
|
|
|
|
type: 'PUT',
|
|
|
|
|
|
item: fileItems,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
[setSelectedFiles, room],
|
2023-06-12 21:15:23 +10:00
|
|
|
|
);
|
|
|
|
|
|
const pickFile = useFilePicker(handleFiles, true);
|
2026-09-17 01:46:21 -04:00
|
|
|
|
const handleFilePaste = useFilePasteHandler(handleFiles);
|
|
|
|
|
|
const handlePaste = useCallback<React.ClipboardEventHandler>(
|
|
|
|
|
|
(evt) => {
|
|
|
|
|
|
handleFilePaste(evt);
|
|
|
|
|
|
if (evt.defaultPrevented || !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],
|
|
|
|
|
|
);
|
2024-05-31 19:49:46 +05:30
|
|
|
|
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
2026-05-15 13:37:03 -04:00
|
|
|
|
const { gifApiKey } = useClientConfig();
|
|
|
|
|
|
const gifBtnRef = useRef<HTMLButtonElement>(null);
|
2024-05-31 19:49:46 +05:30
|
|
|
|
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
|
2026-05-19 16:45:02 -04:00
|
|
|
|
const [gifError, setGifError] = React.useState<string | null>(null);
|
2026-05-30 17:13:54 -04:00
|
|
|
|
const [gifUploading, setGifUploading] = React.useState(false);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2025-09-24 23:35:42 -04:00
|
|
|
|
const isComposing = useComposingCheck();
|
|
|
|
|
|
|
2024-05-31 19:49:46 +05:30
|
|
|
|
useElementSizeObserver(
|
2026-03-10 22:45:26 +11:00
|
|
|
|
useCallback(() => fileDropContainerRef.current, [fileDropContainerRef]),
|
2026-05-21 23:30:50 -04:00
|
|
|
|
useCallback((width) => setHideStickerBtn(width < 500), []),
|
2024-05-31 19:49:46 +05:30
|
|
|
|
);
|
2023-06-16 11:09:09 +10:00
|
|
|
|
|
2026-05-20 21:26:18 -04:00
|
|
|
|
const didRestoreDraft = React.useRef(false);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
useEffect(() => {
|
2026-05-20 21:26:18 -04:00
|
|
|
|
if (didRestoreDraft.current) return;
|
|
|
|
|
|
didRestoreDraft.current = true;
|
|
|
|
|
|
if (msgDraft.length > 0) {
|
|
|
|
|
|
Transforms.insertFragment(editor, msgDraft);
|
2026-05-30 22:22:40 -04:00
|
|
|
|
} else {
|
|
|
|
|
|
// Jotai draft is empty (page reload) — try localStorage fallback
|
|
|
|
|
|
try {
|
2026-07-01 21:45:20 -04:00
|
|
|
|
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
|
2026-05-30 22:22:40 -04:00
|
|
|
|
if (stored) {
|
2026-09-12 14:48:34 -04:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-30 22:22:40 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Ignore malformed stored draft
|
|
|
|
|
|
}
|
2026-05-20 21:26:18 -04:00
|
|
|
|
}
|
2026-09-12 14:48:34 -04:00
|
|
|
|
}, [editor, msgDraft, draftKey, setMsgDraft, mx]);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-09-15 21:32:06 -04:00
|
|
|
|
// 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;
|
2023-10-18 13:15:30 +11:00
|
|
|
|
}
|
2026-09-15 21:32:06 -04:00
|
|
|
|
persistDraft();
|
|
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener('pagehide', flush);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
window.removeEventListener('pagehide', flush);
|
|
|
|
|
|
flush();
|
2023-06-12 21:15:23 +10:00
|
|
|
|
resetEditor(editor);
|
2023-06-16 11:11:03 +10:00
|
|
|
|
resetEditorHistory(editor);
|
2026-09-15 21:32:06 -04:00
|
|
|
|
};
|
|
|
|
|
|
}, [editor, persistDraft]);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2025-02-26 21:43:43 +11:00
|
|
|
|
const handleFileMetadata = useCallback(
|
|
|
|
|
|
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
|
|
|
|
|
|
setSelectedFiles({
|
|
|
|
|
|
type: 'REPLACE',
|
|
|
|
|
|
item: fileItem,
|
|
|
|
|
|
replacement: { ...fileItem, metadata },
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
[setSelectedFiles],
|
2025-02-26 21:43:43 +11:00
|
|
|
|
);
|
|
|
|
|
|
|
2023-06-12 21:15:23 +10:00
|
|
|
|
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));
|
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
[setSelectedFiles, selectedFiles],
|
2023-06-12 21:15:23 +10:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-24 08:22:00 -04:00
|
|
|
|
const handleCancelUpload = useCallback(
|
|
|
|
|
|
(uploads: Upload[]) => {
|
|
|
|
|
|
uploads.forEach((upload) => {
|
|
|
|
|
|
if (upload.status === UploadStatus.Loading) {
|
|
|
|
|
|
mx.cancelUpload(upload.promise);
|
2026-06-04 10:26:08 -04:00
|
|
|
|
}
|
2026-06-24 08:22:00 -04:00
|
|
|
|
});
|
|
|
|
|
|
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;
|
2026-06-04 10:26:08 -04:00
|
|
|
|
|
2026-06-24 08:22:00 -04:00
|
|
|
|
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;
|
2026-07-02 20:56:27 -04:00
|
|
|
|
// 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,
|
2026-06-24 08:22:00 -04:00
|
|
|
|
});
|
2026-09-12 02:08:03 -04:00
|
|
|
|
// 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,
|
|
|
|
|
|
});
|
2026-06-24 08:22:00 -04:00
|
|
|
|
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;
|
2026-09-12 02:08:03 -04:00
|
|
|
|
// Synthetic fileItem referring to the compressed file so
|
|
|
|
|
|
// getImageMsgContent picks up the correct dimensions, type and encInfo.
|
|
|
|
|
|
const compressedItem = buildCompressedUploadItem(
|
|
|
|
|
|
fileItem,
|
|
|
|
|
|
compressedFile,
|
|
|
|
|
|
encrypted,
|
|
|
|
|
|
);
|
2026-06-24 08:22:00 -04:00
|
|
|
|
return getImageMsgContent(mx, compressedItem, mxc);
|
|
|
|
|
|
}
|
2026-06-04 10:26:08 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-24 08:22:00 -04:00
|
|
|
|
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));
|
2026-07-01 21:45:20 -04:00
|
|
|
|
contents.forEach((content) => mx.sendMessage(roomId, threadRootId ?? null, content as any));
|
2026-06-24 08:22:00 -04:00
|
|
|
|
},
|
2026-07-01 21:45:20 -04:00
|
|
|
|
[mx, roomId, threadRootId, selectedFiles, handleCancelUpload],
|
2026-06-24 08:22:00 -04:00
|
|
|
|
);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
|
|
|
|
|
const submit = useCallback(() => {
|
|
|
|
|
|
uploadBoardHandlers.current?.handleSend();
|
|
|
|
|
|
|
2026-07-10 22:34:24 -04:00
|
|
|
|
// 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);
|
2025-02-21 19:19:24 +11:00
|
|
|
|
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
2023-10-18 13:15:30 +11:00
|
|
|
|
let customHtml = trimCustomHtml(
|
2023-10-09 22:26:54 +11:00
|
|
|
|
toMatrixCustomHTML(editor.children, {
|
|
|
|
|
|
allowTextFormatting: true,
|
2023-10-27 21:27:22 +11:00
|
|
|
|
allowBlockMarkdown: isMarkdown,
|
|
|
|
|
|
allowInlineMarkdown: isMarkdown,
|
2026-07-07 12:46:25 -04:00
|
|
|
|
allowMath: true,
|
2026-05-21 23:30:50 -04:00
|
|
|
|
}),
|
2023-10-09 22:26:54 +11:00
|
|
|
|
);
|
2023-10-18 13:15:30 +11:00
|
|
|
|
let msgType = MsgType.Text;
|
|
|
|
|
|
|
|
|
|
|
|
if (commandName) {
|
|
|
|
|
|
plainText = trimCommand(commandName, plainText);
|
|
|
|
|
|
customHtml = trimCommand(commandName, customHtml);
|
|
|
|
|
|
}
|
2026-09-17 01:46:21 -04:00
|
|
|
|
if (stripTracking) {
|
|
|
|
|
|
plainText = stripTrackingParamsInText(plainText);
|
|
|
|
|
|
customHtml = stripTrackingParamsInHtml(customHtml);
|
|
|
|
|
|
}
|
2023-10-18 13:15:30 +11:00
|
|
|
|
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}`;
|
2024-12-13 05:32:25 +01:00
|
|
|
|
} else if (commandName === Command.TableFlip) {
|
|
|
|
|
|
plainText = `${TABLEFLIP} ${plainText}`;
|
|
|
|
|
|
customHtml = `${TABLEFLIP} ${customHtml}`;
|
|
|
|
|
|
} else if (commandName === Command.UnFlip) {
|
|
|
|
|
|
plainText = `${UNFLIP} ${plainText}`;
|
|
|
|
|
|
customHtml = `${UNFLIP} ${customHtml}`;
|
2023-10-18 13:15:30 +11:00
|
|
|
|
} else if (commandName) {
|
|
|
|
|
|
const commandContent = commands[commandName as Command];
|
|
|
|
|
|
if (commandContent) {
|
2026-07-07 22:29:39 -04:00
|
|
|
|
// Fire-and-forget by design (the editor resets immediately for UX), but
|
2026-09-19 12:27:15 -04:00
|
|
|
|
// 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.
|
2026-07-07 22:29:39 -04:00
|
|
|
|
commandContent.exe(plainText).catch((err) => {
|
|
|
|
|
|
console.error(`Failed to run /${commandName} command:`, err);
|
|
|
|
|
|
setToast(
|
|
|
|
|
|
createErrorToast(
|
2026-09-19 12:27:15 -04:00
|
|
|
|
err instanceof CommandError
|
|
|
|
|
|
? err.message
|
|
|
|
|
|
: `The /${commandName} command failed. Please try again.`,
|
2026-07-07 22:29:39 -04:00
|
|
|
|
Icons.Warning,
|
|
|
|
|
|
'Command failed',
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2023-10-18 13:15:30 +11:00
|
|
|
|
}
|
|
|
|
|
|
resetEditor(editor);
|
|
|
|
|
|
resetEditorHistory(editor);
|
2026-07-02 21:40:07 -04:00
|
|
|
|
setCharCount(0);
|
2023-10-18 13:15:30 +11:00
|
|
|
|
sendTypingStatus(false);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
|
|
|
|
|
if (plainText === '') return;
|
|
|
|
|
|
|
2025-02-23 11:08:08 +00:00
|
|
|
|
const body = plainText;
|
|
|
|
|
|
const formattedBody = customHtml;
|
|
|
|
|
|
const mentionData = getMentions(mx, roomId, editor);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
|
|
|
|
|
const content: IContent = {
|
2023-10-18 13:15:30 +11:00
|
|
|
|
msgtype: msgType,
|
2023-06-12 21:15:23 +10:00
|
|
|
|
body,
|
|
|
|
|
|
};
|
2025-02-23 11:08:08 +00:00
|
|
|
|
|
|
|
|
|
|
if (replyDraft && replyDraft.userId !== mx.getUserId()) {
|
|
|
|
|
|
mentionData.users.add(replyDraft.userId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
|
|
|
|
|
|
content['m.mentions'] = mMentions;
|
|
|
|
|
|
|
2023-07-23 18:12:09 +10:00
|
|
|
|
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
|
|
|
|
|
|
content.format = 'org.matrix.custom.html';
|
|
|
|
|
|
content.formatted_body = formattedBody;
|
|
|
|
|
|
}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
if (replyDraft) {
|
|
|
|
|
|
content['m.relates_to'] = {
|
|
|
|
|
|
'm.in_reply_to': {
|
|
|
|
|
|
event_id: replyDraft.eventId,
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
2024-08-15 16:52:32 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
}
|
2026-07-01 21:45:20 -04:00
|
|
|
|
mx.sendMessage(roomId, threadRootId ?? null, content as any);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
resetEditor(editor);
|
2023-06-16 11:11:03 +10:00
|
|
|
|
resetEditorHistory(editor);
|
2026-07-02 21:40:07 -04:00
|
|
|
|
setCharCount(0);
|
2026-07-10 11:48:35 -04:00
|
|
|
|
setMsgDraft([]);
|
2026-07-01 21:45:20 -04:00
|
|
|
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
2024-05-31 19:49:46 +05:30
|
|
|
|
setReplyDraft(undefined);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
sendTypingStatus(false);
|
2026-07-01 21:45:20 -04:00
|
|
|
|
}, [
|
|
|
|
|
|
mx,
|
|
|
|
|
|
roomId,
|
|
|
|
|
|
threadRootId,
|
|
|
|
|
|
draftKey,
|
|
|
|
|
|
editor,
|
|
|
|
|
|
replyDraft,
|
|
|
|
|
|
sendTypingStatus,
|
|
|
|
|
|
setReplyDraft,
|
2026-07-10 11:48:35 -04:00
|
|
|
|
setMsgDraft,
|
2026-07-01 21:45:20 -04:00
|
|
|
|
isMarkdown,
|
|
|
|
|
|
commands,
|
2026-07-07 22:29:39 -04:00
|
|
|
|
setToast,
|
2026-09-17 01:46:21 -04:00
|
|
|
|
stripTracking,
|
2026-07-01 21:45:20 -04:00
|
|
|
|
]);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-06-04 10:26:08 -04:00
|
|
|
|
/**
|
|
|
|
|
|
* 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,
|
2026-07-07 12:46:25 -04:00
|
|
|
|
allowMath: true,
|
2026-06-04 10:26:08 -04:00
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
if (plainText === '') return null;
|
|
|
|
|
|
|
2026-09-17 01:46:21 -04:00
|
|
|
|
const body = stripTracking ? stripTrackingParamsInText(plainText) : plainText;
|
|
|
|
|
|
const formattedBody = stripTracking ? stripTrackingParamsInHtml(customHtml) : customHtml;
|
2026-06-04 10:26:08 -04:00
|
|
|
|
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;
|
2026-09-17 01:46:21 -04:00
|
|
|
|
}, [editor, isMarkdown, mx, roomId, replyDraft, stripTracking]);
|
2026-06-04 10:26:08 -04:00
|
|
|
|
|
|
|
|
|
|
const handleScheduleClick = useCallback(() => {
|
2026-09-12 02:08:03 -04:00
|
|
|
|
// 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;
|
2026-06-04 12:07:12 -04:00
|
|
|
|
// Pre-fill from editor if there's content; open blank if editor is empty.
|
2026-06-04 10:26:08 -04:00
|
|
|
|
const content = buildCurrentTextContent();
|
|
|
|
|
|
setScheduleContent(content);
|
|
|
|
|
|
setScheduleOpen(true);
|
2026-09-12 02:08:03 -04:00
|
|
|
|
}, [buildCurrentTextContent, isEncrypted]);
|
2026-06-04 10:26:08 -04:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-09-15 21:32:06 -04:00
|
|
|
|
// [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.
|
2026-06-04 10:26:08 -04:00
|
|
|
|
resetEditor(editor);
|
|
|
|
|
|
resetEditorHistory(editor);
|
2026-07-10 11:48:35 -04:00
|
|
|
|
setMsgDraft([]);
|
2026-07-01 21:45:20 -04:00
|
|
|
|
localStorage.removeItem(`draft-msg-${draftKey}`);
|
2026-06-04 10:26:08 -04:00
|
|
|
|
setReplyDraft(undefined);
|
|
|
|
|
|
sendTypingStatus(false);
|
|
|
|
|
|
},
|
2026-07-10 11:48:35 -04:00
|
|
|
|
[
|
|
|
|
|
|
setScheduledMessages,
|
|
|
|
|
|
roomId,
|
|
|
|
|
|
draftKey,
|
|
|
|
|
|
editor,
|
|
|
|
|
|
setReplyDraft,
|
|
|
|
|
|
setMsgDraft,
|
|
|
|
|
|
sendTypingStatus,
|
|
|
|
|
|
],
|
2026-06-04 10:26:08 -04:00
|
|
|
|
);
|
|
|
|
|
|
|
2023-06-12 21:15:23 +10:00
|
|
|
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
|
|
|
|
|
(evt) => {
|
2025-03-23 22:09:29 +11:00
|
|
|
|
if (
|
|
|
|
|
|
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
|
2025-09-24 23:35:42 -04:00
|
|
|
|
!isComposing(evt)
|
2025-03-23 22:09:29 +11:00
|
|
|
|
) {
|
2023-06-12 21:15:23 +10:00
|
|
|
|
evt.preventDefault();
|
|
|
|
|
|
submit();
|
|
|
|
|
|
}
|
2023-10-21 18:14:33 +11:00
|
|
|
|
if (isKeyHotkey('escape', evt)) {
|
2026-07-02 00:18:51 -04:00
|
|
|
|
// 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.
|
2025-02-26 21:42:42 +11:00
|
|
|
|
if (autocompleteQuery) {
|
2026-07-02 00:18:51 -04:00
|
|
|
|
evt.preventDefault();
|
|
|
|
|
|
evt.stopPropagation();
|
2025-02-26 21:42:42 +11:00
|
|
|
|
setAutocompleteQuery(undefined);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-07-02 00:18:51 -04:00
|
|
|
|
if (replyDraft) {
|
|
|
|
|
|
evt.preventDefault();
|
|
|
|
|
|
evt.stopPropagation();
|
|
|
|
|
|
setReplyDraft(undefined);
|
|
|
|
|
|
}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-07-02 00:18:51 -04:00
|
|
|
|
[submit, replyDraft, setReplyDraft, enterForNewline, autocompleteQuery, isComposing],
|
2023-06-12 21:15:23 +10:00
|
|
|
|
);
|
|
|
|
|
|
|
2023-10-14 16:08:43 +11:00
|
|
|
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
|
|
|
|
|
(evt) => {
|
2023-10-21 18:14:33 +11:00
|
|
|
|
if (isKeyHotkey('escape', evt)) {
|
2023-10-14 16:08:43 +11:00
|
|
|
|
evt.preventDefault();
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-02-26 21:44:53 +11:00
|
|
|
|
if (!hideActivity) {
|
|
|
|
|
|
sendTypingStatus(!isEmptyEditor(editor));
|
|
|
|
|
|
}
|
2023-10-14 16:08:43 +11:00
|
|
|
|
|
2026-08-11 00:23:15 -04:00
|
|
|
|
const prevWordRange = getPrevWordRange(editor);
|
2023-10-14 16:08:43 +11:00
|
|
|
|
const query = prevWordRange
|
|
|
|
|
|
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
setAutocompleteQuery(query);
|
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
|
[editor, sendTypingStatus, hideActivity],
|
2023-10-14 16:08:43 +11:00
|
|
|
|
);
|
2023-10-06 13:44:06 +11:00
|
|
|
|
|
2023-10-18 13:15:30 +11:00
|
|
|
|
const handleCloseAutocomplete = useCallback(() => {
|
|
|
|
|
|
setAutocompleteQuery(undefined);
|
|
|
|
|
|
ReactEditor.focus(editor);
|
|
|
|
|
|
}, [editor]);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-06-24 08:22:00 -04:00
|
|
|
|
const handleEmoticonSelect = useCallback(
|
|
|
|
|
|
(key: string, shortcode: string) => {
|
|
|
|
|
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
|
|
|
|
|
moveCursor(editor);
|
|
|
|
|
|
},
|
|
|
|
|
|
[editor],
|
|
|
|
|
|
);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-05-15 13:37:03 -04:00
|
|
|
|
const handleGifSelect = useCallback(
|
|
|
|
|
|
async (gifUrl: string, w: number, h: number) => {
|
2026-05-30 17:13:54 -04:00
|
|
|
|
setGifUploading(true);
|
2026-05-15 13:37:03 -04:00
|
|
|
|
try {
|
2026-06-02 09:55:04 -04:00
|
|
|
|
// Only fetch from trusted Giphy CDN domains (match any *.giphy.com subdomain)
|
2026-05-15 15:08:55 -04:00
|
|
|
|
const { hostname } = new URL(gifUrl);
|
2026-06-02 09:55:04 -04:00
|
|
|
|
if (!hostname.endsWith('.giphy.com') && hostname !== 'giphy.com') {
|
|
|
|
|
|
setGifError('GIF source not trusted.');
|
|
|
|
|
|
setTimeout(() => setGifError(null), 4000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-15 15:08:55 -04:00
|
|
|
|
|
2026-05-15 13:37:03 -04:00
|
|
|
|
const res = await fetch(gifUrl);
|
2026-06-02 09:55:04 -04:00
|
|
|
|
if (!res.ok) {
|
|
|
|
|
|
setGifError('Failed to download GIF from Giphy.');
|
|
|
|
|
|
setTimeout(() => setGifError(null), 4000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-15 15:08:55 -04:00
|
|
|
|
const contentType = res.headers.get('content-type') ?? '';
|
2026-06-02 09:55:04 -04:00
|
|
|
|
if (!contentType.startsWith('image/')) {
|
|
|
|
|
|
setGifError('Unexpected GIF format. Please try another.');
|
|
|
|
|
|
setTimeout(() => setGifError(null), 4000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-15 15:08:55 -04:00
|
|
|
|
|
2026-05-15 13:37:03 -04:00
|
|
|
|
const blob = await res.blob();
|
2026-05-19 16:45:02 -04:00
|
|
|
|
if (blob.size > 20 * 1024 * 1024) {
|
|
|
|
|
|
setGifError('GIF is too large (max 20 MB).');
|
|
|
|
|
|
setTimeout(() => setGifError(null), 4000);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-15 15:08:55 -04:00
|
|
|
|
|
2026-09-12 02:08:03 -04:00
|
|
|
|
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' });
|
|
|
|
|
|
const baseContent = {
|
2026-05-15 13:37:03 -04:00
|
|
|
|
msgtype: MsgType.Image,
|
|
|
|
|
|
body: 'image.gif',
|
|
|
|
|
|
info: { mimetype: 'image/gif', w, h, size: blob.size },
|
2026-09-12 02:08:03 -04:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Mirror the attachment/voice paths: in an encrypted room the media
|
|
|
|
|
|
// itself must be encrypted, otherwise the homeserver (and anyone with
|
|
|
|
|
|
// the mxc URI) can see the GIF even though the event body is encrypted.
|
|
|
|
|
|
if (room.hasEncryptionStateEvent()) {
|
|
|
|
|
|
const { encInfo, file: encBlob } = await encryptFile(gifFile);
|
|
|
|
|
|
const uploadRes = await mx.uploadContent(encBlob);
|
|
|
|
|
|
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
|
|
|
|
|
if (!mxcUrl) return;
|
|
|
|
|
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
|
|
|
|
|
...baseContent,
|
|
|
|
|
|
file: { ...encInfo, url: mxcUrl },
|
|
|
|
|
|
} as any);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const uploadRes = await mx.uploadContent(gifFile, {
|
|
|
|
|
|
type: 'image/gif',
|
|
|
|
|
|
name: 'image.gif',
|
|
|
|
|
|
includeFilename: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
|
|
|
|
|
if (!mxcUrl) return;
|
|
|
|
|
|
mx.sendMessage(roomId, threadRootId ?? null, {
|
|
|
|
|
|
...baseContent,
|
|
|
|
|
|
url: mxcUrl,
|
|
|
|
|
|
} as any);
|
|
|
|
|
|
}
|
2026-05-15 13:37:03 -04:00
|
|
|
|
} catch (e) {
|
2026-06-18 19:24:52 -04:00
|
|
|
|
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
|
2026-05-20 21:26:18 -04:00
|
|
|
|
if (!alive()) return;
|
2026-05-19 16:45:02 -04:00
|
|
|
|
setGifError('Failed to send GIF. Please try again.');
|
|
|
|
|
|
setTimeout(() => setGifError(null), 4000);
|
2026-05-30 17:13:54 -04:00
|
|
|
|
} finally {
|
|
|
|
|
|
if (alive()) setGifUploading(false);
|
2026-05-15 13:37:03 -04:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-09-12 02:08:03 -04:00
|
|
|
|
[mx, room, roomId, threadRootId, alive],
|
2026-05-15 13:37:03 -04:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-24 08:22:00 -04:00
|
|
|
|
const handleStickerSelect = useCallback(
|
|
|
|
|
|
async (mxc: string, shortcode: string, label: string) => {
|
|
|
|
|
|
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
|
|
|
|
|
if (!stickerUrl) return;
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-06-24 08:22:00 -04:00
|
|
|
|
const info = await getImageInfo(
|
|
|
|
|
|
await loadImageElement(stickerUrl),
|
|
|
|
|
|
await getImageUrlBlob(stickerUrl),
|
|
|
|
|
|
);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-07-01 21:45:20 -04:00
|
|
|
|
mx.sendEvent(roomId, threadRootId ?? null, EventType.Sticker, {
|
2026-06-24 08:22:00 -04:00
|
|
|
|
body: label,
|
|
|
|
|
|
url: mxc,
|
|
|
|
|
|
info,
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
2026-07-01 21:45:20 -04:00
|
|
|
|
[mx, roomId, threadRootId, useAuthentication],
|
2026-06-24 08:22:00 -04:00
|
|
|
|
);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
|
2026-06-01 17:15:01 -04:00
|
|
|
|
if (room.getType() === 'm.server_notice') {
|
|
|
|
|
|
return (
|
2026-06-01 21:30:27 -04:00
|
|
|
|
<Box
|
|
|
|
|
|
ref={ref as React.Ref<HTMLDivElement>}
|
|
|
|
|
|
direction="Column"
|
|
|
|
|
|
alignItems="Center"
|
|
|
|
|
|
justifyContent="Center"
|
|
|
|
|
|
style={{ padding: config.space.S300 }}
|
|
|
|
|
|
>
|
2026-06-01 17:15:01 -04:00
|
|
|
|
<Text size="T300" priority="300">
|
2026-06-01 21:30:27 -04:00
|
|
|
|
This room contains system messages from your homeserver. Replies are not permitted.
|
2026-06-01 17:15:01 -04:00
|
|
|
|
</Text>
|
2026-06-01 21:30:27 -04:00
|
|
|
|
</Box>
|
2026-06-01 17:15:01 -04:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-24 14:44:13 -04:00
|
|
|
|
// 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;
|
|
|
|
|
|
|
2023-06-12 21:15:23 +10:00
|
|
|
|
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}
|
2025-02-22 03:55:13 -05:00
|
|
|
|
fileItem={fileItem}
|
2025-02-26 21:43:43 +11:00
|
|
|
|
setMetadata={handleFileMetadata}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
onRemove={handleRemoveUpload}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</UploadBoardContent>
|
|
|
|
|
|
</Scroll>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</UploadBoard>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<Overlay
|
|
|
|
|
|
open={dropZoneVisible}
|
|
|
|
|
|
backdrop={<OverlayBackdrop />}
|
|
|
|
|
|
style={{ pointerEvents: 'none' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<OverlayCenter>
|
|
|
|
|
|
<Dialog variant="Primary">
|
|
|
|
|
|
<Box
|
|
|
|
|
|
direction="Column"
|
|
|
|
|
|
justifyContent="Center"
|
|
|
|
|
|
alignItems="Center"
|
|
|
|
|
|
gap="500"
|
|
|
|
|
|
style={{ padding: toRem(60) }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon size="600" src={Icons.File} />
|
|
|
|
|
|
<Text size="H4" align="Center">
|
|
|
|
|
|
{`Drop Files in "${room?.name || 'Room'}"`}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
<Text align="Center">Drag and drop files here or click for selection dialog</Text>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
</Dialog>
|
|
|
|
|
|
</OverlayCenter>
|
|
|
|
|
|
</Overlay>
|
|
|
|
|
|
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
|
|
|
|
|
|
<RoomMentionAutocomplete
|
|
|
|
|
|
roomId={roomId}
|
|
|
|
|
|
editor={editor}
|
|
|
|
|
|
query={autocompleteQuery}
|
2023-10-14 16:08:43 +11:00
|
|
|
|
requestClose={handleCloseAutocomplete}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
|
|
|
|
|
<UserMentionAutocomplete
|
2023-10-19 17:43:16 +11:00
|
|
|
|
room={room}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
editor={editor}
|
|
|
|
|
|
query={autocompleteQuery}
|
2023-10-14 16:08:43 +11:00
|
|
|
|
requestClose={handleCloseAutocomplete}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
|
|
|
|
|
<EmoticonAutocomplete
|
|
|
|
|
|
imagePackRooms={imagePackRooms}
|
|
|
|
|
|
editor={editor}
|
|
|
|
|
|
query={autocompleteQuery}
|
2023-10-14 16:08:43 +11:00
|
|
|
|
requestClose={handleCloseAutocomplete}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2023-10-18 13:15:30 +11:00
|
|
|
|
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
|
|
|
|
|
|
<CommandAutocomplete
|
|
|
|
|
|
room={room}
|
|
|
|
|
|
editor={editor}
|
|
|
|
|
|
query={autocompleteQuery}
|
|
|
|
|
|
requestClose={handleCloseAutocomplete}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-03 22:13:22 -04:00
|
|
|
|
{showUnverifiedWarning && (
|
|
|
|
|
|
<Box
|
|
|
|
|
|
alignItems="Center"
|
|
|
|
|
|
gap="200"
|
|
|
|
|
|
style={{
|
2026-06-04 12:48:19 -04:00
|
|
|
|
margin: `0 ${config.space.S300} ${config.space.S100}`,
|
|
|
|
|
|
padding: `${config.space.S100} ${config.space.S200}`,
|
|
|
|
|
|
borderRadius: config.radii.R300,
|
2026-06-03 22:13:22 -04:00
|
|
|
|
background: color.Warning.Container,
|
2026-06-19 00:15:35 -04:00
|
|
|
|
border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
|
2026-06-03 22:13:22 -04:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon
|
|
|
|
|
|
size="100"
|
|
|
|
|
|
src={Icons.Shield}
|
2026-06-19 00:15:35 -04:00
|
|
|
|
style={{ color: color.Warning.OnContainer, flexShrink: 0 }}
|
2026-06-03 22:13:22 -04:00
|
|
|
|
/>
|
2026-06-19 00:15:35 -04:00
|
|
|
|
<Text size="T200" style={{ color: color.Warning.OnContainer }}>
|
2026-06-03 22:13:22 -04:00
|
|
|
|
{roomUnverifiedDeviceCount}{' '}
|
|
|
|
|
|
{roomUnverifiedDeviceCount === 1 ? 'unverified device' : 'unverified devices'} in this
|
|
|
|
|
|
room
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</Box>
|
|
|
|
|
|
)}
|
2026-06-04 10:26:08 -04:00
|
|
|
|
<ScheduledMessagesTray roomId={roomId} />
|
2023-06-12 21:15:23 +10:00
|
|
|
|
<CustomEditor
|
2026-07-10 20:28:26 -04:00
|
|
|
|
editableName={editableName}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
editor={editor}
|
|
|
|
|
|
placeholder="Send a message..."
|
|
|
|
|
|
onKeyDown={handleKeyDown}
|
2023-10-06 13:44:06 +11:00
|
|
|
|
onKeyUp={handleKeyUp}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
onPaste={handlePaste}
|
2026-09-15 21:32:06 -04:00
|
|
|
|
onChange={(value) => {
|
|
|
|
|
|
setCharCount(toPlainText(value, isMarkdown).trim().length);
|
|
|
|
|
|
schedulePersistDraft();
|
|
|
|
|
|
}}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
top={
|
|
|
|
|
|
replyDraft && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Box
|
|
|
|
|
|
alignItems="Center"
|
|
|
|
|
|
gap="300"
|
|
|
|
|
|
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<IconButton
|
2024-05-31 19:49:46 +05:30
|
|
|
|
onClick={() => setReplyDraft(undefined)}
|
2026-05-21 11:58:40 -04:00
|
|
|
|
aria-label="Dismiss reply"
|
2023-06-12 21:15:23 +10:00
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon src={Icons.Cross} size="50" />
|
|
|
|
|
|
</IconButton>
|
2025-08-04 20:34:01 +05:30
|
|
|
|
<Box direction="Row" gap="200" alignItems="Center">
|
2024-08-15 16:52:32 +02:00
|
|
|
|
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
|
|
|
|
|
|
<ReplyLayout
|
2025-03-23 22:09:29 +11:00
|
|
|
|
userColor={replyUsernameColor}
|
2024-08-15 16:52:32 +02:00
|
|
|
|
username={
|
|
|
|
|
|
<Text size="T300" truncate>
|
2026-07-07 23:01:32 -04:00
|
|
|
|
<b>{getMemberName(room, replyDraft.userId)}</b>
|
2024-08-15 16:52:32 +02:00
|
|
|
|
</Text>
|
|
|
|
|
|
}
|
|
|
|
|
|
>
|
2024-05-31 19:49:46 +05:30
|
|
|
|
<Text size="T300" truncate>
|
2024-08-15 16:52:32 +02:00
|
|
|
|
{trimReplyFromBody(replyDraft.body)}
|
2024-05-31 19:49:46 +05:30
|
|
|
|
</Text>
|
2024-08-15 16:52:32 +02:00
|
|
|
|
</ReplyLayout>
|
|
|
|
|
|
</Box>
|
2023-06-12 21:15:23 +10:00
|
|
|
|
</Box>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
before={
|
2026-09-17 01:14:48 -04:00
|
|
|
|
compact ? (
|
2026-07-24 14:44:13 -04:00
|
|
|
|
<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}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-07-24 14:44:13 -04:00
|
|
|
|
>
|
|
|
|
|
|
<Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
onClick={() => pickFile('*')}
|
|
|
|
|
|
aria-label="Attach file"
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
|
|
|
|
|
style={touchTarget}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-07-24 14:44:13 -04:00
|
|
|
|
>
|
|
|
|
|
|
<Icon src={Icons.PlusCircle} />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
)
|
2023-06-12 21:15:23 +10:00
|
|
|
|
}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
after={(() => {
|
|
|
|
|
|
const formatButton = showFormat ? (
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
key="showFormat"
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
|
|
|
|
|
style={touchTarget}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
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 ? (
|
2026-06-10 13:20:29 -04:00
|
|
|
|
<IconButton
|
2026-07-01 09:07:03 -04:00
|
|
|
|
key="showSticker"
|
2026-06-10 13:20:29 -04:00
|
|
|
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
|
|
|
|
|
aria-label="Insert sticker"
|
|
|
|
|
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
2026-06-18 13:34:40 -04:00
|
|
|
|
style={touchTarget}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-06-10 13:20:29 -04:00
|
|
|
|
>
|
|
|
|
|
|
<Icon
|
|
|
|
|
|
src={Icons.Sticker}
|
|
|
|
|
|
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</IconButton>
|
2026-07-01 09:07:03 -04:00
|
|
|
|
) : 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}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
>
|
|
|
|
|
|
<Icon
|
|
|
|
|
|
src={Icons.Smile}
|
|
|
|
|
|
filled={
|
2026-06-10 13:20:29 -04:00
|
|
|
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
|
|
|
|
|
}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}}
|
2026-06-10 13:20:29 -04:00
|
|
|
|
</UseStateProvider>
|
2026-07-01 09:07:03 -04:00
|
|
|
|
) : null;
|
|
|
|
|
|
|
|
|
|
|
|
const gifButton =
|
|
|
|
|
|
!!gifApiKey && showGif ? (
|
|
|
|
|
|
<UseStateProvider key="showGif" initial={false}>
|
2026-05-15 13:37:03 -04:00
|
|
|
|
{(gifOpen: boolean, setGifOpen) => (
|
|
|
|
|
|
<PopOut
|
|
|
|
|
|
offset={16}
|
|
|
|
|
|
alignOffset={-44}
|
|
|
|
|
|
position="Top"
|
|
|
|
|
|
align="End"
|
|
|
|
|
|
anchor={
|
|
|
|
|
|
gifOpen
|
2026-05-21 23:30:50 -04:00
|
|
|
|
? (gifBtnRef.current?.getBoundingClientRect() ?? undefined)
|
2026-05-15 13:37:03 -04:00
|
|
|
|
: undefined
|
|
|
|
|
|
}
|
|
|
|
|
|
content={
|
2026-05-21 20:49:33 -04:00
|
|
|
|
<React.Suspense fallback={null}>
|
|
|
|
|
|
<GifPicker
|
|
|
|
|
|
apiKey={gifApiKey}
|
|
|
|
|
|
onSelect={handleGifSelect}
|
|
|
|
|
|
requestClose={() => setGifOpen(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</React.Suspense>
|
2026-05-15 13:37:03 -04:00
|
|
|
|
}
|
|
|
|
|
|
>
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
ref={gifBtnRef}
|
2026-05-21 11:58:40 -04:00
|
|
|
|
aria-label="Insert GIF"
|
2026-05-15 13:37:03 -04:00
|
|
|
|
aria-pressed={gifOpen}
|
2026-05-30 17:13:54 -04:00
|
|
|
|
onClick={() => !gifUploading && setGifOpen(!gifOpen)}
|
2026-05-15 13:37:03 -04:00
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
2026-05-30 17:13:54 -04:00
|
|
|
|
disabled={gifUploading}
|
2026-06-18 13:34:40 -04:00
|
|
|
|
style={touchTarget}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-05-15 13:37:03 -04:00
|
|
|
|
>
|
2026-09-17 01:14:48 -04:00
|
|
|
|
{/* 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>
|
2026-05-15 13:37:03 -04:00
|
|
|
|
</IconButton>
|
|
|
|
|
|
</PopOut>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</UseStateProvider>
|
2026-07-01 09:07:03 -04:00
|
|
|
|
) : null;
|
|
|
|
|
|
|
|
|
|
|
|
const locationButton = showLocation ? (
|
2026-05-21 20:49:33 -04:00
|
|
|
|
<IconButton
|
2026-07-01 09:07:03 -04:00
|
|
|
|
key="showLocation"
|
|
|
|
|
|
onClick={handleShareLocation}
|
|
|
|
|
|
disabled={locating}
|
|
|
|
|
|
aria-label="Share location"
|
2026-05-21 20:49:33 -04:00
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
2026-07-01 09:07:03 -04:00
|
|
|
|
title="Share location"
|
2026-06-18 13:34:40 -04:00
|
|
|
|
style={touchTarget}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-05-21 20:49:33 -04:00
|
|
|
|
>
|
2026-07-01 09:07:03 -04:00
|
|
|
|
{locating ? (
|
2026-09-17 01:14:48 -04:00
|
|
|
|
<Spinner variant="Secondary" size="400" />
|
2026-07-01 09:07:03 -04:00
|
|
|
|
) : (
|
2026-09-17 01:14:48 -04:00
|
|
|
|
<Icon src={Icons.SpaceGlobe} />
|
2026-07-01 09:07:03 -04:00
|
|
|
|
)}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
</IconButton>
|
2026-07-01 09:07:03 -04:00
|
|
|
|
) : 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}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
>
|
2026-09-17 01:14:48 -04:00
|
|
|
|
<Icon src={Icons.OrderList} />
|
2026-07-01 09:07:03 -04:00
|
|
|
|
</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}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
aria-label="Schedule message"
|
|
|
|
|
|
title="Schedule message"
|
|
|
|
|
|
>
|
2026-09-17 01:14:48 -04:00
|
|
|
|
<Icon src={Icons.Clock} />
|
2026-07-01 09:07:03 -04:00
|
|
|
|
</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;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-17 01:14:48 -04:00
|
|
|
|
// Compact: keep only emoji/sticker inline beside Send; the rest move
|
2026-07-24 14:44:13 -04:00
|
|
|
|
// into the "+" overflow row (rendered via `bottom`), led by the attach
|
2026-09-17 01:14:48 -04:00
|
|
|
|
// 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.
|
2026-07-24 14:44:13 -04:00
|
|
|
|
const emojiInline = orderedButtons.filter(
|
|
|
|
|
|
(node) => React.isValidElement(node) && node.key === 'showEmojiSticker',
|
|
|
|
|
|
);
|
|
|
|
|
|
const overflowButtons = orderedButtons.filter(
|
|
|
|
|
|
(node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'),
|
|
|
|
|
|
);
|
2026-09-17 01:14:48 -04:00
|
|
|
|
if (compact) {
|
2026-07-24 14:44:13 -04:00
|
|
|
|
composerOverflow = (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<IconButton
|
|
|
|
|
|
key="showAttach"
|
|
|
|
|
|
onClick={() => pickFile('*')}
|
|
|
|
|
|
aria-label="Attach file"
|
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
|
size="300"
|
|
|
|
|
|
radii="300"
|
|
|
|
|
|
style={touchTarget}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-07-24 14:44:13 -04:00
|
|
|
|
>
|
|
|
|
|
|
<Icon src={Icons.PlusCircle} />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
{overflowButtons}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
<DraftIndicator roomId={draftKey} />
|
2026-07-24 14:44:13 -04:00
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 09:07:03 -04:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
2026-09-17 01:14:48 -04:00
|
|
|
|
{compact ? emojiInline : orderedButtons}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
{!compact && <DraftIndicator roomId={draftKey} />}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
{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}
|
2026-09-17 01:14:48 -04:00
|
|
|
|
className={MobileTouchTarget}
|
2026-07-01 09:07:03 -04:00
|
|
|
|
aria-label="Send message"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Icon src={Icons.Send} />
|
|
|
|
|
|
</IconButton>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
})()}
|
2023-06-16 11:09:09 +10:00
|
|
|
|
bottom={
|
2026-07-24 14:44:13 -04:00
|
|
|
|
<>
|
2026-09-17 01:14:48 -04:00
|
|
|
|
{compact && mobileToolsOpen && composerOverflow && (
|
2026-07-24 14:44:13 -04:00
|
|
|
|
<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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
2023-06-16 11:09:09 +10:00
|
|
|
|
}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
/>
|
2026-09-12 14:48:34 -04:00
|
|
|
|
{pollOpen && (
|
|
|
|
|
|
<PollCreator
|
|
|
|
|
|
room={room}
|
|
|
|
|
|
roomId={roomId}
|
|
|
|
|
|
threadRootId={threadRootId}
|
|
|
|
|
|
onClose={() => setPollOpen(false)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2026-06-04 12:07:12 -04:00
|
|
|
|
{scheduleOpen && (
|
2026-06-04 10:26:08 -04:00
|
|
|
|
<ScheduleMessageModal
|
|
|
|
|
|
roomId={roomId}
|
2026-09-15 21:32:06 -04:00
|
|
|
|
initialContent={scheduleContent}
|
2026-06-04 10:26:08 -04:00
|
|
|
|
onScheduled={handleScheduled}
|
|
|
|
|
|
onClose={() => {
|
|
|
|
|
|
setScheduleOpen(false);
|
|
|
|
|
|
setScheduleContent(null);
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
2023-06-12 21:15:23 +10:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-05-21 23:30:50 -04:00
|
|
|
|
},
|
2023-06-12 21:15:23 +10:00
|
|
|
|
);
|