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 @@ -17,8 +17,9 @@ getMentionTypeaheadUserSuggestions, getTypeaheadRegexMatches, getUserMentionsCandidates, + type MentionTypeaheadSuggestionItem, + type TypeaheadMatchedStrings, } from 'lib/shared/mention-utils.js'; -import type { TypeaheadMatchedStrings } from 'lib/shared/mention-utils.js'; import { localIDPrefix, trimMessage } from 'lib/shared/message-utils.js'; import { threadHasPermission, @@ -35,7 +36,6 @@ type ThreadInfo, type ClientThreadJoinRequest, type ThreadJoinPayload, - type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { @@ -56,7 +56,11 @@ import Multimedia from '../media/multimedia.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; -import { webMentionTypeaheadRegex } from '../utils/typeahead-utils.js'; +import { + webMentionTypeaheadRegex, + getMentionTypeaheadTooltipActions, + getMentionTypeaheadTooltipButtons, +} from '../utils/typeahead-utils.js'; type BaseProps = { +threadInfo: ThreadInfo, @@ -74,7 +78,7 @@ +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise<ThreadJoinPayload>, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, - +suggestedUsers: $ReadOnlyArray<RelativeMemberInfo>, + +suggestions: $ReadOnlyArray<MentionTypeaheadSuggestionItem>, +parentThreadInfo: ?ThreadInfo, }; @@ -364,7 +368,7 @@ let typeaheadTooltip; if ( this.props.inputState.typeaheadState.canBeVisible && - this.props.suggestedUsers.length > 0 && + this.props.suggestions.length > 0 && this.props.typeaheadMatchedStrings && this.textarea ) { @@ -373,7 +377,9 @@ inputState={this.props.inputState} textarea={this.textarea} matchedStrings={this.props.typeaheadMatchedStrings} - suggestedUsers={this.props.suggestedUsers} + suggestions={this.props.suggestions} + typeaheadTooltipActionsGetter={getMentionTypeaheadTooltipActions} + typeaheadTooltipButtonsGetter={getMentionTypeaheadTooltipButtons} /> ); } @@ -621,23 +627,22 @@ props.inputState.typeaheadState.keepUpdatingThreadMembers, ]); - const suggestedUsers: $ReadOnlyArray<RelativeMemberInfo> = - React.useMemo(() => { - if (!typeaheadMatchedStrings) { - return []; - } - return getMentionTypeaheadUserSuggestions( - userSearchIndex, - props.inputState.typeaheadState.frozenUserMentionsCandidates, - viewerID, - typeaheadMatchedStrings.textPrefix, - ).map(suggestion => suggestion.userInfo); - }, [ + const suggestedUsers = React.useMemo(() => { + if (!typeaheadMatchedStrings) { + return []; + } + return getMentionTypeaheadUserSuggestions( userSearchIndex, props.inputState.typeaheadState.frozenUserMentionsCandidates, viewerID, - typeaheadMatchedStrings, - ]); + typeaheadMatchedStrings.textPrefix, + ); + }, [ + userSearchIndex, + props.inputState.typeaheadState.frozenUserMentionsCandidates, + viewerID, + typeaheadMatchedStrings, + ]); return ( <ChatInputBar @@ -652,7 +657,7 @@ dispatchActionPromise={dispatchActionPromise} joinThread={callJoinThread} typeaheadMatchedStrings={typeaheadMatchedStrings} - suggestedUsers={suggestedUsers} + suggestions={suggestedUsers} parentThreadInfo={parentThreadInfo} /> ); 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 @@ -3,28 +3,47 @@ import classNames from 'classnames'; import * as React from 'react'; -import type { TypeaheadMatchedStrings } from 'lib/shared/mention-utils.js'; -import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; +import type { + TypeaheadMatchedStrings, + TypeaheadTooltipActionItem, +} from 'lib/shared/mention-utils.js'; import { leastPositiveResidue } from 'lib/utils/math-utils.js'; import css from './typeahead-tooltip.css'; import type { InputState } from '../input/input-state.js'; +import type { + GetTypeaheadTooltipActionsParams, + GetMentionTypeaheadTooltipButtonsParams, +} from '../utils/typeahead-utils.js'; import { getTypeaheadOverlayScroll, - getTypeaheadTooltipActions, - getTypeaheadTooltipButtons, getTypeaheadTooltipPosition, } from '../utils/typeahead-utils.js'; -export type TypeaheadTooltipProps = { +export type TypeaheadTooltipProps<SuggestionItemType> = { +inputState: InputState, +textarea: HTMLTextAreaElement, +matchedStrings: TypeaheadMatchedStrings, - +suggestedUsers: $ReadOnlyArray<RelativeMemberInfo>, + +suggestions: $ReadOnlyArray<SuggestionItemType>, + +typeaheadTooltipActionsGetter: ( + GetTypeaheadTooltipActionsParams<SuggestionItemType>, + ) => $ReadOnlyArray<TypeaheadTooltipActionItem<SuggestionItemType>>, + +typeaheadTooltipButtonsGetter: ( + GetMentionTypeaheadTooltipButtonsParams<SuggestionItemType>, + ) => React.Node, }; -function TypeaheadTooltip(props: TypeaheadTooltipProps): React.Node { - const { inputState, textarea, matchedStrings, suggestedUsers } = props; +function TypeaheadTooltip<SuggestionItemType>( + props: TypeaheadTooltipProps<SuggestionItemType>, +): React.Node { + const { + inputState, + textarea, + matchedStrings, + suggestions, + typeaheadTooltipActionsGetter, + typeaheadTooltipButtonsGetter, + } = props; const { textBeforeAtSymbol, textPrefix } = matchedStrings; @@ -38,7 +57,7 @@ React.useEffect(() => { setChosenPositionInOverlay(0); - }, [suggestedUsers]); + }, [suggestions]); React.useEffect(() => { setIsVisibleForAnimation(true); @@ -57,11 +76,11 @@ const actions = React.useMemo( () => - getTypeaheadTooltipActions({ + typeaheadTooltipActionsGetter({ inputStateDraft: inputState.draft, inputStateSetDraft: inputState.setDraft, inputStateSetTextCursorPosition: inputState.setTextCursorPosition, - suggestedUsers, + suggestions, textBeforeAtSymbol, textPrefix, }), @@ -69,9 +88,10 @@ inputState.draft, inputState.setDraft, inputState.setTextCursorPosition, - suggestedUsers, + suggestions, textBeforeAtSymbol, textPrefix, + typeaheadTooltipActionsGetter, ], ); @@ -91,12 +111,12 @@ const tooltipButtons = React.useMemo( () => - getTypeaheadTooltipButtons( + typeaheadTooltipButtonsGetter({ setChosenPositionInOverlay, chosenPositionInOverlay, actions, - ), - [setChosenPositionInOverlay, actions, chosenPositionInOverlay], + }), + [typeaheadTooltipButtonsGetter, chosenPositionInOverlay, actions], ); const close = React.useCallback(() => { @@ -164,7 +184,7 @@ } }, [chosenPositionInOverlay]); - if (suggestedUsers.length === 0) { + if (suggestions.length === 0) { return null; } diff --git a/web/utils/typeahead-utils.js b/web/utils/typeahead-utils.js --- a/web/utils/typeahead-utils.js +++ b/web/utils/typeahead-utils.js @@ -4,10 +4,13 @@ import * as React from 'react'; import { oldValidUsernameRegexString } from 'lib/shared/account-utils.js'; -import { getNewTextAndSelection } from 'lib/shared/mention-utils.js'; +import { + getNewTextAndSelection, + type MentionTypeaheadSuggestionItem, + type TypeaheadTooltipActionItem, +} from 'lib/shared/mention-utils.js'; import { stringForUserExplicit } from 'lib/shared/user-utils.js'; import type { SetState } from 'lib/types/hook-types.js'; -import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; import UserAvatar from '../avatars/user-avatar.react.js'; import { typeaheadStyle } from '../chat/chat-constants.js'; @@ -18,12 +21,6 @@ `(?<textPrefix>(?:^(?:.|\n)*\\s+)|^)@(?<username>${oldValidUsernameRegexString})?$`, ); -export type TypeaheadTooltipAction = { - +key: string, - +execute: () => mixed, - +actionButtonContent: { +userID: string, +username: string }, -}; - export type TooltipPosition = { +top: number, +left: number, @@ -76,55 +73,67 @@ caretLeftOffset, }; } -export type GetTypeaheadTooltipActionsParams = { +export type GetTypeaheadTooltipActionsParams<SuggestionItemType> = { +inputStateDraft: string, +inputStateSetDraft: (draft: string) => mixed, +inputStateSetTextCursorPosition: (newPosition: number) => mixed, - +suggestedUsers: $ReadOnlyArray<RelativeMemberInfo>, + +suggestions: $ReadOnlyArray<SuggestionItemType>, +textBeforeAtSymbol: string, +textPrefix: string, }; -function getTypeaheadTooltipActions( - params: GetTypeaheadTooltipActionsParams, -): $ReadOnlyArray<TypeaheadTooltipAction> { +function getMentionTypeaheadTooltipActions( + params: GetTypeaheadTooltipActionsParams<MentionTypeaheadSuggestionItem>, +): $ReadOnlyArray<TypeaheadTooltipActionItem<MentionTypeaheadSuggestionItem>> { const { inputStateDraft, inputStateSetDraft, inputStateSetTextCursorPosition, - suggestedUsers, + suggestions, textBeforeAtSymbol, textPrefix, } = params; - return suggestedUsers - .filter( - suggestedUser => stringForUserExplicit(suggestedUser) !== 'anonymous', - ) - .map(suggestedUser => ({ - key: suggestedUser.id, - execute: () => { - const { newText, newSelectionStart } = getNewTextAndSelection( - textBeforeAtSymbol, - inputStateDraft, - textPrefix, - stringForUserExplicit(suggestedUser), - ); - - inputStateSetDraft(newText); - inputStateSetTextCursorPosition(newSelectionStart); - }, - actionButtonContent: { - userID: suggestedUser.id, - username: stringForUserExplicit(suggestedUser), - }, - })); + const actions = []; + for (const suggestion of suggestions) { + if (suggestion.type === 'user') { + const suggestedUser = suggestion.userInfo; + if (stringForUserExplicit(suggestedUser) === 'anonymous') { + continue; + } + const mentionText = `@${stringForUserExplicit(suggestedUser)}`; + actions.push({ + key: suggestedUser.id, + execute: () => { + const { newText, newSelectionStart } = getNewTextAndSelection( + textBeforeAtSymbol, + inputStateDraft, + textPrefix, + mentionText, + ); + + inputStateSetDraft(newText); + inputStateSetTextCursorPosition(newSelectionStart); + }, + actionButtonContent: { + type: 'user', + userInfo: suggestedUser, + }, + }); + } + } + return actions; } -function getTypeaheadTooltipButtons( - setChosenPositionInOverlay: SetState<number>, - chosenPositionInOverlay: number, - actions: $ReadOnlyArray<TypeaheadTooltipAction>, +export type GetMentionTypeaheadTooltipButtonsParams<SuggestionItemType> = { + +setChosenPositionInOverlay: SetState<number>, + +chosenPositionInOverlay: number, + +actions: $ReadOnlyArray<TypeaheadTooltipActionItem<SuggestionItemType>>, +}; +function getMentionTypeaheadTooltipButtons( + params: GetMentionTypeaheadTooltipButtonsParams<MentionTypeaheadSuggestionItem>, ): $ReadOnlyArray<React.Node> { + const { setChosenPositionInOverlay, chosenPositionInOverlay, actions } = + params; return actions.map((action, idx) => { const { key, execute, actionButtonContent } = action; const buttonClasses = classNames(css.suggestion, { @@ -137,6 +146,16 @@ setChosenPositionInOverlay(idx); }; + let avatarComponent = null; + let typeaheadButtonText = null; + if (actionButtonContent.type === 'user') { + const suggestedUser = actionButtonContent.userInfo; + avatarComponent = ( + <UserAvatar size="small" userID={actionButtonContent.userInfo.id} /> + ); + typeaheadButtonText = `@${stringForUserExplicit(suggestedUser)}`; + } + return ( <Button key={key} @@ -144,8 +163,8 @@ onMouseMove={onMouseMove} className={buttonClasses} > - <UserAvatar size="small" userID={actionButtonContent.userID} /> - <span className={css.username}>@{actionButtonContent.username}</span> + {avatarComponent} + <span className={css.username}>{typeaheadButtonText}</span> </Button> ); }); @@ -208,8 +227,8 @@ export { webMentionTypeaheadRegex, getCaretOffsets, - getTypeaheadTooltipActions, - getTypeaheadTooltipButtons, + getMentionTypeaheadTooltipActions, + getMentionTypeaheadTooltipButtons, getTypeaheadOverlayScroll, getTypeaheadTooltipPosition, };