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, +}; +export type TypeaheadMatchedStrings = { + entireText: string, + textBeforeAtSymbol: string, + usernamePrefix: string, }; class ChatInputBar extends React.PureComponent { textarea: ?HTMLTextAreaElement; @@ -118,8 +133,19 @@ return; } - if (this.props.threadInfo.id !== prevProps.threadInfo.id && this.textarea) { + if ( + (this.props.threadInfo.id !== prevProps.threadInfo.id || + (inputState.textCursorPosition !== prevInputState.textCursorPosition && + this.textarea?.selectionStart === this.textarea?.selectionEnd)) && + this.textarea + ) { this.textarea.focus(); + + this.textarea?.setSelectionRange( + inputState.textCursorPosition, + inputState.textCursorPosition, + 'none', + ); } } @@ -264,6 +290,8 @@ value={this.props.inputState.draft} onChange={this.onChangeMessageText} onKeyDown={this.onKeyDown} + onClick={this.onClickTextarea} + onSelect={this.onSelectTextarea} ref={this.textareaRef} autoFocus /> @@ -300,11 +328,26 @@ ); } + let typeaheadTooltip; + if (this.props.typeaheadMatchedStrings && this.textarea) { + typeaheadTooltip = ( + + ); + } + return (
{joinButton} {previews} {content} + {typeaheadTooltip}
); } @@ -318,6 +361,21 @@ 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, + ); + }; + + onSelectTextarea = (event: SyntheticEvent) => { + this.props.inputState.setTextCursorPosition( + event.currentTarget.selectionStart, + ); }; focusAndUpdateText = (text: string) => { @@ -361,8 +419,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 +518,41 @@ 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 = React.useMemo( + () => + typeaheadRegexMatches !== null + ? { + entireText: typeaheadRegexMatches[0], + textBeforeAtSymbol: typeaheadRegexMatches[1], + usernamePrefix: typeaheadRegexMatches[2], + } + : null, + [typeaheadRegexMatches], + ); return ( ); }, diff --git a/web/chat/mention-suggestion-tooltip.react.js b/web/chat/mention-suggestion-tooltip.react.js --- a/web/chat/mention-suggestion-tooltip.react.js +++ b/web/chat/mention-suggestion-tooltip.react.js @@ -8,6 +8,7 @@ import Button from '../components/button.react'; import type { InputState } from '../input/input-state'; +import { type TypeaheadMatchedStrings } from './chat-input-bar.react'; import css from './mention-suggestion-tooltip.css'; import { getTypeaheadTooltipActions, @@ -21,9 +22,7 @@ +userSearchIndex: SearchIndex, +threadMembers: $ReadOnlyArray, +viewerID: ?string, - +matchedText: string, - +matchedTextBeforeAtSymbol: string, - +matchedUsernamePrefix: string, + +matchedStrings: TypeaheadMatchedStrings, }; function MentionSuggestionTooltip( @@ -35,11 +34,15 @@ userSearchIndex, threadMembers, viewerID, - matchedText, - matchedTextBeforeAtSymbol, - matchedUsernamePrefix, + matchedStrings, } = props; + const { + entireText: matchedText, + textBeforeAtSymbol: matchedTextBeforeAtSymbol, + usernamePrefix: matchedUsernamePrefix, + } = matchedStrings; + const typedPrefix = matchedUsernamePrefix ?? ''; const suggestedUsers = React.useMemo(