Files
cinny/src/app/features/forum/ForumChatComposer.tsx

197 lines
6.5 KiB
TypeScript

import React, { KeyboardEventHandler, useCallback, useRef, useState } from 'react';
import { isKeyHotkey } from 'is-hotkey';
import { Editor } from 'slate';
import { IconButton, Line, PopOut } from 'folds';
import { Icon, Icons } from '../../components/icons';
import {
CustomEditor,
Toolbar,
EmoticonAutocomplete,
AutocompletePrefix,
AutocompleteQuery,
getAutocompleteQuery,
createEmoticonElement,
moveCursor,
safeFocusEditor,
} 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);
safeFocusEditor(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} size="200" />
</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()) safeFocusEditor(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}
size="200"
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} size="200" />
</IconButton>
)}
</>
}
bottom={
toolbar ? (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
) : undefined
}
/>
</div>
);
}