diff --git a/lib/shared/mention-utils.js b/lib/shared/mention-utils.js --- a/lib/shared/mention-utils.js +++ b/lib/shared/mention-utils.js @@ -23,6 +23,19 @@ +end: number, }; +type TypeaheadUserSuggestionItem = { + +type: 'user', + +userInfo: RelativeMemberInfo, +}; + +export type TypeaheadSuggestionItem = TypeaheadUserSuggestionItem; + +export type TypeaheadTooltipActionItem = { + +key: string, + +execute: () => void, + +actionButtonContent: SuggestionItemType, +}; + // The simple-markdown package already breaks words out for us, and we are // supposed to only match when the first word of the input matches const markdownMentionRegex: RegExp = new RegExp( @@ -69,7 +82,7 @@ threadMembers: $ReadOnlyArray, viewerID: ?string, usernamePrefix: string, -): $ReadOnlyArray { +): $ReadOnlyArray { const userIDs = userSearchIndex.getSearchResults(usernamePrefix); const usersInThread = threadOtherMembers(threadMembers, viewerID); @@ -77,26 +90,25 @@ .filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare(stringForUserExplicit(userB)), - ); + ) + .map(userInfo => ({ type: 'user', userInfo })); } function getNewTextAndSelection( textBeforeAtSymbol: string, entireText: string, - usernamePrefix: string, - user: RelativeMemberInfo, + textPrefix: string, + suggestionText: string, ): { newText: string, newSelectionStart: number, } { - const totalMatchLength = - textBeforeAtSymbol.length + usernamePrefix.length + 1; // 1 for @ char + const totalMatchLength = textBeforeAtSymbol.length + textPrefix.length + 1; // 1 for @ char let newSuffixText = entireText.slice(totalMatchLength); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; - const newText = - textBeforeAtSymbol + '@' + stringForUserExplicit(user) + newSuffixText; + const newText = textBeforeAtSymbol + suggestionText + newSuffixText; const newSelectionStart = newText.length - newSuffixText.length + 1; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -129,7 +129,11 @@ import Alert from '../utils/alert.js'; import { runTiming } from '../utils/animation-utils.js'; import { exitEditAlert } from '../utils/edit-messages-utils.js'; -import { nativeTypeaheadRegex } from '../utils/typeahead-utils.js'; +import { + nativeTypeaheadRegex, + mentionTypeaheadTooltipActions, + mentionTypeaheadTooltipButtonRenderer, +} from '../utils/typeahead-utils.js'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock } = @@ -567,8 +571,10 @@ ); } diff --git a/native/chat/typeahead-tooltip.react.js b/native/chat/typeahead-tooltip.react.js --- a/native/chat/typeahead-tooltip.react.js +++ b/native/chat/typeahead-tooltip.react.js @@ -1,67 +1,64 @@ // @flow import * as React from 'react'; -import { Platform, Text } from 'react-native'; +import { Platform } from 'react-native'; import { PanGestureHandler, FlatList } from 'react-native-gesture-handler'; import { type TypeaheadMatchedStrings, type Selection, - getNewTextAndSelection, + type TypeaheadTooltipActionItem, } from 'lib/shared/mention-utils.js'; -import type { RelativeMemberInfo } from 'lib/types/thread-types.js'; -import UserAvatar from '../avatars/user-avatar.react.js'; import Button from '../components/button.react.js'; import { useStyles } from '../themes/colors.js'; +import type { + TypeaheadTooltipActionsParams, + TypeaheadTooltipButtonRendererParams, +} from '../utils/typeahead-utils.js'; -export type TypeaheadTooltipProps = { +export type TypeaheadTooltipStyles = typeof unboundStyles; + +export type TypeaheadTooltipProps = { +text: string, +matchedStrings: TypeaheadMatchedStrings, - +suggestedUsers: $ReadOnlyArray, + +suggestions: $ReadOnlyArray, +focusAndUpdateTextAndSelection: (text: string, selection: Selection) => void, + +typeaheadButtonRenderer: ( + TypeaheadTooltipButtonRendererParams, + ) => React.Node, + +typeaheadTooltipActionsGetter: ( + TypeaheadTooltipActionsParams, + ) => $ReadOnlyArray>, }; -function TypeaheadTooltip(props: TypeaheadTooltipProps): React.Node { +function TypeaheadTooltip( + props: TypeaheadTooltipProps, +): React.Node { const { text, matchedStrings, - suggestedUsers, + suggestions, focusAndUpdateTextAndSelection, + typeaheadButtonRenderer, + typeaheadTooltipActionsGetter, } = props; const { textBeforeAtSymbol, textPrefix } = matchedStrings; const styles = useStyles(unboundStyles); - - const renderTypeaheadButton = React.useCallback( - ({ item }: { item: RelativeMemberInfo, ... }) => { - const onPress = () => { - const { newText, newSelectionStart } = getNewTextAndSelection( - textBeforeAtSymbol, - text, - textPrefix, - item, - ); - - focusAndUpdateTextAndSelection(newText, { - start: newSelectionStart, - end: newSelectionStart, - }); - }; - - return ( - - ); - }, + const actions = React.useMemo( + () => + typeaheadTooltipActionsGetter({ + suggestions, + textBeforeAtSymbol, + text, + textPrefix, + focusAndUpdateTextAndSelection, + }), [ - styles.button, - styles.buttonLabel, + typeaheadTooltipActionsGetter, + suggestions, textBeforeAtSymbol, text, textPrefix, @@ -69,6 +66,29 @@ ], ); + const renderTypeaheadButton = React.useCallback( + ({ + item, + }: { + item: TypeaheadTooltipActionItem, + ... + }) => { + return ( + + ); + }, + [styles, typeaheadButtonRenderer], + ); + // This is a hack that was introduced due to a buggy behavior of a // absolutely positioned FlatList on Android. @@ -95,17 +115,12 @@ ), - [ - renderTypeaheadButton, - styles.container, - styles.contentContainer, - suggestedUsers, - ], + [actions, renderTypeaheadButton, styles.container, styles.contentContainer], ); const listWithConditionalHandler = React.useMemo(() => { diff --git a/native/utils/typeahead-utils.js b/native/utils/typeahead-utils.js --- a/native/utils/typeahead-utils.js +++ b/native/utils/typeahead-utils.js @@ -1,6 +1,19 @@ // @flow +import * as React from 'react'; +import { Text } from 'react-native'; + import { oldValidUsernameRegexString } from 'lib/shared/account-utils.js'; +import { + getNewTextAndSelection, + type Selection, + type TypeaheadTooltipActionItem, + type TypeaheadSuggestionItem, +} from 'lib/shared/mention-utils.js'; +import { stringForUserExplicit } from 'lib/shared/user-utils.js'; + +import UserAvatar from '../avatars/user-avatar.react.js'; +import type { TypeaheadTooltipStyles } from '../chat/typeahead-tooltip.react.js'; // Native regex is a little bit different than web one as // there are no named capturing groups yet on native. @@ -8,4 +21,83 @@ `((^(.|\n)*\\s+)|^)@(${oldValidUsernameRegexString})?$`, ); -export { nativeTypeaheadRegex }; +export type TypeaheadTooltipActionsParams = { + +suggestions: $ReadOnlyArray, + +textBeforeAtSymbol: string, + +text: string, + +textPrefix: string, + +focusAndUpdateTextAndSelection: (text: string, selection: Selection) => void, +}; +function mentionTypeaheadTooltipActions({ + suggestions, + textBeforeAtSymbol, + text, + textPrefix, + focusAndUpdateTextAndSelection, +}: TypeaheadTooltipActionsParams): $ReadOnlyArray< + TypeaheadTooltipActionItem, +> { + const actions = []; + for (const suggestion of suggestions) { + if (suggestion.type === 'user') { + const { userInfo } = suggestion; + const resolvedUsername = stringForUserExplicit(userInfo); + if (resolvedUsername === 'anonymous') { + continue; + } + const mentionText = `@${resolvedUsername}`; + actions.push({ + key: userInfo.id, + execute: () => { + const { newText, newSelectionStart } = getNewTextAndSelection( + textBeforeAtSymbol, + text, + textPrefix, + mentionText, + ); + focusAndUpdateTextAndSelection(newText, { + start: newSelectionStart, + end: newSelectionStart, + }); + }, + actionButtonContent: { + type: 'user', + userInfo, + }, + }); + } + } + return actions; +} + +export type TypeaheadTooltipButtonRendererParams = { + +item: TypeaheadTooltipActionItem, + +styles: TypeaheadTooltipStyles, +}; +function mentionTypeaheadTooltipButtonRenderer({ + item, + styles, +}: TypeaheadTooltipButtonRendererParams): React.Node { + let avatarComponent = null; + let typeaheadTooltipButtonText = null; + if (item.actionButtonContent.type === 'user') { + avatarComponent = ( + + ); + typeaheadTooltipButtonText = item.actionButtonContent.userInfo.username; + } + return ( + <> + {avatarComponent} + + {typeaheadTooltipButtonText} + + + ); +} + +export { + nativeTypeaheadRegex, + mentionTypeaheadTooltipActions, + mentionTypeaheadTooltipButtonRenderer, +}; 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 @@ -631,7 +631,7 @@ props.inputState.typeaheadState.frozenMentionsCandidates, viewerID, typeaheadMatchedStrings.textPrefix, - ); + ).map(suggestion => suggestion.userInfo); }, [ userSearchIndex, props.inputState.typeaheadState.frozenMentionsCandidates, 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 @@ -107,7 +107,7 @@ textBeforeAtSymbol, inputStateDraft, textPrefix, - suggestedUser, + stringForUserExplicit(suggestedUser), ); inputStateSetDraft(newText);