2023-06-12 21:15:23 +10:00
|
|
|
import React, {
|
|
|
|
|
KeyboardEventHandler,
|
|
|
|
|
RefObject,
|
|
|
|
|
forwardRef,
|
|
|
|
|
useCallback,
|
|
|
|
|
useEffect,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
} from 'react';
|
2024-07-08 16:57:10 +05:30
|
|
|
import { useAtom, useAtomValue } 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,
|
|
|
|
|
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,
|
|
|
|
|
getPrevWorldRange,
|
|
|
|
|
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,
|
|
|
|
|
getMxIdLocalPart,
|
|
|
|
|
mxcUrlToHttp,
|
|
|
|
|
} from '../../utils/matrix';
|
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';
|
2023-06-12 21:15:23 +10:00
|
|
|
import { settingsAtom } from '../../state/settings';
|
|
|
|
|
import {
|
|
|
|
|
getAudioMsgContent,
|
|
|
|
|
getFileMsgContent,
|
|
|
|
|
getImageMsgContent,
|
|
|
|
|
getVideoMsgContent,
|
|
|
|
|
} from './msgContent';
|
2025-03-19 23:14:54 +11:00
|
|
|
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
2023-10-18 13:15:30 +11:00
|
|
|
import { CommandAutocomplete } from './CommandAutocomplete';
|
2024-12-13 05:32:25 +01:00
|
|
|
import { Command, 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-05-23 12:19:06 -04:00
|
|
|
import { VoiceMessageRecorder } from '../../components/VoiceMessageRecorder';
|
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 })),
|
|
|
|
|
);
|
|
|
|
|
|
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;
|
2023-06-12 21:15:23 +10:00
|
|
|
}
|
|
|
|
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
2025-08-12 19:42:30 +05:30
|
|
|
({ editor, fileDropContainerRef, roomId, room }, 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');
|
|
|
|
|
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-05-21 20:49:33 -04:00
|
|
|
const alive = useAlive();
|
2023-06-12 21:15:23 +10:00
|
|
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
|
|
|
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
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);
|
|
|
|
|
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
|
|
|
|
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-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-05-15 13:37:03 -04:00
|
|
|
const handleShareLocation = () => {
|
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)}`;
|
|
|
|
|
mx.sendMessage(roomId, {
|
|
|
|
|
msgtype: 'm.location',
|
|
|
|
|
body: `Location: ${geoUri}`,
|
|
|
|
|
geo_uri: geoUri,
|
|
|
|
|
} 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-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);
|
|
|
|
|
mx.sendMessage(roomId, {
|
|
|
|
|
...baseContent,
|
|
|
|
|
file: { ...encInfo, url: uploadResult.content_uri },
|
|
|
|
|
} as any);
|
|
|
|
|
} else {
|
|
|
|
|
const uploadResult = await mx.uploadContent(blob, {
|
|
|
|
|
name: 'voice-message.ogg',
|
|
|
|
|
type: mimeType,
|
|
|
|
|
});
|
|
|
|
|
mx.sendMessage(roomId, {
|
|
|
|
|
...baseContent,
|
|
|
|
|
url: uploadResult.content_uri,
|
|
|
|
|
} as any);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[mx, room, roomId],
|
|
|
|
|
);
|
|
|
|
|
|
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);
|
|
|
|
|
const handlePaste = useFilePasteHandler(handleFiles);
|
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 {
|
|
|
|
|
const stored = localStorage.getItem(`draft-msg-${roomId}`);
|
|
|
|
|
if (stored) {
|
|
|
|
|
const nodes = JSON.parse(stored);
|
|
|
|
|
if (Array.isArray(nodes) && nodes.length > 0) {
|
|
|
|
|
Transforms.insertFragment(editor, nodes);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Ignore malformed stored draft
|
|
|
|
|
}
|
2026-05-20 21:26:18 -04:00
|
|
|
}
|
2026-05-30 22:22:40 -04:00
|
|
|
}, [editor, msgDraft, roomId]);
|
2023-06-12 21:15:23 +10:00
|
|
|
|
2024-08-04 11:34:11 +05:30
|
|
|
useEffect(
|
|
|
|
|
() => () => {
|
2023-10-18 13:15:30 +11:00
|
|
|
if (!isEmptyEditor(editor)) {
|
|
|
|
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
|
|
|
|
setMsgDraft(parsedDraft);
|
2026-05-30 22:22:40 -04:00
|
|
|
localStorage.setItem(`draft-msg-${roomId}`, JSON.stringify(parsedDraft));
|
2023-10-18 13:15:30 +11:00
|
|
|
} else {
|
2023-10-19 17:43:37 +11:00
|
|
|
setMsgDraft([]);
|
2026-05-30 22:22:40 -04:00
|
|
|
localStorage.removeItem(`draft-msg-${roomId}`);
|
2023-10-18 13:15:30 +11:00
|
|
|
}
|
2023-06-12 21:15:23 +10:00
|
|
|
resetEditor(editor);
|
2023-06-16 11:11:03 +10:00
|
|
|
resetEditorHistory(editor);
|
2024-08-04 11:34:11 +05:30
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
[roomId, editor, setMsgDraft],
|
2024-08-04 11:34:11 +05:30
|
|
|
);
|
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
|
|
|
);
|
|
|
|
|
|
|
|
|
|
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[]) => {
|
2023-10-25 16:50:38 +11:00
|
|
|
const contentsPromises = uploads.map(async (upload) => {
|
2023-06-12 21:15:23 +10:00
|
|
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
2023-10-25 16:50:38 +11:00
|
|
|
if (!fileItem) throw new Error('Broken upload');
|
|
|
|
|
|
|
|
|
|
if (fileItem.file.type.startsWith('image')) {
|
|
|
|
|
return getImageMsgContent(mx, fileItem, upload.mxc);
|
2023-06-12 21:15:23 +10:00
|
|
|
}
|
2023-10-25 16:50:38 +11:00
|
|
|
if (fileItem.file.type.startsWith('video')) {
|
|
|
|
|
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
2023-06-12 21:15:23 +10:00
|
|
|
}
|
2023-10-25 16:50:38 +11:00
|
|
|
if (fileItem.file.type.startsWith('audio')) {
|
|
|
|
|
return getAudioMsgContent(fileItem, upload.mxc);
|
2023-06-12 21:15:23 +10:00
|
|
|
}
|
2023-10-25 16:50:38 +11:00
|
|
|
return getFileMsgContent(fileItem, upload.mxc);
|
2023-06-12 21:15:23 +10:00
|
|
|
});
|
|
|
|
|
handleCancelUpload(uploads);
|
2023-10-25 16:50:38 +11:00
|
|
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
2025-08-12 19:42:30 +05:30
|
|
|
contents.forEach((content) => mx.sendMessage(roomId, content as any));
|
2023-06-12 21:15:23 +10:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const submit = useCallback(() => {
|
|
|
|
|
uploadBoardHandlers.current?.handleSend();
|
|
|
|
|
|
2023-10-18 13:15:30 +11:00
|
|
|
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-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);
|
|
|
|
|
}
|
|
|
|
|
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) {
|
|
|
|
|
commandContent.exe(plainText);
|
|
|
|
|
}
|
|
|
|
|
resetEditor(editor);
|
|
|
|
|
resetEditorHistory(editor);
|
|
|
|
|
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
|
|
|
}
|
2025-08-12 19:42:30 +05:30
|
|
|
mx.sendMessage(roomId, content as any);
|
2023-06-12 21:15:23 +10:00
|
|
|
resetEditor(editor);
|
2023-06-16 11:11:03 +10:00
|
|
|
resetEditorHistory(editor);
|
2026-05-30 22:22:40 -04:00
|
|
|
localStorage.removeItem(`draft-msg-${roomId}`);
|
2024-05-31 19:49:46 +05:30
|
|
|
setReplyDraft(undefined);
|
2023-06-12 21:15:23 +10:00
|
|
|
sendTypingStatus(false);
|
2023-10-18 13:15:30 +11:00
|
|
|
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
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)) {
|
2023-06-12 21:15:23 +10:00
|
|
|
evt.preventDefault();
|
2025-02-26 21:42:42 +11:00
|
|
|
if (autocompleteQuery) {
|
|
|
|
|
setAutocompleteQuery(undefined);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2024-05-31 19:49:46 +05:30
|
|
|
setReplyDraft(undefined);
|
2023-06-12 21:15:23 +10:00
|
|
|
}
|
|
|
|
|
},
|
2026-05-21 23:30:50 -04:00
|
|
|
[submit, 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
|
|
|
|
|
|
|
|
const prevWordRange = getPrevWorldRange(editor);
|
|
|
|
|
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
|
|
|
|
|
|
|
|
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
|
|
|
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
|
|
|
|
moveCursor(editor);
|
|
|
|
|
};
|
|
|
|
|
|
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-05-15 15:08:55 -04:00
|
|
|
// Only fetch from trusted Giphy CDN domains
|
2026-05-21 20:49:33 -04:00
|
|
|
const allowed = [
|
|
|
|
|
'media.giphy.com',
|
|
|
|
|
'i.giphy.com',
|
|
|
|
|
'media0.giphy.com',
|
|
|
|
|
'media1.giphy.com',
|
|
|
|
|
'media2.giphy.com',
|
|
|
|
|
'media3.giphy.com',
|
|
|
|
|
'media4.giphy.com',
|
|
|
|
|
];
|
2026-05-15 15:08:55 -04:00
|
|
|
const { hostname } = new URL(gifUrl);
|
|
|
|
|
if (!allowed.includes(hostname)) return;
|
|
|
|
|
|
2026-05-15 13:37:03 -04:00
|
|
|
const res = await fetch(gifUrl);
|
2026-05-15 15:08:55 -04:00
|
|
|
if (!res.ok) return;
|
|
|
|
|
const contentType = res.headers.get('content-type') ?? '';
|
|
|
|
|
if (!contentType.startsWith('image/')) return;
|
|
|
|
|
|
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-05-15 13:37:03 -04:00
|
|
|
const uploadRes = await mx.uploadContent(
|
|
|
|
|
new File([blob], 'image.gif', { type: 'image/gif' }),
|
2026-05-21 23:30:50 -04:00
|
|
|
{ type: 'image/gif', name: 'image.gif', includeFilename: false },
|
2026-05-15 13:37:03 -04:00
|
|
|
);
|
2026-05-16 01:34:20 -04:00
|
|
|
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
2026-05-15 13:37:03 -04:00
|
|
|
if (!mxcUrl) return;
|
|
|
|
|
mx.sendMessage(roomId, {
|
|
|
|
|
msgtype: MsgType.Image,
|
|
|
|
|
body: 'image.gif',
|
|
|
|
|
url: mxcUrl,
|
|
|
|
|
info: { mimetype: 'image/gif', w, h, size: blob.size },
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('GIF send failed', e);
|
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-05-22 17:17:26 -04:00
|
|
|
[mx, roomId, alive],
|
2026-05-15 13:37:03 -04:00
|
|
|
);
|
|
|
|
|
|
2023-10-25 16:50:38 +11:00
|
|
|
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
2024-09-07 21:45:55 +08:00
|
|
|
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
2023-06-12 21:15:23 +10:00
|
|
|
if (!stickerUrl) return;
|
|
|
|
|
|
|
|
|
|
const info = await getImageInfo(
|
|
|
|
|
await loadImageElement(stickerUrl),
|
2026-05-21 23:30:50 -04:00
|
|
|
await getImageUrlBlob(stickerUrl),
|
2023-06-12 21:15:23 +10:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
mx.sendEvent(roomId, EventType.Sticker, {
|
2023-10-25 16:50:38 +11:00
|
|
|
body: label,
|
2023-06-12 21:15:23 +10:00
|
|
|
url: mxc,
|
|
|
|
|
info,
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-06-01 17:15:01 -04:00
|
|
|
if (room.getType() === 'm.server_notice') {
|
|
|
|
|
return (
|
|
|
|
|
<div ref={ref} style={{ padding: config.space.S300, textAlign: 'center' }}>
|
|
|
|
|
<Text size="T300" priority="300">
|
|
|
|
|
This is a server notice room — you cannot send messages here.
|
|
|
|
|
</Text>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
2023-06-12 21:15:23 +10:00
|
|
|
<CustomEditor
|
2023-10-14 16:08:43 +11:00
|
|
|
editableName="RoomInput"
|
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}
|
|
|
|
|
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>
|
|
|
|
|
<b>
|
|
|
|
|
{getMemberDisplayName(room, replyDraft.userId) ??
|
|
|
|
|
getMxIdLocalPart(replyDraft.userId) ??
|
|
|
|
|
replyDraft.userId}
|
|
|
|
|
</b>
|
|
|
|
|
</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={
|
|
|
|
|
<IconButton
|
|
|
|
|
onClick={() => pickFile('*')}
|
2026-05-21 11:58:40 -04:00
|
|
|
aria-label="Attach file"
|
2023-06-12 21:15:23 +10:00
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
>
|
|
|
|
|
<Icon src={Icons.PlusCircle} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
}
|
|
|
|
|
after={
|
|
|
|
|
<>
|
|
|
|
|
<IconButton
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
2026-05-21 11:58:40 -04:00
|
|
|
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
|
|
|
|
aria-pressed={toolbar}
|
2023-06-12 21:15:23 +10:00
|
|
|
onClick={() => setToolbar(!toolbar)}
|
|
|
|
|
>
|
|
|
|
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
<UseStateProvider initial={undefined}>
|
|
|
|
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
|
|
|
|
<PopOut
|
|
|
|
|
offset={16}
|
|
|
|
|
alignOffset={-44}
|
|
|
|
|
position="Top"
|
|
|
|
|
align="End"
|
2024-05-31 19:49:46 +05:30
|
|
|
anchor={
|
|
|
|
|
emojiBoardTab === undefined
|
|
|
|
|
? undefined
|
2026-05-21 23:30:50 -04:00
|
|
|
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
2024-05-31 19:49:46 +05:30
|
|
|
}
|
2023-06-12 21:15:23 +10:00
|
|
|
content={
|
2026-05-21 20:49:33 -04:00
|
|
|
<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>
|
2023-06-12 21:15:23 +10:00
|
|
|
}
|
|
|
|
|
>
|
2024-05-31 19:49:46 +05:30
|
|
|
{!hideStickerBtn && (
|
|
|
|
|
<IconButton
|
|
|
|
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
2026-05-21 11:58:40 -04:00
|
|
|
aria-label="Insert sticker"
|
2024-05-31 19:49:46 +05:30
|
|
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
>
|
|
|
|
|
<Icon
|
|
|
|
|
src={Icons.Sticker}
|
|
|
|
|
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
|
|
|
|
/>
|
|
|
|
|
</IconButton>
|
2023-06-12 21:15:23 +10:00
|
|
|
)}
|
2024-05-31 19:49:46 +05:30
|
|
|
<IconButton
|
|
|
|
|
ref={emojiBtnRef}
|
2026-05-21 11:58:40 -04:00
|
|
|
aria-label="Insert emoji"
|
2024-05-31 19:49:46 +05:30
|
|
|
aria-pressed={
|
|
|
|
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
|
|
|
|
}
|
|
|
|
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
>
|
|
|
|
|
<Icon
|
|
|
|
|
src={Icons.Smile}
|
|
|
|
|
filled={
|
|
|
|
|
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
</IconButton>
|
2023-06-12 21:15:23 +10:00
|
|
|
</PopOut>
|
|
|
|
|
)}
|
|
|
|
|
</UseStateProvider>
|
2026-05-15 13:37:03 -04:00
|
|
|
{!!gifApiKey && (
|
|
|
|
|
<UseStateProvider initial={false}>
|
|
|
|
|
{(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-05-15 13:37:03 -04:00
|
|
|
>
|
2026-05-30 17:13:54 -04:00
|
|
|
{gifUploading ? (
|
|
|
|
|
<Spinner variant="Secondary" size="100" />
|
|
|
|
|
) : (
|
|
|
|
|
<Text
|
|
|
|
|
size="T200"
|
|
|
|
|
style={{
|
|
|
|
|
fontWeight: 800,
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
letterSpacing: '0.04em',
|
|
|
|
|
lineHeight: 1,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
GIF
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
2026-05-15 13:37:03 -04:00
|
|
|
</IconButton>
|
|
|
|
|
</PopOut>
|
|
|
|
|
)}
|
|
|
|
|
</UseStateProvider>
|
|
|
|
|
)}
|
2026-05-19 16:45:02 -04:00
|
|
|
{gifError && (
|
2026-05-21 20:49:33 -04:00
|
|
|
<Text
|
2026-05-22 11:16:11 -04:00
|
|
|
size="T200"
|
2026-05-21 20:49:33 -04:00
|
|
|
style={{
|
|
|
|
|
color: 'var(--tc-danger-normal)',
|
|
|
|
|
padding: '2px 6px',
|
|
|
|
|
alignSelf: 'center',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-05-19 16:45:02 -04:00
|
|
|
{gifError}
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
2026-05-24 00:02:19 -04:00
|
|
|
{locationError && (
|
|
|
|
|
<Text
|
|
|
|
|
size="T200"
|
|
|
|
|
style={{
|
|
|
|
|
color: 'var(--tc-danger-normal)',
|
|
|
|
|
padding: '2px 6px',
|
|
|
|
|
alignSelf: 'center',
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{locationError}
|
|
|
|
|
</Text>
|
|
|
|
|
)}
|
2026-05-15 13:37:03 -04:00
|
|
|
<IconButton
|
|
|
|
|
onClick={handleShareLocation}
|
2026-05-24 00:02:19 -04:00
|
|
|
disabled={locating}
|
2026-05-21 11:58:40 -04:00
|
|
|
aria-label="Share location"
|
2026-05-15 13:37:03 -04:00
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
title="Share location"
|
|
|
|
|
>
|
|
|
|
|
{locating ? (
|
2026-05-24 00:02:19 -04:00
|
|
|
<Spinner variant="Secondary" size="100" />
|
2026-05-15 13:37:03 -04:00
|
|
|
) : (
|
2026-05-23 23:52:58 -04:00
|
|
|
<Icon src={Icons.SpaceGlobe} size="100" />
|
2026-05-15 13:37:03 -04:00
|
|
|
)}
|
|
|
|
|
</IconButton>
|
2026-05-24 00:30:12 -04:00
|
|
|
<VoiceMessageRecorder
|
|
|
|
|
onSend={handleVoiceSend}
|
|
|
|
|
onError={(err) => {
|
|
|
|
|
setLocationError(err);
|
|
|
|
|
setTimeout(() => setLocationError(null), 4000);
|
|
|
|
|
}}
|
|
|
|
|
/>
|
2026-05-21 20:49:33 -04:00
|
|
|
<IconButton
|
|
|
|
|
onClick={submit}
|
|
|
|
|
variant="SurfaceVariant"
|
|
|
|
|
size="300"
|
|
|
|
|
radii="300"
|
|
|
|
|
aria-label="Send message"
|
|
|
|
|
>
|
2023-06-12 21:15:23 +10:00
|
|
|
<Icon src={Icons.Send} />
|
|
|
|
|
</IconButton>
|
|
|
|
|
</>
|
|
|
|
|
}
|
2023-06-16 11:09:09 +10:00
|
|
|
bottom={
|
|
|
|
|
toolbar && (
|
|
|
|
|
<div>
|
|
|
|
|
<Line variant="SurfaceVariant" size="300" />
|
|
|
|
|
<Toolbar />
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
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
|
|
|
);
|