diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -10,7 +10,12 @@ newThreadActionTypes, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { + userStoreSearchIndex, + relativeMemberInfoSelectorForMembersOfThread, +} from 'lib/selectors/user-selectors'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils'; +import SearchIndex from 'lib/shared/search-index'; import { threadHasPermission, viewerIsMember, @@ -27,6 +32,7 @@ type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types'; +import type { RelativeMemberInfo } from 'lib/types/thread-types'; import { type UserInfos } from 'lib/types/user-types'; import { type DispatchActionPromise, @@ -46,7 +52,8 @@ import { nonThreadCalendarQuery } from '../selectors/nav-selectors'; import SWMansionIcon from '../SWMansionIcon.react'; import css from './chat-input-bar.css'; - +import MentionSuggestionTooltip from './mention-suggestion-tooltip.react'; +import { mentionRegex } from './mention-utils'; type BaseProps = { +threadInfo: ThreadInfo, +inputState: InputState, @@ -65,6 +72,14 @@ +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, + +userSearchIndex: SearchIndex, + +threadMembers: $ReadOnlyArray, + +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, +}; +type TypeaheadMatchedStrings = { + matchedText: string, + matchedTextBeforeAtSymbol: string, + matchedUsernamePrefix: string, }; class ChatInputBar extends React.PureComponent { textarea: ?HTMLTextAreaElement; @@ -120,6 +135,26 @@ if (this.props.threadInfo.id !== prevProps.threadInfo.id && this.textarea) { this.textarea.focus(); + + this.textarea?.setSelectionRange( + this.props.inputState.textCursorPosition, + this.props.inputState.textCursorPosition, + 'none', + ); + } + + if ( + inputState.textCursorPosition !== prevInputState.textCursorPosition && + this.textarea && + this.textarea.selectionStart === this.textarea.selectionEnd + ) { + this.textarea.focus(); + + this.textarea?.setSelectionRange( + inputState.textCursorPosition, + inputState.textCursorPosition, + 'none', + ); } } @@ -264,6 +299,7 @@ value={this.props.inputState.draft} onChange={this.onChangeMessageText} onKeyDown={this.onKeyDown} + onClick={this.onClickTextarea} ref={this.textareaRef} autoFocus /> @@ -300,11 +336,32 @@ ); } + let typeaheadTooltip; + if (this.props.typeaheadMatchedStrings && this.textarea) { + typeaheadTooltip = ( + + ); + } + return (
{joinButton} {previews} {content} + {typeaheadTooltip}
); } @@ -318,6 +375,15 @@ onChangeMessageText = (event: SyntheticEvent) => { this.props.inputState.setDraft(event.currentTarget.value); + this.props.inputState.setTextCursorPosition( + event.currentTarget.selectionStart, + ); + }; + + onClickTextarea = (event: SyntheticEvent) => { + this.props.inputState.setTextCursorPosition( + event.currentTarget.selectionStart, + ); }; focusAndUpdateText = (text: string) => { @@ -361,8 +427,6 @@ const text = trimMessage(this.props.inputState.draft); if (text) { - // TODO we should make the send button appear dynamically - // iff trimmed text is nonempty, just like native this.dispatchTextMessageAction(text, nextLocalID); nextLocalID++; } @@ -462,6 +526,38 @@ const calendarQuery = useSelector(nonThreadCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); + const userSearchIndex = useSelector(userStoreSearchIndex); + const threadMembers = useSelector( + relativeMemberInfoSelectorForMembersOfThread(props.threadInfo.id), + ); + + const inputSliceEndingAtCursor = React.useMemo( + () => + props.inputState.draft.slice(0, props.inputState.textCursorPosition), + [props.inputState.draft, props.inputState.textCursorPosition], + ); + // we only try to match if there is end of text or whitespace after cursor + const typeaheadRegexMatches = React.useMemo( + () => + inputSliceEndingAtCursor.length === props.inputState.draft.length || + /\s/.test(props.inputState.draft[props.inputState.textCursorPosition]) + ? inputSliceEndingAtCursor.match(mentionRegex) + : null, + [ + inputSliceEndingAtCursor, + props.inputState.textCursorPosition, + props.inputState.draft, + ], + ); + + const typeaheadMatchedStrings: ?TypeaheadMatchedStrings = + typeaheadRegexMatches !== null + ? { + matchedText: typeaheadRegexMatches[0], + matchedTextBeforeAtSymbol: typeaheadRegexMatches[1], + matchedUsernamePrefix: typeaheadRegexMatches[2], + } + : null; return ( ); },