193 lines
6.3 KiB
TypeScript
193 lines
6.3 KiB
TypeScript
import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
import { Editor } from 'slate';
|
|
import { ReactEditor } from 'slate-react';
|
|
import { IconButton, Line, PopOut } from 'folds';
|
|
import { Icon, Icons } from '../../components/icons';
|
|
import {
|
|
CustomEditor,
|
|
Toolbar,
|
|
EmoticonAutocomplete,
|
|
AutocompletePrefix,
|
|
AutocompleteQuery,
|
|
getAutocompleteQuery,
|
|
createEmoticonElement,
|
|
moveCursor,
|
|
} from '../../components/editor';
|
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
|
import { useAtomValue } from 'jotai';
|
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|
import { Room } from 'matrix-js-sdk';
|
|
import { mobileOrTablet } from '../../utils/user-agent';
|
|
import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot';
|
|
|
|
type ForumChatComposerProps = {
|
|
editor: Editor;
|
|
roomId: string;
|
|
room: Room;
|
|
placeholder?: string;
|
|
onSend?: () => void;
|
|
showAttachButton?: boolean;
|
|
maxHeight?: string;
|
|
};
|
|
|
|
/** Same chrome as {@link RoomInput} (attach, emoji, toolbar, send). */
|
|
export function ForumChatComposer({
|
|
editor,
|
|
roomId,
|
|
room,
|
|
placeholder = 'Send a message…',
|
|
onSend,
|
|
showAttachButton = false,
|
|
maxHeight,
|
|
}: ForumChatComposerProps) {
|
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
|
const imagePackRooms = useImagePackRooms(roomId, roomToParents);
|
|
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
|
const [autocompleteQuery, setAutocompleteQuery] =
|
|
useState<AutocompleteQuery<AutocompletePrefix>>();
|
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
|
(evt) => {
|
|
if (
|
|
onSend &&
|
|
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt)))
|
|
) {
|
|
evt.preventDefault();
|
|
onSend();
|
|
}
|
|
},
|
|
[enterForNewline, onSend]
|
|
);
|
|
|
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
|
(evt) => {
|
|
const prevWordRange = getAutocompleteQuery(editor, evt);
|
|
if (prevWordRange?.prefix === AutocompletePrefix.Emoticon) {
|
|
setAutocompleteQuery({ prefix: AutocompletePrefix.Emoticon, range: prevWordRange.range });
|
|
return;
|
|
}
|
|
setAutocompleteQuery(undefined);
|
|
},
|
|
[editor]
|
|
);
|
|
|
|
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
|
moveCursor(editor);
|
|
};
|
|
|
|
const handleCloseAutocomplete = useCallback(() => {
|
|
setAutocompleteQuery(undefined);
|
|
ReactEditor.focus(editor);
|
|
}, [editor]);
|
|
|
|
return (
|
|
<div>
|
|
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
|
<EmoticonAutocomplete
|
|
imagePackRooms={imagePackRooms}
|
|
editor={editor}
|
|
query={autocompleteQuery}
|
|
requestClose={handleCloseAutocomplete}
|
|
/>
|
|
)}
|
|
<CustomEditor
|
|
editor={editor}
|
|
placeholder={placeholder}
|
|
maxHeight={maxHeight}
|
|
onKeyDown={handleKeyDown}
|
|
onKeyUp={handleKeyUp}
|
|
before={showAttachButton ? <PluginButtonSlot location="composer-actions" /> : undefined}
|
|
after={
|
|
<>
|
|
<IconButton
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
type="button"
|
|
onClick={() => setToolbar(!toolbar)}
|
|
aria-label="Formatting"
|
|
>
|
|
<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}
|
|
requestClose={() => {
|
|
setEmojiBoardTab((t) => {
|
|
if (t) {
|
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
|
return undefined;
|
|
}
|
|
return t;
|
|
});
|
|
}}
|
|
/>
|
|
}
|
|
>
|
|
<IconButton
|
|
ref={emojiBtnRef}
|
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Emoji}
|
|
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
type="button"
|
|
aria-label="Emoji"
|
|
>
|
|
<Icon src={Icons.Smile} filled={emojiBoardTab === EmojiBoardTab.Emoji} />
|
|
</IconButton>
|
|
</PopOut>
|
|
)}
|
|
</UseStateProvider>
|
|
<PluginButtonSlot location="text-composer-toolbar" />
|
|
{onSend && (
|
|
<IconButton
|
|
type="button"
|
|
onClick={onSend}
|
|
variant="SurfaceVariant"
|
|
size="300"
|
|
radii="300"
|
|
aria-label="Send"
|
|
>
|
|
<Icon src={Icons.Send} />
|
|
</IconButton>
|
|
)}
|
|
</>
|
|
}
|
|
bottom={
|
|
toolbar ? (
|
|
<div>
|
|
<Line variant="SurfaceVariant" size="300" />
|
|
<Toolbar />
|
|
</div>
|
|
) : undefined
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|