diff --git a/web/chat/mention-suggestion-tooltip.react.js b/web/chat/mention-suggestion-tooltip.react.js index 243e001b7..e6fe423e5 100644 --- a/web/chat/mention-suggestion-tooltip.react.js +++ b/web/chat/mention-suggestion-tooltip.react.js @@ -1,55 +1,110 @@ // @flow import * as React from 'react'; +import SearchIndex from 'lib/shared/search-index'; +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 css from './mention-suggestion-tooltip.css'; +import { + getTypeaheadTooltipActions, + getTypeaheadTooltipPosition, + getTypeaheadUserSuggestions, +} from './mention-utils'; -export type MentionSuggestionTooltipAction = { - +key: string, - +onClick: (SyntheticEvent) => mixed, - +actionButtonContent: React.Node, -}; - -export type TooltipPosition = { - +top: number, - +left: number, -}; export type MentionSuggestionTooltipProps = { - +actions: $ReadOnlyArray, - +tooltipPosition: TooltipPosition, + +inputState: InputState, + +textarea: HTMLTextAreaElement, + +userSearchIndex: SearchIndex, + +threadMembers: $ReadOnlyArray, + +viewerID: ?string, + +matchedText: string, + +matchedTextBeforeAtSymbol: string, + +matchedUsernamePrefix: string, }; function MentionSuggestionTooltip( props: MentionSuggestionTooltipProps, ): React.Node { - const { actions, tooltipPosition } = props; + const { + inputState, + textarea, + userSearchIndex, + threadMembers, + viewerID, + matchedText, + matchedTextBeforeAtSymbol, + matchedUsernamePrefix, + } = props; + + const typedPrefix = matchedUsernamePrefix ?? ''; + + const suggestedUsers = React.useMemo( + () => + getTypeaheadUserSuggestions( + userSearchIndex, + threadOtherMembers(threadMembers, viewerID), + typedPrefix, + ), + [userSearchIndex, threadMembers, viewerID, typedPrefix], + ); + + const actions = React.useMemo( + () => + getTypeaheadTooltipActions( + inputState, + textarea, + suggestedUsers, + matchedTextBeforeAtSymbol, + matchedText, + ), + [ + inputState, + textarea, + suggestedUsers, + matchedTextBeforeAtSymbol, + matchedText, + ], + ); + + const tooltipPosition = React.useMemo( + () => + getTypeaheadTooltipPosition( + textarea, + actions.length, + matchedTextBeforeAtSymbol, + ), + [textarea, actions.length, matchedTextBeforeAtSymbol], + ); const tooltipPositionStyle = React.useMemo( () => ({ top: tooltipPosition.top, left: tooltipPosition.left, }), [tooltipPosition], ); const tooltipButtons = React.useMemo(() => { return actions.map(({ key, onClick, actionButtonContent }) => ( )); }, [actions]); if (!actions || actions.length === 0) { return null; } return (
{tooltipButtons}
); } export default MentionSuggestionTooltip; diff --git a/web/chat/mention-utils.js b/web/chat/mention-utils.js index 47f98d45f..0e9d6e749 100644 --- a/web/chat/mention-utils.js +++ b/web/chat/mention-utils.js @@ -1,159 +1,159 @@ // @flow import * as React from 'react'; import { oldValidUsernameRegexString } from 'lib/shared/account-utils'; import SearchIndex from 'lib/shared/search-index'; import { stringForUserExplicit } from 'lib/shared/user-utils'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; const mentionRegex: RegExp = new RegExp( `(?(?:^(?:.|\n)*\\s+)|^)@(?${oldValidUsernameRegexString})?$`, ); import { type InputState } from '../input/input-state'; import { typeaheadStyle } from './chat-constants'; export type MentionSuggestionTooltipAction = { +key: string, +onClick: (SyntheticEvent) => mixed, +actionButtonContent: React.Node, }; export type TooltipPosition = { +top: number, +left: number, }; function getTypeaheadUserSuggestions( userSearchIndex: SearchIndex, usersInThread: $ReadOnlyArray, typedPrefix: string, ): $ReadOnlyArray { const userIDs = userSearchIndex.getSearchResults(typedPrefix); return usersInThread .filter(user => typedPrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare(stringForUserExplicit(userB)), ); } function getCaretOffsets( textarea: HTMLTextAreaElement, text: string, ): { caretTopOffset: number, caretLeftOffset: number } { if (!textarea) { return { caretTopOffset: 0, caretLeftOffset: 0 }; } // terribly hacky but it works I guess :D // we had to use it, as it's hard to count lines in textarea // and track cursor position within it as // lines can be wrapped into new lines without \n character // as result of overflow const textareaStyle: CSSStyleDeclaration = window.getComputedStyle( textarea, null, ); const div = document.createElement('div'); for (const styleName of textareaStyle) { div.style.setProperty(styleName, textareaStyle.getPropertyValue(styleName)); } div.style.display = 'inline-block'; div.style.position = 'absolute'; div.textContent = text; const span = document.createElement('span'); span.textContent = textarea.value.slice(text.length); div.appendChild(span); document.body?.appendChild(div); const { offsetTop, offsetLeft } = span; document.body?.removeChild(div); const textareaWidth = parseInt(textareaStyle.getPropertyValue('width')); const caretLeftOffset = offsetLeft + typeaheadStyle.tooltipWidth > textareaWidth ? textareaWidth - typeaheadStyle.tooltipWidth : offsetLeft; return { caretTopOffset: offsetTop - textarea.scrollTop, caretLeftOffset, }; } function getTypeaheadTooltipActions( inputState: InputState, textarea: HTMLTextAreaElement, suggestedUsers: $ReadOnlyArray, matchedTextBefore: string, - wholeMatch: string, + matchedText: string, ): $ReadOnlyArray { return suggestedUsers .filter( suggestedUser => stringForUserExplicit(suggestedUser) !== 'anonymous', ) .map(suggestedUser => ({ key: suggestedUser.id, onClick: () => { const newPrefixText = matchedTextBefore; - let newSuffixText = inputState.draft.slice(wholeMatch.length); + let newSuffixText = inputState.draft.slice(matchedText.length); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; const newText = newPrefixText + '@' + stringForUserExplicit(suggestedUser) + newSuffixText; inputState.setDraft(newText); inputState.setTextCursorPosition( newText.length - newSuffixText.length + 1, ); }, actionButtonContent: stringForUserExplicit(suggestedUser), })); } function getTypeaheadTooltipPosition( textarea: HTMLTextAreaElement, actionsLength: number, matchedTextBefore: string, ): TooltipPosition { const { caretTopOffset, caretLeftOffset } = getCaretOffsets( textarea, matchedTextBefore, ); const textareaBoundingClientRect = textarea.getBoundingClientRect(); const top: number = textareaBoundingClientRect.top - Math.min( typeaheadStyle.tooltipVerticalPadding + actionsLength * typeaheadStyle.rowHeight, typeaheadStyle.tooltipMaxHeight, ) - typeaheadStyle.tooltipTopOffset + caretTopOffset; const left: number = textareaBoundingClientRect.left - typeaheadStyle.tooltipLeftOffset + caretLeftOffset; return { top, left }; } export { mentionRegex, getTypeaheadUserSuggestions, getCaretOffsets, getTypeaheadTooltipActions, getTypeaheadTooltipPosition, };