Skip to content

Commit

Permalink
Add withEmoji
Browse files Browse the repository at this point in the history
  • Loading branch information
compulim committed Sep 15, 2023
1 parent bb5898e commit c03d8e2
Show file tree
Hide file tree
Showing 5 changed files with 243 additions and 211 deletions.
233 changes: 25 additions & 208 deletions packages/component/src/SendBox/TextBox.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,21 @@
import { hooks } from 'botframework-webchat-api';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useMemo, useRef } from 'react';

import { ie11 } from '../Utils/detectBrowser';
import AccessibleInputText from '../Utils/AccessibleInputText';
import AutoResizeTextArea from './AutoResizeTextArea';
import connectToWebChat from '../connectToWebChat';
import navigableEvent from '../Utils/TypeFocusSink/navigableEvent';
import useRegisterFocusSendBox from '../hooks/internal/useRegisterFocusSendBox';
import useReplaceEmoticon from '../hooks/internal/useReplaceEmoticon';
import useScrollDown from '../hooks/useScrollDown';
import useScrollUp from '../hooks/useScrollUp';
import useStyleSet from '../hooks/useStyleSet';
import useStyleToEmotionObject from '../hooks/internal/useStyleToEmotionObject';
import useSubmit from '../providers/internal/SendBox/useSubmit';
import withEmoji from '../withEmoji/withEmoji';

import type { MutableRefObject, ReactEventHandler } from 'react';
import type { MutableRefObject } from 'react';

const { useDisabled, useLocalizer, usePonyfill, useSendBoxValue, useStopDictate, useStyleOptions } = hooks;

Expand All @@ -30,44 +29,6 @@ const ROOT_STYLE = {
}
};

const connectSendTextBox = (...selectors) =>
connectToWebChat(
({ disabled, focusSendBox, language, scrollToEnd, sendBoxValue, setSendBox, stopDictate, submitSendBox }) => ({
disabled,
language,
onChange: ({ target: { value } }) => {
setSendBox(value);
stopDictate();
},
onKeyPress: event => {
const { key, shiftKey } = event;

if (key === 'Enter' && !shiftKey) {
event.preventDefault();

if (sendBoxValue) {
scrollToEnd();
submitSendBox();
focusSendBox();
}
}
},
onSubmit: event => {
event.preventDefault();

// Consider clearing the send box only after we received POST_ACTIVITY_PENDING
// E.g. if the connection is bad, sending the message essentially do nothing but just clearing the send box

if (sendBoxValue) {
scrollToEnd();
submitSendBox();
}
},
value: sendBoxValue
}),
...selectors
);

/**
* Submits the text box and optionally set the focus after send.
*/
Expand All @@ -94,170 +55,46 @@ function useTextBoxSubmit(): SubmitTextBoxFunction {
);
}

