Files
cinny/src/app/features/room/RoomInput.tsx
T

622 lines
21 KiB
TypeScript
Raw Normal View History

2023-06-12 21:15:23 +10:00
import React, {
KeyboardEventHandler,
RefObject,
forwardRef,
useCallback,
useEffect,
useMemo,
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,
Line,
2023-06-12 21:15:23 +10:00
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
Scroll,
Text,
config,
toRem,
} from 'folds';
import { useMatrixClient } from '../../hooks/useMatrixClient';
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,
2023-06-12 21:15:23 +10:00
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
2024-09-07 21:45:55 +08: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,
roomIdToMsgDraftAtomFamily,
roomIdToReplyDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
roomUploadAtomFamily,
} 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';
import { settingsAtom } from '../../state/settings';
import {
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import colorMXID from '../../../util/colorMXID';
2023-10-14 16:08:43 +11:00
import {
2024-07-08 16:57:10 +05:30
getAllParents,
getMemberDisplayName,
2023-10-14 16:08:43 +11:00
parseReplyBody,
parseReplyFormattedBody,
trimReplyFromBody,
trimReplyFromFormattedBody,
} from '../../utils/room';
2023-06-12 21:15:23 +10:00
import { sanitizeText } from '../../utils/sanitize';
2023-10-18 13:15:30 +11:00
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
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';
2023-06-12 21:15:23 +10:00
interface RoomInputProps {
2023-10-06 13:44:06 +11:00
editor: Editor;
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>(
({ 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');
2023-10-18 13:15:30 +11:00
const commands = useCommands(mx, room);
const emojiBtnRef = useRef<HTMLButtonElement>(null);
2024-07-08 16:57:10 +05:30
const roomToParents = useAtomValue(roomToParentsAtom);
2023-06-12 21:15:23 +10:00
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
roomUploadAtomFamily,
selectedFiles.map((f) => f.file)
);
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
const imagePackRooms: Room[] = useMemo(() => {
2024-07-08 16:57:10 +05:30
const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId)));
2023-06-12 21:15:23 +10:00
return allParentSpaces.reduce<Room[]>((list, rId) => {
const r = mx.getRoom(rId);
if (r) list.push(r);
return list;
}, []);
2024-07-08 16:57:10 +05:30
}, [mx, roomId, roomToParents]);
2023-06-12 21:15:23 +10:00
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
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[] = [];
if (mx.isRoomEncrypted(roomId)) {
const encryptFiles = fulfilledPromiseSettledResult(
await Promise.allSettled(safeFiles.map((f) => encryptFile(f)))
);
encryptFiles.forEach((ef) => fileItems.push(ef));
} else {
safeFiles.forEach((f) =>
fileItems.push({ file: f, originalFile: f, encInfo: undefined })
);
}
setSelectedFiles({
type: 'PUT',
item: fileItems,
});
},
[setSelectedFiles, roomId, mx]
);
const pickFile = useFilePicker(handleFiles, true);
const handlePaste = useFilePasteHandler(handleFiles);
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
2023-06-12 21:15:23 +10:00
useElementSizeObserver(
useCallback(() => document.body, []),
useCallback((width) => setHideStickerBtn(width < 500), [])
);
2023-06-12 21:15:23 +10:00
useEffect(() => {
Transforms.insertFragment(editor, msgDraft);
}, [editor, msgDraft]);
useEffect(
() => () => {
2023-10-18 13:15:30 +11:00
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
} else {
2023-10-19 17:43:37 +11:00
setMsgDraft([]);
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);
},
[roomId, editor, setMsgDraft]
);
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));
},
[setSelectedFiles, selectedFiles]
);
const handleCancelUpload = (uploads: Upload[]) => {
uploads.forEach((upload) => {
if (upload.status === UploadStatus.Loading) {
mx.cancelUpload(upload.promise);
}
});
handleRemoveUpload(uploads.map((upload) => upload.file));
};
const handleSendUpload = async (uploads: UploadSuccess[]) => {
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));
contents.forEach((content) => mx.sendMessage(roomId, content));
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);
let plainText = toPlainText(editor.children).trim();
let customHtml = trimCustomHtml(
2023-10-09 22:26:54 +11:00
toMatrixCustomHTML(editor.children, {
allowTextFormatting: true,
allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
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}`;
} 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;
let body = plainText;
let formattedBody = customHtml;
if (replyDraft) {
2023-10-14 16:08:43 +11:00
body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
2023-06-12 21:15:23 +10:00
formattedBody =
parseReplyFormattedBody(
roomId,
replyDraft.userId,
replyDraft.eventId,
2023-10-14 16:08:43 +11:00
replyDraft.formattedBody
? trimReplyFromFormattedBody(replyDraft.formattedBody)
: sanitizeText(replyDraft.body)
2023-06-12 21:15:23 +10:00
) + formattedBody;
}
const content: IContent = {
2023-10-18 13:15:30 +11:00
msgtype: msgType,
2023-06-12 21:15:23 +10:00
body,
};
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
}
mx.sendMessage(roomId, content);
resetEditor(editor);
2023-06-16 11:11:03 +10:00
resetEditorHistory(editor);
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) => {
2023-10-25 16:50:38 +11:00
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
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();
setReplyDraft(undefined);
2023-06-12 21:15:23 +10:00
}
},
2023-10-18 13:15:30 +11:00
[submit, setReplyDraft, enterForNewline]
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;
}
sendTypingStatus(!isEmptyEditor(editor));
const prevWordRange = getPrevWorldRange(editor);
const query = prevWordRange
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
: undefined;
setAutocompleteQuery(query);
},
[editor, sendTypingStatus]
);
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);
};
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),
await getImageUrlBlob(stickerUrl)
);
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,
});
};
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
<UploadBoardHeader
open={uploadBoard}
onToggle={() => setUploadBoard(!uploadBoard)}
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
onSend={handleSendUpload}
imperativeHandlerRef={uploadBoardHandlers}
onCancel={handleCancelUpload}
/>
}
>
{uploadBoard && (
<Scroll size="300" hideTrack visibility="Hover">
<UploadBoardContent>
{Array.from(selectedFiles)
.reverse()
.map((fileItem, index) => (
<UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key
key={index}
file={fileItem.file}
isEncrypted={!!fileItem.encInfo}
uploadAtom={roomUploadAtomFamily(fileItem.file)}
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
onClick={() => setReplyDraft(undefined)}
2023-06-12 21:15:23 +10:00
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
2024-08-15 16:52:32 +02:00
<Box direction="Column">
{replyDraft.relation?.rel_type === RelationType.Thread && <ThreadIndicator />}
<ReplyLayout
userColor={colorMXID(replyDraft.userId)}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text>
}
>
<Text size="T300" truncate>
2024-08-15 16:52:32 +02:00
{trimReplyFromBody(replyDraft.body)}
</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('*')}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.PlusCircle} />
</IconButton>
}
after={
<>
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
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"
anchor={
emojiBoardTab === undefined
? undefined
: emojiBtnRef.current?.getBoundingClientRect() ?? undefined
}
2023-06-12 21:15:23 +10:00
content={
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab(undefined);
2023-10-18 13:15:30 +11:00
if (!mobileOrTablet()) ReactEditor.focus(editor);
2023-06-12 21:15:23 +10:00
}}
/>
}
>
{!hideStickerBtn && (
<IconButton
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
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
)}
<IconButton
ref={emojiBtnRef}
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>
<IconButton onClick={submit} variant="SurfaceVariant" size="300" radii="300">
<Icon src={Icons.Send} />
</IconButton>
</>
}
bottom={
toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)
}
2023-06-12 21:15:23 +10:00
/>
</div>
);
}
);