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 @@ -110,6 +110,17 @@ if (inputState.draft !== prevInputState.draft) { this.updateHeight(); } + + if ( + inputState.draft !== prevInputState.draft || + inputState.textCursorPosition !== prevInputState.textCursorPosition + ) { + inputState.setTypeaheadState({ + isVisible: true, + chosenButtonNumber: 0, + }); + } + const curUploadIDs = ChatInputBar.unassignedUploadIDs( inputState.pendingUploads, ); @@ -330,7 +341,11 @@ } let typeaheadTooltip; - if (this.props.typeaheadMatchedStrings && this.textarea) { + if ( + this.props.typeaheadMatchedStrings && + this.textarea && + this.props.inputState.typeaheadState.isVisible + ) { typeaheadTooltip = ( ) => { - if (event.key === 'Enter' && !event.shiftKey) { + const accept = this.props.inputState.typeaheadState.accept; + const close = this.props.inputState.typeaheadState.close; + + if (!this.props.inputState.typeaheadState.isVisible || !accept || !close) { + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + this.send(); + } + } else if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + accept(); + close(); + } else if (event.key === 'ArrowDown') { + event.preventDefault(); + this.props.inputState.setTypeaheadState({ + chosenButtonNumber: + (this.props.inputState.typeaheadState.chosenButtonNumber ?? 0) + 1, + }); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + this.props.inputState.setTypeaheadState({ + chosenButtonNumber: + (this.props.inputState.typeaheadState.chosenButtonNumber ?? 0) - 1, + }); + } else if (event.key === 'Escape') { event.preventDefault(); - this.send(); + close(); } }; diff --git a/web/chat/typeahead-tooltip.css b/web/chat/typeahead-tooltip.css --- a/web/chat/typeahead-tooltip.css +++ b/web/chat/typeahead-tooltip.css @@ -53,7 +53,7 @@ white-space: nowrap; } -.suggestion:hover { +.suggestionHover { background-color: var(--typeahead-overlay-light); } diff --git a/web/chat/typeahead-tooltip.react.js b/web/chat/typeahead-tooltip.react.js --- a/web/chat/typeahead-tooltip.react.js +++ b/web/chat/typeahead-tooltip.react.js @@ -7,14 +7,16 @@ import { threadOtherMembers } from 'lib/shared/thread-utils'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; -import Button from '../components/button.react'; import type { InputState } from '../input/input-state'; import { type TypeaheadMatchedStrings } from './chat-input-bar.react'; import css from './typeahead-tooltip.css'; import { + getTypeaheadOverlayScroll, getTypeaheadTooltipActions, + getTypeaheadTooltipButtons, getTypeaheadTooltipPosition, getTypeaheadUserSuggestions, + getTypeaheadChosenActionPosition, } from './typeahead-utils'; export type TypeaheadTooltipProps = { @@ -35,13 +37,6 @@ viewerID, matchedStrings, } = props; - const [isVisible, setIsVisible] = React.useState(false); - - React.useEffect(() => { - setIsVisible(true); - - return () => setIsVisible(false); - }, [setIsVisible]); const { entireText: matchedText, @@ -51,6 +46,15 @@ const typedPrefix = matchedUsernamePrefix ?? ''; + const [isAnimation, setIsAnimation] = React.useState(false); + const overlayRef = React.useRef(); + + React.useEffect(() => { + setIsAnimation(true); + + return () => setIsAnimation(false); + }, []); + const suggestedUsers = React.useMemo( () => getTypeaheadUserSuggestions( @@ -63,16 +67,18 @@ const actions = React.useMemo( () => - getTypeaheadTooltipActions( - inputState, - textarea, + getTypeaheadTooltipActions({ + inputStateDraft: inputState.draft, + inputStateSetDraft: inputState.setDraft, + inputStateSetTextCursorPosition: inputState.setTextCursorPosition, suggestedUsers, matchedTextBeforeAtSymbol, matchedText, - ), + }), [ - inputState, - textarea, + inputState.draft, + inputState.setDraft, + inputState.setTextCursorPosition, suggestedUsers, matchedTextBeforeAtSymbol, matchedText, @@ -97,25 +103,80 @@ [tooltipPosition], ); + const chosenActionPosition = React.useMemo( + () => + getTypeaheadChosenActionPosition( + inputState.typeaheadState.chosenButtonNumber, + actions.length, + ), + [inputState.typeaheadState.chosenButtonNumber, actions.length], + ); + const tooltipButtons = React.useMemo(() => { - return actions.map(({ key, onClick, actionButtonContent }) => ( - - )); - }, [actions]); - - if (!actions || actions.length === 0) { - return null; - } + return getTypeaheadTooltipButtons( + inputState.setTypeaheadState, + actions, + chosenActionPosition, + ); + }, [inputState.setTypeaheadState, actions, chosenActionPosition]); + + const close = React.useCallback(() => { + const setter = inputState.setTypeaheadState; + setter({ + isVisible: false, + chosenButtonNumber: 0, + close: null, + accept: null, + }); + }, [inputState.setTypeaheadState]); + + const accept = React.useCallback(() => { + actions[chosenActionPosition].onClick(); + }, [actions, chosenActionPosition]); + + React.useEffect(() => { + const setter = inputState.setTypeaheadState; + setter({ + close: actions.length > 0 ? close : null, + accept: actions.length > 0 ? accept : null, + }); + }, [close, accept, actions, inputState.setTypeaheadState]); + + React.useEffect(() => { + const current = overlayRef.current; + if (current) { + current.scrollTop = getTypeaheadOverlayScroll( + current.scrollTop, + chosenActionPosition, + ); + } + }, [chosenActionPosition]); + + React.useEffect(() => { + const current = overlayRef.current; + if (current) { + current.scrollTop = getTypeaheadOverlayScroll( + current.scrollTop, + chosenActionPosition, + ); + } + }, [chosenActionPosition]); const overlayClasses = classNames(css.suggestionsContainer, { - [css.notVisible]: !isVisible, - [css.visible]: isVisible, + [css.notVisible]: !isAnimation, + [css.visible]: isAnimation, }); + if (actions.length === 0) { + return null; + } + return ( -
+
{tooltipButtons}
); diff --git a/web/chat/typeahead-utils.js b/web/chat/typeahead-utils.js --- a/web/chat/typeahead-utils.js +++ b/web/chat/typeahead-utils.js @@ -1,5 +1,6 @@ // @flow +import classNames from 'classnames'; import * as React from 'react'; import { oldValidUsernameRegexString } from 'lib/shared/account-utils'; @@ -7,16 +8,18 @@ import { stringForUserExplicit } from 'lib/shared/user-utils'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; +import Button from '../components/button.react'; +import type { TypeaheadState } from '../input/input-state'; +import { typeaheadStyle } from './chat-constants'; +import css from './typeahead-tooltip.css'; + const typeaheadRegex: RegExp = new RegExp( `(?(?:^(?:.|\n)*\\s+)|^)@(?${oldValidUsernameRegexString})?$`, ); -import { type InputState } from '../input/input-state'; -import { typeaheadStyle } from './chat-constants'; - export type TypeaheadTooltipAction = { +key: string, - +onClick: (SyntheticEvent) => mixed, + +onClick: () => mixed, +actionButtonContent: React.Node, }; @@ -86,14 +89,26 @@ caretLeftOffset, }; } - -function getTypeaheadTooltipActions( - inputState: InputState, - textarea: HTMLTextAreaElement, +export type GetTypeaheadTooltipActionsParams = { + inputStateDraft: string, + inputStateSetDraft: (draft: string) => void, + inputStateSetTextCursorPosition: (newPosition: number) => void, suggestedUsers: $ReadOnlyArray, - matchedTextBefore: string, + matchedTextBeforeAtSymbol: string, matchedText: string, +}; + +function getTypeaheadTooltipActions( + params: GetTypeaheadTooltipActionsParams, ): $ReadOnlyArray { + const { + inputStateDraft, + inputStateSetDraft, + inputStateSetTextCursorPosition, + suggestedUsers, + matchedTextBeforeAtSymbol, + matchedText, + } = params; return suggestedUsers .filter( suggestedUser => stringForUserExplicit(suggestedUser) !== 'anonymous', @@ -101,9 +116,9 @@ .map(suggestedUser => ({ key: suggestedUser.id, onClick: () => { - const newPrefixText = matchedTextBefore; + const newPrefixText = matchedTextBeforeAtSymbol; - let newSuffixText = inputState.draft.slice(matchedText.length); + let newSuffixText = inputStateDraft.slice(matchedText.length); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; const newText = @@ -112,14 +127,71 @@ stringForUserExplicit(suggestedUser) + newSuffixText; - inputState.setDraft(newText); - inputState.setTextCursorPosition( + inputStateSetDraft(newText); + inputStateSetTextCursorPosition( newText.length - newSuffixText.length + 1, ); }, actionButtonContent: stringForUserExplicit(suggestedUser), })); } + +function getTypeaheadTooltipButtons( + inputStateSetTypeaheadState: ($Shape) => void, + actions: $ReadOnlyArray, + chosenActionPosition: number, +): $ReadOnlyArray { + return actions.map((action, idx) => { + const { key, onClick, actionButtonContent } = action; + const buttonClasses = classNames(css.suggestion, { + [css.suggestionHover]: idx === chosenActionPosition, + }); + + const onMouseMove: ( + event: SyntheticEvent, + ) => mixed = () => { + inputStateSetTypeaheadState({ + chosenButtonNumber: idx, + }); + }; + + return ( + + ); + }); +} + +function getTypeaheadOverlayScroll( + currentScrollTop: number, + chosenActionPosition: number, +): number { + const upperButtonBoundary = chosenActionPosition * typeaheadStyle.rowHeight; + const lowerButtonBoundary = + (chosenActionPosition + 1) * typeaheadStyle.rowHeight; + + if (upperButtonBoundary < currentScrollTop) { + return upperButtonBoundary; + } else if ( + lowerButtonBoundary - typeaheadStyle.tooltipMaxHeight > + currentScrollTop + ) { + return ( + lowerButtonBoundary + + typeaheadStyle.tooltipVerticalPadding - + typeaheadStyle.tooltipMaxHeight + ); + } + + return currentScrollTop; +} + function getTypeaheadTooltipPosition( textarea: HTMLTextAreaElement, actionsLength: number, @@ -150,10 +222,24 @@ return { top, left }; } +function getTypeaheadChosenActionPosition( + chosenButtonNumber: number, + actionsLength: number, +): number { + // Getting positive modulo of chosenButtonNumber + return ( + chosenButtonNumber - + Math.floor(chosenButtonNumber / actionsLength) * actionsLength + ); +} + export { typeaheadRegex, getTypeaheadUserSuggestions, getCaretOffsets, getTypeaheadTooltipActions, + getTypeaheadTooltipButtons, + getTypeaheadOverlayScroll, getTypeaheadTooltipPosition, + getTypeaheadChosenActionPosition, };