function useTextBoxValue(): [
string,
(
textBoxValue: string,
options: { selectionEnd: number; selectionStart: number }
) => { selectionEnd: number; selectionStart: number; value: string }
] {
function useTextBoxValue(): [string, (textBoxValue: string) => void] {
const [value, setValue] = useSendBoxValue();
const replaceEmoticon = useReplaceEmoticon();
const stopDictate = useStopDictate();

const setter = useCallback<
(
nextValue: string,
options?: { selectionEnd: number; selectionStart: number }
) => {
selectionEnd: number;
selectionStart: number;
value: string;
}
>(
(nextValue, { selectionEnd, selectionStart } = { selectionEnd: undefined, selectionStart: undefined }) => {
const setter = useCallback<(nextValue: string) => void>(
nextValue => {
if (typeof nextValue !== 'string') {
throw new Error('botframework-webchat: First argument passed to useTextBoxValue() must be a string.');
}

// Currently, we cannot detect whether the change is due to clipboard paste or pressing a key on the keyboard.
// We should not change to emoji when the user is pasting text.
// We would assume, for a single character addition, the user must be pressing a key.
if (nextValue.length === value.length + 1) {
const {
selectionEnd: nextSelectionEnd,
selectionStart: nextSelectionStart,
value: nextValueWithEmoji
} = replaceEmoticon({ selectionEnd, selectionStart, value: nextValue });

selectionEnd = nextSelectionEnd;
selectionStart = nextSelectionStart;
nextValue = nextValueWithEmoji;
}

setValue(nextValue);
stopDictate();

return {
selectionEnd,
selectionStart,
value: nextValue
};
},
[replaceEmoticon, setValue, stopDictate, value]
[setValue, stopDictate]
);

return [value, setter];
}

const PREVENT_DEFAULT_HANDLER = event => event.preventDefault();

const SingleLineTextBox = withEmoji(AccessibleInputText);
const MultiLineTextBox = withEmoji(AutoResizeTextArea);

const TextBox = ({ className }) => {
const [, setSendBox] = useSendBoxValue();
const [value, setValue] = useSendBoxValue();
const [{ sendBoxTextBox: sendBoxTextBoxStyleSet }] = useStyleSet();
const [{ sendBoxTextWrap }] = useStyleOptions();
const [{ emojiSet, sendBoxTextWrap }] = useStyleOptions();
const [{ setTimeout }] = usePonyfill();
const [disabled] = useDisabled();
const [textBoxValue, setTextBoxValue] = useTextBoxValue();
const inputElementRef: MutableRefObject<HTMLInputElement & HTMLTextAreaElement> = useRef();
const localize = useLocalizer();
const placeCheckpointOnChangeRef = useRef(false);
const prevInputStateRef: MutableRefObject<{
selectionEnd: number;
selectionStart: number;
value: string;
}> = useRef();
const rootClassName = useStyleToEmotionObject()(ROOT_STYLE) + '';
const scrollDown = useScrollDown();
const scrollUp = useScrollUp();
const submitTextBox = useTextBoxSubmit();
const undoStackRef = useRef([]);

const sendBoxString = localize('TEXT_INPUT_ALT');
const typeYourMessageString = localize('TEXT_INPUT_PLACEHOLDER');

const rememberInputState = useCallback(() => {
const {
current: { selectionEnd, selectionStart, value }
} = inputElementRef;

prevInputStateRef.current = { selectionEnd, selectionStart, value };
}, [inputElementRef, prevInputStateRef]);

// This is for TypeFocusSink. When the focus in on the script, then starting press "a", without this line, it would cause errors.
// We call rememberInputState() when "onFocus" event is fired, but since this is from TypeFocusSink, we are not able to receive "onFocus" event before it happen.
useEffect(rememberInputState, [rememberInputState]);

// This is for moving the selection while setting the send box value.
// If we only use setSendBox, we will need to wait for the next render cycle to get the value in, before we can set selectionEnd/Start.
const setSelectionRangeAndValue = useCallback(
({ selectionEnd, selectionStart, value }) => {
if (inputElementRef.current) {
// We need to set the value, before selectionStart/selectionEnd.
inputElementRef.current.value = value;

inputElementRef.current.selectionStart = selectionStart;
inputElementRef.current.selectionEnd = selectionEnd;
}

setSendBox(value);
},
[inputElementRef, setSendBox]
);

const handleChange = useCallback(
event => {
const {
target: { selectionEnd, selectionStart, value }
} = event;

if (placeCheckpointOnChangeRef.current) {
undoStackRef.current.push({ ...prevInputStateRef.current });

placeCheckpointOnChangeRef.current = false;
}

const nextInputState = setTextBoxValue(value, { selectionEnd, selectionStart });

// If an emoticon is converted to emoji, place another checkpoint.
if (nextInputState.value !== value) {
undoStackRef.current.push({ selectionEnd, selectionStart, value });

placeCheckpointOnChangeRef.current = true;

setSelectionRangeAndValue(nextInputState);
}
},
[placeCheckpointOnChangeRef, prevInputStateRef, setSelectionRangeAndValue, setTextBoxValue, undoStackRef]
);

const handleFocus = useCallback(() => {
rememberInputState();

placeCheckpointOnChangeRef.current = true;
}, [placeCheckpointOnChangeRef, rememberInputState]);

const handleKeyDown = useCallback(
event => {
const { ctrlKey, key, metaKey } = event;

if ((ctrlKey || metaKey) && (key === 'Z' || key === 'z')) {
event.preventDefault();

const poppedInputState = undoStackRef.current.pop();

if (poppedInputState) {
prevInputStateRef.current = { ...poppedInputState };
} else {
prevInputStateRef.current = { selectionEnd: 0, selectionStart: 0, value: '' };
}

setSelectionRangeAndValue(prevInputStateRef.current);
}
},
[prevInputStateRef, setSelectionRangeAndValue, undoStackRef]
);

const handleKeyPress = useCallback(
event => {
const { key, shiftKey } = event;
Expand All @@ -267,24 +104,9 @@ const TextBox = ({ className }) => {

// If text box is submitted, focus on the send box
submitTextBox('sendBox');

// After submit, we will clear the undo stack.
undoStackRef.current = [];
}
},
[submitTextBox, undoStackRef]
);

const handleSelect = useCallback<ReactEventHandler<HTMLInputElement | HTMLTextAreaElement>>(
({ currentTarget: { selectionEnd, selectionStart, value } }) => {
if (value === prevInputStateRef.current.value) {
// When caret move, we should push to undo stack on change.
placeCheckpointOnChangeRef.current = true;
}

prevInputStateRef.current = { selectionEnd, selectionStart, value };
},
[placeCheckpointOnChangeRef, prevInputStateRef]
[submitTextBox]
);

const handleSubmit = useCallback(
Expand All @@ -294,11 +116,8 @@ const TextBox = ({ className }) => {
// Consider clearing the send box only after we received POST_ACTIVITY_PENDING
// E.g. if the connection is bad, sending the message essentially do nothing but just clearing the send box
submitTextBox();

// After submit, we will clear the undo stack.
undoStackRef.current = [];
},
[submitTextBox, undoStackRef]
[submitTextBox]
);

const handleKeyDownCapture = useCallback(
Expand Down Expand Up @@ -380,6 +199,8 @@ const TextBox = ({ className }) => {

useRegisterFocusSendBox(focusCallback);

const emojiMap = useMemo(() => new Map(Object.entries(emojiSet)), [emojiSet]);

return (
<form
aria-disabled={disabled}
Expand All @@ -392,45 +213,41 @@ const TextBox = ({ className }) => {
onSubmit={disabled ? PREVENT_DEFAULT_HANDLER : handleSubmit}
>
{!sendBoxTextWrap ? (
<AccessibleInputText
<SingleLineTextBox
aria-label={sendBoxString}
className="webchat__send-box-text-box__input"
data-id="webchat-sendbox-input"
disabled={disabled}
emojiSet={emojiMap}
enterKeyHint="send"
inputMode="text"
onChange={disabled ? undefined : handleChange}
onFocus={disabled ? undefined : handleFocus}
onKeyDown={disabled ? undefined : handleKeyDown}
onChange={setValue}
onKeyDownCapture={disabled ? undefined : handleKeyDownCapture}
onKeyPress={disabled ? undefined : handleKeyPress}
onSelect={disabled ? undefined : handleSelect}
placeholder={typeYourMessageString}
readOnly={disabled}
ref={inputElementRef}
type="text"
value={textBoxValue}
value={value}
/>
) : (
<AutoResizeTextArea
<MultiLineTextBox
aria-label={sendBoxString}
className="webchat__send-box-text-box__text-area"
data-id="webchat-sendbox-input"
disabled={disabled}
emojiSet={emojiMap}
enterKeyHint="send"
inputMode="text"
onChange={disabled ? undefined : handleChange}
onFocus={disabled ? undefined : handleFocus}
onKeyDown={disabled ? undefined : handleKeyDown}
onChange={setValue}
onKeyDownCapture={disabled ? undefined : handleKeyDownCapture}
onKeyPress={disabled ? undefined : handleKeyPress}
onSelect={disabled ? undefined : handleSelect}
placeholder={typeYourMessageString}
readOnly={disabled}
ref={inputElementRef}
rows={1}
textAreaClassName="webchat__send-box-text-box__html-text-area"
value={textBoxValue}
value={value}
/>
)}
{disabled && <div className="webchat__send-box-text-box__glass" />}
Expand All @@ -448,4 +265,4 @@ TextBox.propTypes = {

export default TextBox;

export { connectSendTextBox, useTextBoxSubmit, useTextBoxValue };
export { useTextBoxSubmit, useTextBoxValue };
3 changes: 1 addition & 2 deletions packages/component/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import YouTubeContent from './Attachment/YouTubeContent';
import DictationInterims, { connectDictationInterims } from './SendBox/DictationInterims';
import MicrophoneButton, { connectMicrophoneButton } from './SendBox/MicrophoneButton';
import SendButton, { connectSendButton } from './SendBox/SendButton';
import SendTextBox, { connectSendTextBox } from './SendBox/TextBox';
import SendTextBox from './SendBox/TextBox';
import SuggestedActions, { connectSuggestedActions } from './SendBox/SuggestedActions';
import UploadButton, { connectUploadButton } from './SendBox/UploadButton';

Expand Down Expand Up @@ -95,7 +95,6 @@ const Components = {
connectDictationInterims,
connectMicrophoneButton,
connectSendButton,
connectSendTextBox,
connectSuggestedActions,
connectUploadButton
};
Expand Down
5 changes: 4 additions & 1 deletion packages/component/src/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@
"downlevelIteration": true,
"emitDeclarationOnly": true,
"jsx": "react",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"preserveWatchOutput": true,
"pretty": true,
"skipLibCheck": true,
"sourceMap": true
"sourceMap": true,
"target": "ESNext"
},
"files": ["index.ts"]
}
Loading

0 comments on commit c03d8e2

Please sign in to comment.