mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-03-13 06:30:01 +01:00
627 lines
21 KiB
TypeScript
627 lines
21 KiB
TypeScript
import React, {
|
|
KeyboardEventHandler,
|
|
RefObject,
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { useAtom, useAtomValue } from 'jotai';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
|
import { ReactEditor } from 'slate-react';
|
|
import { Transforms, Editor } from 'slate';
|
|
import {
|
|
Box,
|
|
Dialog,
|
|
Icon,
|
|
IconButton,
|
|
Icons,
|
|
Line,
|
|
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,
|
|
resetEditorHistory,
|
|
customHtmlEqualsPlainText,
|
|
trimCustomHtml,
|
|
isEmptyEditor,
|
|
getBeginCommand,
|
|
trimCommand,
|
|
} from '../../components/editor';
|
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
|
import { TUploadContent, encryptFile, getImageInfo, getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
|
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
|
import { useFilePicker } from '../../hooks/useFilePicker';
|
|
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
|
import { useFileDropZone } from '../../hooks/useFileDrop';
|
|
import {
|
|
TUploadItem,
|
|
roomIdToMsgDraftAtomFamily,
|
|
roomIdToReplyDraftAtomFamily,
|
|
roomIdToUploadItemsAtomFamily,
|
|
roomUploadAtomFamily,
|
|
} from '../../state/room/roomInputDrafts';
|
|
import { UploadCardRenderer } from '../../components/upload-card';
|
|
import {
|
|
UploadBoard,
|
|
UploadBoardContent,
|
|
UploadBoardHeader,
|
|
UploadBoardImperativeHandlers,
|
|
} from '../../components/upload-board';
|
|
import {
|
|
Upload,
|
|
UploadStatus,
|
|
UploadSuccess,
|
|
createUploadFamilyObserverAtom,
|
|
} from '../../state/upload';
|
|
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
|
import { safeFile } from '../../utils/mimeTypes';
|
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import {
|
|
getAudioMsgContent,
|
|
getFileMsgContent,
|
|
getImageMsgContent,
|
|
getVideoMsgContent,
|
|
} from './msgContent';
|
|
import colorMXID from '../../../util/colorMXID';
|
|
import {
|
|
getAllParents,
|
|
getMemberDisplayName,
|
|
parseReplyBody,
|
|
parseReplyFormattedBody,
|
|
trimReplyFromBody,
|
|
trimReplyFromFormattedBody,
|
|
} from '../../utils/room';
|
|
import { sanitizeText } from '../../utils/sanitize';
|
|
import { CommandAutocomplete } from './CommandAutocomplete';
|
|
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
|
import { mobileOrTablet } from '../../utils/user-agent';
|
|
import { useElementSizeObserver } from '../../hooks/useElementSizeObserver';
|
|
import { ReplyLayout, ThreadIndicator } from '../../components/message';
|
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
|
|
interface RoomInputProps {
|
|
editor: Editor;
|
|
fileDropContainerRef: RefObject<HTMLElement>;
|
|
roomId: string;
|
|
room: Room;
|
|
}
|
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|
({ editor, fileDropContainerRef, roomId, room }, ref) => {
|
|
const mx = useMatrixClient();
|
|
const useAuthentication = useMediaAuthentication();
|
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
|
const commands = useCommands(mx, room);
|
|
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
|
|
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(() => {
|
|
const allParentSpaces = [roomId].concat(Array.from(getAllParents(roomToParents, roomId)));
|
|
return allParentSpaces.reduce<Room[]>((list, rId) => {
|
|
const r = mx.getRoom(rId);
|
|
if (r) list.push(r);
|
|
return list;
|
|
}, []);
|
|
}, [mx, roomId, roomToParents]);
|
|
|
|
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 (room.hasEncryptionStateEvent()) {
|
|
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, room]
|
|
);
|
|
const pickFile = useFilePicker(handleFiles, true);
|
|
const handlePaste = useFilePasteHandler(handleFiles);
|
|
const dropZoneVisible = useFileDropZone(fileDropContainerRef, handleFiles);
|
|
const [hideStickerBtn, setHideStickerBtn] = useState(document.body.clientWidth < 500);
|
|
|
|
useElementSizeObserver(
|
|
useCallback(() => document.body, []),
|
|
useCallback((width) => setHideStickerBtn(width < 500), [])
|
|
);
|
|
|
|
useEffect(() => {
|
|
Transforms.insertFragment(editor, msgDraft);
|
|
}, [editor, msgDraft]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (!isEmptyEditor(editor)) {
|
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
|
setMsgDraft(parsedDraft);
|
|
} else {
|
|
setMsgDraft([]);
|
|
}
|
|
resetEditor(editor);
|
|
resetEditorHistory(editor);
|
|
},
|
|
[roomId, editor, setMsgDraft]
|
|
);
|
|
|
|
const handleRemoveUpload = useCallback(
|
|
(upload: TUploadContent | TUploadContent[]) => {
|
|
const uploads = Array.isArray(upload) ? upload : [upload];
|
|
setSelectedFiles({
|
|
type: 'DELETE',
|
|
item: selectedFiles.filter((f) => uploads.find((u) => u === f.file)),
|
|
});
|
|
uploads.forEach((u) => roomUploadAtomFamily.remove(u));
|
|
},
|
|
[setSelectedFiles, selectedFiles]
|
|
);
|
|
|
|
const handleCancelUpload = (uploads: Upload[]) => {
|
|
uploads.forEach((upload) => {
|
|
if (upload.status === UploadStatus.Loading) {
|
|
mx.cancelUpload(upload.promise);
|
|
}
|
|
});
|
|
handleRemoveUpload(uploads.map((upload) => upload.file));
|
|
};
|
|
|
|
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
|
const contentsPromises = uploads.map(async (upload) => {
|
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
|
if (!fileItem) throw new Error('Broken upload');
|
|
|
|
if (fileItem.file.type.startsWith('image')) {
|
|
return getImageMsgContent(mx, fileItem, upload.mxc);
|
|
}
|
|
if (fileItem.file.type.startsWith('video')) {
|
|
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
|
}
|
|
if (fileItem.file.type.startsWith('audio')) {
|
|
return getAudioMsgContent(fileItem, upload.mxc);
|
|
}
|
|
return getFileMsgContent(fileItem, upload.mxc);
|
|
});
|
|
handleCancelUpload(uploads);
|
|
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
|
contents.forEach((content) => mx.sendMessage(roomId, content));
|
|
};
|
|
|
|
const submit = useCallback(() => {
|
|
uploadBoardHandlers.current?.handleSend();
|
|
|
|
const commandName = getBeginCommand(editor);
|
|
|
|
let plainText = toPlainText(editor.children).trim();
|
|
let customHtml = trimCustomHtml(
|
|
toMatrixCustomHTML(editor.children, {
|
|
allowTextFormatting: true,
|
|
allowBlockMarkdown: isMarkdown,
|
|
allowInlineMarkdown: isMarkdown,
|
|
})
|
|
);
|
|
let msgType = MsgType.Text;
|
|
|
|
if (commandName) {
|
|
plainText = trimCommand(commandName, plainText);
|
|
customHtml = trimCommand(commandName, customHtml);
|
|
}
|
|
if (commandName === Command.Me) {
|
|
msgType = MsgType.Emote;
|
|
} else if (commandName === Command.Notice) {
|
|
msgType = MsgType.Notice;
|
|
} else if (commandName === Command.Shrug) {
|
|
plainText = `${SHRUG} ${plainText}`;
|
|
customHtml = `${SHRUG} ${customHtml}`;
|
|
} else if (commandName === Command.TableFlip) {
|
|
plainText = `${TABLEFLIP} ${plainText}`;
|
|
customHtml = `${TABLEFLIP} ${customHtml}`;
|
|
} else if (commandName === Command.UnFlip) {
|
|
plainText = `${UNFLIP} ${plainText}`;
|
|
customHtml = `${UNFLIP} ${customHtml}`;
|
|
} else if (commandName) {
|
|
const commandContent = commands[commandName as Command];
|
|
if (commandContent) {
|
|
commandContent.exe(plainText);
|
|
}
|
|
resetEditor(editor);
|
|
resetEditorHistory(editor);
|
|
sendTypingStatus(false);
|
|
return;
|
|
}
|
|
|
|
if (plainText === '') return;
|
|
|
|
let body = plainText;
|
|
let formattedBody = customHtml;
|
|
if (replyDraft) {
|
|
body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
|
|
formattedBody =
|
|
parseReplyFormattedBody(
|
|
roomId,
|
|
replyDraft.userId,
|
|
replyDraft.eventId,
|
|
replyDraft.formattedBody
|
|
? trimReplyFromFormattedBody(replyDraft.formattedBody)
|
|
: sanitizeText(replyDraft.body)
|
|
) + formattedBody;
|
|
}
|
|
|
|
const content: IContent = {
|
|
msgtype: msgType,
|
|
body,
|
|
};
|
|
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
|
|
content.format = 'org.matrix.custom.html';
|
|
content.formatted_body = formattedBody;
|
|
}
|
|
if (replyDraft) {
|
|
content['m.relates_to'] = {
|
|
'm.in_reply_to': {
|
|
event_id: replyDraft.eventId,
|
|
},
|
|
};
|
|
if (replyDraft.relation?.rel_type === RelationType.Thread) {
|
|
content['m.relates_to'].event_id = replyDraft.relation.event_id;
|
|
content['m.relates_to'].rel_type = RelationType.Thread;
|
|
content['m.relates_to'].is_falling_back = false;
|
|
}
|
|
}
|
|
mx.sendMessage(roomId, content);
|
|
resetEditor(editor);
|
|
resetEditorHistory(editor);
|
|
setReplyDraft(undefined);
|
|
sendTypingStatus(false);
|
|
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
|
|
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
|
(evt) => {
|
|
if (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
|
|
evt.preventDefault();
|
|
submit();
|
|
}
|
|
if (isKeyHotkey('escape', evt)) {
|
|
evt.preventDefault();
|
|
setReplyDraft(undefined);
|
|
}
|
|
},
|
|
[submit, setReplyDraft, enterForNewline]
|
|
);
|
|
|
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
|
(evt) => {
|
|
if (isKeyHotkey('escape', evt)) {
|
|
evt.preventDefault();
|
|
return;
|
|
}
|
|
|
|
sendTypingStatus(!isEmptyEditor(editor));
|
|
|
|
const prevWordRange = getPrevWorldRange(editor);
|
|
const query = prevWordRange
|
|
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
|
: undefined;
|
|
setAutocompleteQuery(query);
|
|
},
|
|
[editor, sendTypingStatus]
|
|
);
|
|
|
|
const handleCloseAutocomplete = useCallback(() => {
|
|
setAutocompleteQuery(undefined);
|
|
ReactEditor.focus(editor);
|
|
}, [editor]);
|
|
|
|
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
|
moveCursor(editor);
|
|
};
|
|
|
|
const handleStickerSelect = async (mxc: string, shortcode: string, label: string) => {
|
|
const stickerUrl = mxcUrlToHttp(mx, mxc, useAuthentication);
|
|
if (!stickerUrl) return;
|
|
|
|
const info = await getImageInfo(
|
|
await loadImageElement(stickerUrl),
|
|
await getImageUrlBlob(stickerUrl)
|
|
);
|
|
|
|
mx.sendEvent(roomId, EventType.Sticker, {
|
|
body: label,
|
|
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}
|
|
requestClose={handleCloseAutocomplete}
|
|
/>
|
|
)}
|
|
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
|
<UserMentionAutocomplete
|
|
room={room}
|
|
editor={editor}
|
|
query={autocompleteQuery}
|
|
requestClose={handleCloseAutocomplete}
|
|
/>
|
|
)}
|
|
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
|
<EmoticonAutocomplete
|
|
imagePackRooms={imagePackRooms}
|
|
editor={editor}
|
|
query={autocompleteQuery}
|
|
requestClose={handleCloseAutocomplete}
|
|
/>
|
|
)}
|
|
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
|
|
<CommandAutocomplete
|
|
room={room}
|
|
editor={editor}
|
|
query={autocompleteQuery}
|
|
requestClose={handleCloseAutocomplete}
|
|
/>
|
|
)}
|
|
<CustomEditor
|
|
editableName="RoomInput"
|
|
editor={editor}
|
|
placeholder="Send a message..."
|
|
onKeyDown={handleKeyDown}
|
|
onKeyUp={handleKeyUp}
|
|
onPaste={handlePaste}
|
|
top={
|
|
replyDraft && (
|
|
<div>
|
|
<Box
|
|
alignItems="Center"
|
|
gap="300"
|
|
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
|
>
|
|
<IconButton
|
|
onClick={() => setReplyDraft(undefined)}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
>
|
|
<Icon src={Icons.Cross} size="50" />
|
|
</IconButton>
|
|
<Box direction="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>
|
|
{trimReplyFromBody(replyDraft.body)}
|
|
</Text>
|
|
</ReplyLayout>
|
|
</Box>
|
|
</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
|
|
}
|
|
content={
|
|
<EmojiBoard
|
|
tab={emojiBoardTab}
|
|
onTabChange={setEmojiBoardTab}
|
|
imagePackRooms={imagePackRooms}
|
|
returnFocusOnDeactivate={false}
|
|
onEmojiSelect={handleEmoticonSelect}
|
|
onCustomEmojiSelect={handleEmoticonSelect}
|
|
onStickerSelect={handleStickerSelect}
|
|
requestClose={() => {
|
|
setEmojiBoardTab(undefined);
|
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
{!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>
|
|
)}
|
|
<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>
|
|
</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>
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
);
|