diff --git a/lib/shared/mention-utils.js b/lib/shared/mention-utils.js index 273c491ff..ac8ebe5d3 100644 --- a/lib/shared/mention-utils.js +++ b/lib/shared/mention-utils.js @@ -1,130 +1,130 @@ // @flow import { oldValidUsernameRegexString } from './account-utils.js'; import SearchIndex from './search-index.js'; import { threadOtherMembers, chatNameMaxLength } from './thread-utils.js'; import { stringForUserExplicit } from './user-utils.js'; import { threadTypes } from '../types/thread-types-enum.js'; import { type ThreadInfo, type RelativeMemberInfo, } from '../types/thread-types.js'; import { idSchemaRegex } from '../utils/validation-utils.js'; export type TypeaheadMatchedStrings = { +textBeforeAtSymbol: string, - +usernamePrefix: string, + +query: string, }; export type Selection = { +start: number, +end: number, }; // 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( `^(@(${oldValidUsernameRegexString}))\\b`, ); function isMentioned(username: string, text: string): boolean { return new RegExp(`\\B@${username}\\b`, 'i').test(text); } const mentionsExtractionRegex = new RegExp( `\\B(@(${oldValidUsernameRegexString}))\\b`, 'g', ); const chatMentionRegexString = `^(?<!\\\\)(@\\[\\[(${idSchemaRegex}):(.{1,${chatNameMaxLength}}?)(?<!\\\\)\\]\\])`; const chatMentionRegex: RegExp = new RegExp(chatMentionRegexString); function decodeChatMentionText(text: string): string { return text.replace(/\\]/g, ']'); } function extractMentionsFromText(text: string): string[] { const iterator = text.matchAll(mentionsExtractionRegex); return [...iterator].map(matches => matches[2]); } function getTypeaheadRegexMatches( text: string, selection: Selection, regex: RegExp, ): null | RegExp$matchResult { if ( selection.start === selection.end && (selection.start === text.length || /\s/.test(text[selection.end])) ) { return text.slice(0, selection.start).match(regex); } return null; } function getTypeaheadUserSuggestions( userSearchIndex: SearchIndex, threadMembers: $ReadOnlyArray<RelativeMemberInfo>, viewerID: ?string, usernamePrefix: string, ): $ReadOnlyArray<RelativeMemberInfo> { const userIDs = userSearchIndex.getSearchResults(usernamePrefix); const usersInThread = threadOtherMembers(threadMembers, viewerID); return usersInThread .filter(user => usernamePrefix.length === 0 || userIDs.includes(user.id)) .sort((userA, userB) => stringForUserExplicit(userA).localeCompare(stringForUserExplicit(userB)), ); } function getNewTextAndSelection( textBeforeAtSymbol: string, entireText: string, usernamePrefix: string, user: RelativeMemberInfo, ): { newText: string, newSelectionStart: number, } { const totalMatchLength = textBeforeAtSymbol.length + usernamePrefix.length + 1; // 1 for @ char let newSuffixText = entireText.slice(totalMatchLength); newSuffixText = (newSuffixText[0] !== ' ' ? ' ' : '') + newSuffixText; const newText = textBeforeAtSymbol + '@' + stringForUserExplicit(user) + newSuffixText; const newSelectionStart = newText.length - newSuffixText.length + 1; return { newText, newSelectionStart }; } function getMentionsCandidates( threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ): $ReadOnlyArray<RelativeMemberInfo> { if (threadInfo.type !== threadTypes.SIDEBAR) { return threadInfo.members; } if (parentThreadInfo) { return parentThreadInfo.members; } // This scenario should not occur unless the user logs out while looking at a // sidebar. In that scenario, the Redux store may be cleared before ReactNav // finishes transitioning away from the previous screen return []; } export { markdownMentionRegex, isMentioned, extractMentionsFromText, getTypeaheadUserSuggestions, getNewTextAndSelection, getTypeaheadRegexMatches, getMentionsCandidates, chatMentionRegex, decodeChatMentionText, }; diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index ee942b29e..51894cd31 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,1419 +1,1419 @@ // @flow import Icon from '@expo/vector-icons/Ionicons.js'; import invariant from 'invariant'; import _throttle from 'lodash/throttle.js'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, NativeAppEventEmitter, } from 'react-native'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; import Animated, { EasingNode, FadeInDown, FadeOutDown, } from 'react-native-reanimated'; import { useDispatch } from 'react-redux'; import { moveDraftActionType, updateDraftActionType, } from 'lib/actions/draft-actions.js'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreSearchIndex } from 'lib/selectors/user-selectors.js'; import { colorIsDark } from 'lib/shared/color-utils.js'; import { useEditMessage } from 'lib/shared/edit-messages-utils.js'; import { getTypeaheadUserSuggestions, getTypeaheadRegexMatches, type Selection, getMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { useNextLocalID, trimMessage, useMessagePreview, messageKey, type MessagePreviewResult, } from 'lib/shared/message-utils.js'; import SearchIndex from 'lib/shared/search-index.js'; import { threadHasPermission, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, draftKeyFromThreadID, useThreadChatMentionCandidates, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import type { PhotoPaste } from 'lib/types/media-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import type { SendEditMessageResponse, MessageInfo, } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ClientThreadJoinRequest, type ThreadJoinPayload, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import { ChatContext } from './chat-context.js'; import type { ChatNavigationProp } from './chat.react.js'; import { MessageEditingContext, type MessageEditingContextType, } from './message-editing-context.react.js'; import type { RemoveEditMode } from './message-list-types.js'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; // eslint-disable-next-line import/extensions import ClearableTextInput from '../components/clearable-text-input.react'; import type { SyncedSelectionData } from '../components/selectable-text-input.js'; // eslint-disable-next-line import/extensions import SelectableTextInput from '../components/selectable-text-input.react'; import SingleLine from '../components/single-line.react.js'; import SWMansionIcon from '../components/swmansion-icon.react.js'; import { type InputState, InputStateContext, type EditInputBarMessageParameters, } from '../input/input-state.js'; import KeyboardInputHost from '../keyboard/keyboard-input-host.react.js'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state.js'; import { getKeyboardHeight } from '../keyboard/keyboard.js'; import { getDefaultTextMessageRules } from '../markdown/rules.react.js'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import { OverlayContext } from '../navigation/overlay-context.js'; import type { OverlayContextType } from '../navigation/overlay-context.js'; import { type NavigationRoute, ChatCameraModalRouteName, ImagePasteModalRouteName, } from '../navigation/route-names.js'; import { useSelector } from '../redux/redux-utils.js'; import { type Colors, useStyles, useColors } from '../themes/colors.js'; import type { LayoutEvent } from '../types/react-native.js'; import { type AnimatedViewStyle, AnimatedView } from '../types/styles.js'; 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'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolateNode, stopClock } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: EasingNode.inOut(EasingNode.ease), }; type BaseProps = { +threadInfo: ThreadInfo, }; type Props = { ...BaseProps, +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: string, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +isActive: boolean, +keyboardState: ?KeyboardState, +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise<ThreadJoinPayload>, +inputState: ?InputState, +userSearchIndex: SearchIndex, +mentionsCandidates: $ReadOnlyArray<RelativeMemberInfo>, +parentThreadInfo: ?ThreadInfo, +editedMessagePreview: ?MessagePreviewResult, +editedMessageInfo: ?MessageInfo, +editMessage: ( messageID: string, text: string, ) => Promise<SendEditMessageResponse>, +navigation: ?ChatNavigationProp<'MessageList'>, +overlayContext: ?OverlayContextType, +messageEditingContext: ?MessageEditingContextType, }; type State = { +text: string, +textEdited: boolean, +buttonsExpanded: boolean, +selectionState: SyncedSelectionData, +isExitingDuringEditMode: boolean, }; class ChatInputBar extends React.PureComponent<Props, State> { textInput: ?React.ElementRef<typeof TextInput>; clearableTextInput: ?ClearableTextInput; selectableTextInput: ?React.ElementRef<typeof SelectableTextInput>; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: AnimatedViewStyle; cameraRollIconStyle: AnimatedViewStyle; cameraIconStyle: AnimatedViewStyle; expandIconStyle: AnimatedViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: AnimatedViewStyle; clearBeforeRemoveListener: () => void; clearFocusListener: () => void; clearBlurListener: () => void; constructor(props: Props) { super(props); this.state = { text: props.draft, textEdited: false, buttonsExpanded: true, selectionState: { text: props.draft, selection: { start: 0, end: 0 } }, isExitingDuringEditMode: false, }; this.setUpActionIconAnimations(); this.setUpSendIconAnimations(); } setUpActionIconAnimations() { this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolateNode(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [26, 66], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; } setUpSendIconAnimations() { const initialSendButtonContainerOpen = trimMessage(this.props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolateNode(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { if (this.shouldShowTextInput) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } else { this.setUpSendIconAnimations(); } } componentDidMount() { const { isActive, navigation } = this.props; if (isActive) { this.addEditInputMessageListener(); } if (!navigation) { return; } this.clearBeforeRemoveListener = navigation.addListener( 'beforeRemove', this.onNavigationBeforeRemove, ); this.clearFocusListener = navigation.addListener( 'focus', this.onNavigationFocus, ); this.clearBlurListener = navigation.addListener( 'blur', this.onNavigationBlur, ); } componentWillUnmount() { if (this.props.isActive) { this.removeEditInputMessageListener(); } if (this.clearBeforeRemoveListener) { this.clearBeforeRemoveListener(); } if (this.clearFocusListener) { this.clearFocusListener(); } if (this.clearBlurListener) { this.clearBlurListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.textEdited && this.state.text && this.props.threadInfo.id !== prevProps.threadInfo.id ) { this.props.dispatch({ type: moveDraftActionType, payload: { oldKey: draftKeyFromThreadID(prevProps.threadInfo.id), newKey: draftKeyFromThreadID(this.props.threadInfo.id), }, }); } else if (!this.state.textEdited && this.props.draft !== prevProps.draft) { this.setState({ text: this.props.draft }); } if (this.props.isActive && !prevProps.isActive) { this.addEditInputMessageListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeEditInputMessageListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing(prevProps); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } if ( this.props.messageEditingContext?.editState.editedMessage && !prevProps.messageEditingContext?.editState.editedMessage ) { this.blockNavigation(); } } addEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in addEditInputMessageListener', ); this.props.inputState.addEditInputMessageListener(this.focusAndUpdateText); } removeEditInputMessageListener() { invariant( this.props.inputState, 'inputState should be set in removeEditInputMessageListener', ); this.props.inputState.removeEditInputMessageListener( this.focusAndUpdateText, ); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } get shouldShowTextInput(): boolean { if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { return true; } // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place // and won't list the viewer as a member, // which will end up hiding the input. // In this case, we will assume that our creation action // will get translated into a join, and as long // as members are voiced, we can show the input. if (!this.props.threadCreationInProgress) { return false; } return checkIfDefaultMembersAreVoiced(this.props.threadInfo); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; const threadColor = `#${this.props.threadInfo.color}`; const isEditMode = this.isEditMode(); if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( <ActivityIndicator size="small" color="white" style={this.props.styles.joinThreadLoadingIndicator} /> ); } else { const textStyle = colorIsDark(this.props.threadInfo.color) ? this.props.styles.joinButtonTextLight : this.props.styles.joinButtonTextDark; buttonContent = ( <View style={this.props.styles.joinButtonContent}> <SWMansionIcon name="plus" style={textStyle} /> <Text style={textStyle}>Join Chat</Text> </View> ); } joinButton = ( <View style={this.props.styles.joinButtonContainer}> <Button onPress={this.onPressJoin} iosActiveOpacity={0.85} style={[ this.props.styles.joinButton, { backgroundColor: threadColor }, ]} > {buttonContent} </Button> </View> ); } const typeaheadRegexMatches = getTypeaheadRegexMatches( this.state.selectionState.text, this.state.selectionState.selection, nativeTypeaheadRegex, ); let typeaheadTooltip = null; if (typeaheadRegexMatches && !isEditMode) { const typeaheadMatchedStrings = { textBeforeAtSymbol: typeaheadRegexMatches[1] ?? '', - usernamePrefix: typeaheadRegexMatches[4] ?? '', + query: typeaheadRegexMatches[4] ?? '', }; const suggestedUsers = getTypeaheadUserSuggestions( this.props.userSearchIndex, this.props.mentionsCandidates, this.props.viewerID, - typeaheadMatchedStrings.usernamePrefix, + typeaheadMatchedStrings.query, ); if (suggestedUsers.length > 0) { typeaheadTooltip = ( <TypeaheadTooltip text={this.state.text} matchedStrings={typeaheadMatchedStrings} suggestedUsers={suggestedUsers} focusAndUpdateTextAndSelection={this.focusAndUpdateTextAndSelection} /> ); } } let content; const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); if (this.shouldShowTextInput) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( <Text style={this.props.styles.explanation}> You can’t send messages to a user that you’ve blocked. </Text> ); } else if (isMember) { content = ( <Text style={this.props.styles.explanation}> You don’t have permission to send messages. </Text> ); } else if (defaultMembersAreVoiced && canJoin) { content = null; } else { content = ( <Text style={this.props.styles.explanation}> You don’t have permission to send messages. </Text> ); } const keyboardInputHost = Platform.OS === 'android' ? null : ( <KeyboardInputHost textInputRef={this.textInput} /> ); let editedMessage; if (isEditMode && this.props.editedMessagePreview) { const { message } = this.props.editedMessagePreview; editedMessage = ( <AnimatedView style={this.props.styles.editView} entering={FadeInDown} exiting={FadeOutDown} > <View style={this.props.styles.editViewContent}> <TouchableOpacity onPress={this.scrollToEditedMessage} activeOpacity={0.4} > <Text style={[{ color: threadColor }, this.props.styles.editingLabel]} > Editing message </Text> <SingleLine style={this.props.styles.editingMessagePreview}> {message.text} </SingleLine> </TouchableOpacity> </View> <SWMansionIcon style={this.props.styles.exitEditButton} name="cross" size={22} color={threadColor} onPress={this.onPressExitEditMode} /> </AnimatedView> ); } return ( <AnimatedView style={this.props.styles.container} onLayout={this.props.onInputBarLayout} > {typeaheadTooltip} {joinButton} {editedMessage} {content} {keyboardInputHost} </AnimatedView> ); } renderInput() { const expandoButton = ( <TouchableOpacity onPress={this.expandButtons} activeOpacity={0.4} style={this.props.styles.expandButton} > <AnimatedView style={this.expandIconStyle}> <SWMansionIcon name="chevron-right" size={22} color={`#${this.props.threadInfo.color}`} /> </AnimatedView> </TouchableOpacity> ); const threadColor = `#${this.props.threadInfo.color}`; const expandoButtonsViewStyle = [this.props.styles.innerExpandoButtons]; if (this.isEditMode()) { expandoButtonsViewStyle.push({ display: 'none' }); } return ( <TouchableWithoutFeedback onPress={this.dismissKeyboard}> <View style={this.props.styles.inputContainer}> <AnimatedView style={this.expandoButtonsStyle}> <View style={expandoButtonsViewStyle}> {this.state.buttonsExpanded ? expandoButton : null} <TouchableOpacity onPress={this.showMediaGallery} activeOpacity={0.4} > <AnimatedView style={this.cameraRollIconStyle}> <SWMansionIcon name="image-1" size={28} color={`#${this.props.threadInfo.color}`} /> </AnimatedView> </TouchableOpacity> <TouchableOpacity onPress={this.props.openCamera} activeOpacity={0.4} disabled={!this.state.buttonsExpanded} > <AnimatedView style={this.cameraIconStyle}> <SWMansionIcon name="camera" size={28} color={`#${this.props.threadInfo.color}`} /> </AnimatedView> </TouchableOpacity> {this.state.buttonsExpanded ? null : expandoButton} </View> </AnimatedView> <SelectableTextInput allowImagePasteForThreadID={this.props.threadInfo.id} value={this.state.text} onChangeText={this.updateText} selection={this.state.selectionState.selection} onUpdateSyncedSelectionData={this.updateSelectionState} placeholder="Send a message..." placeholderTextColor={this.props.colors.listInputButton} multiline={true} style={this.props.styles.textInput} textInputRef={this.textInputRef} clearableTextInputRef={this.clearableTextInputRef} ref={this.selectableTextInputRef} selectionColor={`#${this.props.threadInfo.color}`} /> <AnimatedView style={this.sendButtonContainerStyle}> <TouchableOpacity onPress={this.onSend} activeOpacity={0.4} style={this.props.styles.sendButton} disabled={trimMessage(this.state.text) === ''} > <Icon name="md-send" size={25} style={this.props.styles.sendIcon} color={threadColor} /> </TouchableOpacity> </AnimatedView> </View> </TouchableWithoutFeedback> ); } textInputRef = (textInput: ?React.ElementRef<typeof TextInput>) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; selectableTextInputRef = ( selectableTextInput: ?React.ElementRef<typeof SelectableTextInput>, ) => { this.selectableTextInput = selectableTextInput; }; updateText = (text: string) => { if (this.state.isExitingDuringEditMode) { return; } this.setState({ text, textEdited: true }); this.props.messageEditingContext?.setEditedMessageChanged( this.isMessageEdited(text), ); if (this.isEditMode()) { return; } this.saveDraft(text); }; updateSelectionState: (data: SyncedSelectionData) => void = data => { this.setState({ selectionState: data }); }; saveDraft = _throttle(text => { this.props.dispatch({ type: updateDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), text, }, }); }, 400); focusAndUpdateTextAndSelection = (text: string, selection: Selection) => { this.selectableTextInput?.prepareForSelectionMutation(text, selection); this.setState({ text, textEdited: true, selectionState: { text, selection }, }); this.saveDraft(text); this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateText = (params: EditInputBarMessageParameters) => { const { message: text, mode } = params; const currentText = this.state.text; if (mode === 'replace') { this.updateText(text); } else if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); } this.focusAndUpdateButtonsVisibility(); }; focusAndUpdateButtonsVisibility = () => { const { textInput } = this; if (!textInput) { return; } this.immediatelyShowSendButton(); this.immediatelyHideButtons(); textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } const editedMessage = this.getEditedMessage(); if (editedMessage && editedMessage.id) { this.editMessage(editedMessage.id, this.state.text); return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = this.props.nextLocalID; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, this.props.parentThreadInfo, ); }; isEditMode = () => { const editState = this.props.messageEditingContext?.editState; const isThisThread = editState?.editedMessage?.threadID === this.props.threadInfo.id; return editState && editState.editedMessage !== null && isThisThread; }; isMessageEdited = newText => { let text = newText ?? this.state.text; text = trimMessage(text); const originalText = this.props.editedMessageInfo?.text; return text !== originalText; }; unblockNavigation = () => { const { navigation } = this.props; if (!navigation) { return; } navigation.setParams({ removeEditMode: null }); }; removeEditMode: RemoveEditMode = action => { const { navigation } = this.props; if (!navigation || this.state.isExitingDuringEditMode) { return 'ignore_action'; } if (!this.isMessageEdited()) { this.unblockNavigation(); return 'reduce_action'; } const unblockAndDispatch = () => { this.unblockNavigation(); navigation.dispatch(action); }; const onContinueEditing = () => { this.props.overlayContext?.resetScrollBlockingModalStatus(); }; exitEditAlert({ onDiscard: unblockAndDispatch, onContinueEditing, }); return 'ignore_action'; }; blockNavigation = () => { const { navigation } = this.props; if (!navigation || !navigation.isFocused()) { return; } navigation.setParams({ removeEditMode: this.removeEditMode, }); }; editMessage = async (messageID: string, text: string) => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } text = trimMessage(text); try { await this.props.editMessage(messageID, text); this.exitEditMode(); } catch (error) { Alert.alert( 'Couldn’t edit the message', 'Please try again later', [{ text: 'OK' }], { cancelable: true, }, ); } }; getEditedMessage = () => { const editState = this.props.messageEditingContext?.editState; return editState?.editedMessage; }; onPressExitEditMode = () => { if (!this.isMessageEdited()) { this.exitEditMode(); return; } exitEditAlert({ onDiscard: this.exitEditMode, }); }; scrollToEditedMessage = () => { const editedMessage = this.getEditedMessage(); if (!editedMessage) { return; } const editedMessageKey = messageKey(editedMessage); this.props.inputState?.scrollToMessage(editedMessageKey); }; exitEditMode = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.unblockNavigation(); this.updateText(this.props.draft); this.focusAndUpdateButtonsVisibility(); this.updateSendButton(this.props.draft); }); }; onNavigationFocus = () => { this.setState({ isExitingDuringEditMode: false }); }; onNavigationBlur = () => { if (!this.isEditMode()) { return; } this.setState( { text: this.props.draft, isExitingDuringEditMode: true }, this.exitEditMode, ); }; onNavigationBeforeRemove = e => { if (!this.isEditMode()) { return; } const { action } = e.data; e.preventDefault(); const saveExit = () => { this.props.messageEditingContext?.setEditedMessage(null, () => { this.setState({ isExitingDuringEditMode: true }, () => { if (!this.props.navigation) { return; } this.props.navigation.dispatch(action); }); }); }; if (!this.isMessageEdited()) { saveExit(); return; } exitEditAlert({ onDiscard: saveExit, }); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded || this.isEditMode()) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 8, paddingRight: 5, }, container: { backgroundColor: 'listBackground', paddingLeft: Platform.OS === 'android' ? 10 : 5, }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 13 : 11, paddingRight: 2, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { borderRadius: 8, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, }, joinButtonContainer: { flexDirection: 'row', height: 48, marginBottom: 8, }, editView: { marginLeft: 20, marginRight: 20, padding: 10, flexDirection: 'row', justifyContent: 'space-between', }, editViewContent: { flex: 1, paddingRight: 6, }, exitEditButton: { marginTop: 6, }, editingLabel: { paddingBottom: 4, }, editingMessagePreview: { color: 'listForegroundLabel', }, joinButtonContent: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center', }, joinButtonTextLight: { color: 'white', fontSize: 20, marginHorizontal: 4, }, joinButtonTextDark: { color: 'black', fontSize: 20, marginHorizontal: 4, }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: 4, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 6, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 8, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginRight: 4, marginTop: 6, marginBottom: 8, maxHeight: 110, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); type ConnectedChatInputBarBaseProps = { ...BaseProps, +onInputBarLayout?: (event: LayoutEvent) => mixed, +openCamera: () => mixed, +navigation?: ChatNavigationProp<'MessageList'>, }; function ConnectedChatInputBarBase(props: ConnectedChatInputBarBaseProps) { const navContext = React.useContext(NavContext); const keyboardState = React.useContext(KeyboardContext); const inputState = React.useContext(InputStateContext); const overlayContext = React.useContext(OverlayContext); const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const draft = useSelector( state => state.draftStore.drafts[draftKeyFromThreadID(props.threadInfo.id)] ?? '', ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(state => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useNextLocalID(); const userInfos = useSelector(state => state.userStore.userInfos); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const userSearchIndex = useSelector(userStoreSearchIndex); const { parentThreadID } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const mentionsCandidates = getMentionsCandidates( props.threadInfo, parentThreadInfo, ); const chatMentionCandidates = useThreadChatMentionCandidates( props.threadInfo, ); const messageEditingContext = React.useContext(MessageEditingContext); const editedMessageInfo = messageEditingContext?.editState.editedMessage; const editedMessagePreview = useMessagePreview( editedMessageInfo, props.threadInfo, getDefaultTextMessageRules(chatMentionCandidates).simpleMarkdownRules, ); const editMessage = useEditMessage(); return ( <ChatInputBar {...props} viewerID={viewerID} draft={draft} joinThreadLoadingStatus={joinThreadLoadingStatus} threadCreationInProgress={threadCreationInProgress} calendarQuery={calendarQuery} nextLocalID={nextLocalID} userInfos={userInfos} colors={colors} styles={styles} isActive={isActive} keyboardState={keyboardState} dispatch={dispatch} dispatchActionPromise={dispatchActionPromise} joinThread={callJoinThread} inputState={inputState} userSearchIndex={userSearchIndex} mentionsCandidates={mentionsCandidates} parentThreadInfo={parentThreadInfo} editedMessagePreview={editedMessagePreview} editedMessageInfo={editedMessageInfo} editMessage={editMessage} navigation={props.navigation} overlayContext={overlayContext} messageEditingContext={messageEditingContext} /> ); } type DummyChatInputBarProps = { ...BaseProps, +onHeightMeasured: (height: number) => mixed, }; const noop = () => {}; function DummyChatInputBar(props: DummyChatInputBarProps): React.Node { const { onHeightMeasured, ...restProps } = props; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; onHeightMeasured(height); }, [onHeightMeasured], ); return ( <View pointerEvents="none"> <ConnectedChatInputBarBase {...restProps} onInputBarLayout={onInputBarLayout} openCamera={noop} /> </View> ); } type ChatInputBarProps = { ...BaseProps, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, }; const ConnectedChatInputBar: React.ComponentType<ChatInputBarProps> = React.memo<ChatInputBarProps>(function ConnectedChatInputBar( props: ChatInputBarProps, ) { const { navigation, route, ...restProps } = props; const keyboardState = React.useContext(KeyboardContext); const { threadInfo } = props; const imagePastedCallback = React.useCallback( imagePastedEvent => { if (threadInfo.id !== imagePastedEvent.threadID) { return; } const pastedImage: PhotoPaste = { step: 'photo_paste', dimensions: { height: imagePastedEvent.height, width: imagePastedEvent.width, }, filename: imagePastedEvent.fileName, uri: 'file://' + imagePastedEvent.filePath, selectTime: 0, sendTime: 0, retries: 0, }; navigation.navigate<'ImagePasteModal'>({ name: ImagePasteModalRouteName, params: { imagePasteStagingInfo: pastedImage, thread: threadInfo, }, }); }, [navigation, threadInfo], ); React.useEffect(() => { const imagePasteListener = NativeAppEventEmitter.addListener( 'imagePasted', imagePastedCallback, ); return () => imagePasteListener.remove(); }, [imagePastedCallback]); const chatContext = React.useContext(ChatContext); invariant(chatContext, 'should be set'); const { setChatInputBarHeight, deleteChatInputBarHeight } = chatContext; const onInputBarLayout = React.useCallback( (event: LayoutEvent) => { const { height } = event.nativeEvent.layout; setChatInputBarHeight(threadInfo.id, height); }, [threadInfo.id, setChatInputBarHeight], ); React.useEffect(() => { return () => { deleteChatInputBarHeight(threadInfo.id); }; }, [deleteChatInputBarHeight, threadInfo.id]); const openCamera = React.useCallback(() => { keyboardState?.dismissKeyboard(); navigation.navigate<'ChatCameraModal'>({ name: ChatCameraModalRouteName, params: { presentedFrom: route.key, thread: threadInfo, }, }); }, [keyboardState, navigation, route.key, threadInfo]); return ( <ConnectedChatInputBarBase {...restProps} onInputBarLayout={onInputBarLayout} openCamera={openCamera} navigation={navigation} /> ); }); export { ConnectedChatInputBar as ChatInputBar, DummyChatInputBar }; diff --git a/native/chat/typeahead-tooltip.react.js b/native/chat/typeahead-tooltip.react.js index 612970955..d50d636cd 100644 --- a/native/chat/typeahead-tooltip.react.js +++ b/native/chat/typeahead-tooltip.react.js @@ -1,152 +1,152 @@ // @flow import * as React from 'react'; import { Platform, Text } from 'react-native'; import { PanGestureHandler, FlatList } from 'react-native-gesture-handler'; import { type TypeaheadMatchedStrings, type Selection, getNewTextAndSelection, } 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'; export type TypeaheadTooltipProps = { +text: string, +matchedStrings: TypeaheadMatchedStrings, +suggestedUsers: $ReadOnlyArray<RelativeMemberInfo>, +focusAndUpdateTextAndSelection: (text: string, selection: Selection) => void, }; function TypeaheadTooltip(props: TypeaheadTooltipProps): React.Node { const { text, matchedStrings, suggestedUsers, focusAndUpdateTextAndSelection, } = props; - const { textBeforeAtSymbol, usernamePrefix } = matchedStrings; + const { textBeforeAtSymbol, query } = matchedStrings; const styles = useStyles(unboundStyles); const renderTypeaheadButton = React.useCallback( ({ item }: { item: RelativeMemberInfo, ... }) => { const onPress = () => { const { newText, newSelectionStart } = getNewTextAndSelection( textBeforeAtSymbol, text, - usernamePrefix, + query, item, ); focusAndUpdateTextAndSelection(newText, { start: newSelectionStart, end: newSelectionStart, }); }; return ( <Button onPress={onPress} style={styles.button} iosActiveOpacity={0.85}> <UserAvatar size="small" userID={item.id} /> <Text style={styles.buttonLabel} numberOfLines={1}> @{item.username} </Text> </Button> ); }, [ styles.button, styles.buttonLabel, textBeforeAtSymbol, text, - usernamePrefix, + query, focusAndUpdateTextAndSelection, ], ); // This is a hack that was introduced due to a buggy behavior of a // absolutely positioned FlatList on Android. // There was a bug that was present when there were too few items in a // FlatList and it wasn't scrollable. It was only present on Android as // iOS has a default "bounce" animation, even if the list is too short. // The bug manifested itself when we tried to scroll the FlatList. // Because it was unscrollable we were really scrolling FlatList // below it (in the ChatList) as FlatList here has "position: absolute" // and is positioned over the other FlatList. // The hack here solves it by using a PanGestureHandler. This way Pan events // on TypeaheadTooltip FlatList are always caught by handler. // When the FlatList is scrollable it scrolls normally, because handler // passes those events down to it. // If it's not scrollable, the PanGestureHandler "swallows" them. // Normally it would trigger onGestureEvent callback, but we don't need to // handle those events. We just want them to be ignored // and that's what's actually happening. const flatList = React.useMemo( () => ( <FlatList style={styles.container} contentContainerStyle={styles.contentContainer} data={suggestedUsers} renderItem={renderTypeaheadButton} keyboardShouldPersistTaps="always" /> ), [ renderTypeaheadButton, styles.container, styles.contentContainer, suggestedUsers, ], ); const listWithConditionalHandler = React.useMemo(() => { if (Platform.OS === 'android') { return <PanGestureHandler>{flatList}</PanGestureHandler>; } return flatList; }, [flatList]); return listWithConditionalHandler; } const unboundStyles = { container: { position: 'absolute', maxHeight: 200, left: 0, right: 0, bottom: '100%', backgroundColor: 'typeaheadTooltipBackground', borderBottomWidth: 1, borderTopWidth: 1, borderColor: 'typeaheadTooltipBorder', borderStyle: 'solid', }, contentContainer: { padding: 8, }, button: { alignItems: 'center', flexDirection: 'row', innerHeight: 24, padding: 8, color: 'typeaheadTooltipText', }, buttonLabel: { color: 'white', fontSize: 16, fontWeight: '400', marginLeft: 8, }, }; export default TypeaheadTooltip; diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js index 2c09d8d2d..4ff59b5f9 100644 --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -1,661 +1,661 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import * as React from 'react'; import { joinThreadActionTypes, joinThread, newThreadActionTypes, } from 'lib/actions/thread-actions.js'; import SWMansionIcon from 'lib/components/SWMansionIcon.react.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { userStoreSearchIndex } from 'lib/selectors/user-selectors.js'; import { getTypeaheadUserSuggestions, getTypeaheadRegexMatches, getMentionsCandidates, } 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, viewerIsMember, threadFrozenDueToViewerBlock, threadActualMembers, checkIfDefaultMembersAreVoiced, } from 'lib/shared/thread-utils.js'; import type { CalendarQuery } from 'lib/types/entry-types.js'; import type { LoadingStatus } from 'lib/types/loading-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadInfo, type ClientThreadJoinRequest, type ThreadJoinPayload, type RelativeMemberInfo, } from 'lib/types/thread-types.js'; import { type UserInfos } from 'lib/types/user-types.js'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils.js'; import css from './chat-input-bar.css'; import TypeaheadTooltip from './typeahead-tooltip.react.js'; import Button from '../components/button.react.js'; import { type InputState, type PendingMultimediaUpload, } from '../input/input-state.js'; import LoadingIndicator from '../loading-indicator.react.js'; import { allowedMimeTypeString } from '../media/file-utils.js'; import Multimedia from '../media/multimedia.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { nonThreadCalendarQuery } from '../selectors/nav-selectors.js'; import { webTypeaheadRegex } from '../utils/typeahead-utils.js'; type BaseProps = { +threadInfo: ThreadInfo, +inputState: InputState, }; type Props = { ...BaseProps, +viewerID: ?string, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +isThreadActive: boolean, +userInfos: UserInfos, +dispatchActionPromise: DispatchActionPromise, +joinThread: (request: ClientThreadJoinRequest) => Promise<ThreadJoinPayload>, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, +suggestedUsers: $ReadOnlyArray<RelativeMemberInfo>, +parentThreadInfo: ?ThreadInfo, }; class ChatInputBar extends React.PureComponent<Props> { textarea: ?HTMLTextAreaElement; multimediaInput: ?HTMLInputElement; componentDidMount() { this.updateHeight(); if (this.props.isThreadActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isThreadActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props) { if (this.props.isThreadActive && !prevProps.isThreadActive) { this.addReplyListener(); } else if (!this.props.isThreadActive && prevProps.isThreadActive) { this.removeReplyListener(); } const { inputState } = this.props; const prevInputState = prevProps.inputState; if (inputState.draft !== prevInputState.draft) { this.updateHeight(); } if ( inputState.draft !== prevInputState.draft || inputState.textCursorPosition !== prevInputState.textCursorPosition ) { inputState.setTypeaheadState({ canBeVisible: true, }); } const curUploadIDs = ChatInputBar.unassignedUploadIDs( inputState.pendingUploads, ); const prevUploadIDs = ChatInputBar.unassignedUploadIDs( prevInputState.pendingUploads, ); if ( this.multimediaInput && _difference(prevUploadIDs)(curUploadIDs).length > 0 ) { // Whenever a pending upload is removed, we reset the file // HTMLInputElement's value field, so that if the same upload occurs again // the onChange call doesn't get filtered this.multimediaInput.value = ''; } else if ( this.textarea && _difference(curUploadIDs)(prevUploadIDs).length > 0 ) { // Whenever a pending upload is added, we focus the textarea this.textarea.focus(); return; } 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', ); } } static unassignedUploadIDs( pendingUploads: $ReadOnlyArray<PendingMultimediaUpload>, ) { return pendingUploads .filter( (pendingUpload: PendingMultimediaUpload) => !pendingUpload.messageID, ) .map((pendingUpload: PendingMultimediaUpload) => pendingUpload.localID); } updateHeight() { const textarea = this.textarea; if (textarea) { textarea.style.height = 'auto'; const newHeight = Math.min(textarea.scrollHeight, 150); textarea.style.height = `${newHeight}px`; } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin && !this.props.threadCreationInProgress) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( <LoadingIndicator status={this.props.joinThreadLoadingStatus} size="medium" color="white" /> ); } else { buttonContent = ( <> <SWMansionIcon icon="plus" size={24} /> <p className={css.joinButtonText}>Join Chat</p> </> ); } joinButton = ( <div className={css.joinButtonContainer}> <Button variant="filled" buttonColor={{ backgroundColor: `#${this.props.threadInfo.color}` }} onClick={this.onClickJoin} > {buttonContent} </Button> </div> ); } const { pendingUploads, cancelPendingUpload } = this.props.inputState; const multimediaPreviews = pendingUploads.map(pendingUpload => { const { uri, mediaType, thumbHash, dimensions } = pendingUpload; let mediaSource = { thumbHash, dimensions }; if (mediaType !== 'encrypted_photo' && mediaType !== 'encrypted_video') { mediaSource = { ...mediaSource, type: mediaType, uri, thumbnailURI: null, }; } else { const { encryptionKey } = pendingUpload; invariant( encryptionKey, 'encryptionKey should be set for encrypted media', ); mediaSource = { ...mediaSource, type: mediaType, blobURI: uri, encryptionKey, thumbnailBlobURI: null, thumbnailEncryptionKey: null, }; } return ( <Multimedia mediaSource={mediaSource} pendingUpload={pendingUpload} remove={cancelPendingUpload} multimediaCSSClass={css.multimedia} multimediaImageCSSClass={css.multimediaImage} key={pendingUpload.localID} /> ); }); const previews = multimediaPreviews.length > 0 ? ( <div className={css.previews}>{multimediaPreviews}</div> ) : null; let content; // If the thread is created by somebody else while the viewer is attempting // to create it, the threadInfo might be modified in-place and won't // list the viewer as a member, which will end up hiding the input. In // this case, we will assume that our creation action will get translated, // into a join and as long as members are voiced, we can show the input. const defaultMembersAreVoiced = checkIfDefaultMembersAreVoiced( this.props.threadInfo, ); let sendButton; if (this.props.inputState.draft.length) { sendButton = ( <a onClick={this.onSend} className={css.sendButton}> <SWMansionIcon icon="send-2" size={22} color={`#${this.props.threadInfo.color}`} /> </a> ); } if ( threadHasPermission(this.props.threadInfo, threadPermissions.VOICED) || (this.props.threadCreationInProgress && defaultMembersAreVoiced) ) { content = ( <div className={css.inputBarWrapper}> <a className={css.multimediaUpload} onClick={this.onMultimediaClick}> <input type="file" onChange={this.onMultimediaFileChange} ref={this.multimediaInputRef} accept={allowedMimeTypeString} multiple /> <SWMansionIcon icon="image-1" size={22} color={`#${this.props.threadInfo.color}`} disableFill /> </a> <div className={css.inputBarTextInput}> <textarea rows="1" placeholder="Type your message" value={this.props.inputState.draft} onChange={this.onChangeMessageText} onKeyDown={this.onKeyDown} onClick={this.onClickTextarea} onSelect={this.onSelectTextarea} ref={this.textareaRef} autoFocus /> </div> {sendButton} </div> ); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( <span className={css.explanation}> You can’t send messages to a user that you’ve blocked. </span> ); } else if (isMember) { content = ( <span className={css.explanation}> You don’t have permission to send messages. </span> ); } else if (defaultMembersAreVoiced && canJoin) { content = null; } else { content = ( <span className={css.explanation}> You don’t have permission to send messages. </span> ); } let typeaheadTooltip; if ( this.props.inputState.typeaheadState.canBeVisible && this.props.suggestedUsers.length > 0 && this.props.typeaheadMatchedStrings && this.textarea ) { typeaheadTooltip = ( <TypeaheadTooltip inputState={this.props.inputState} textarea={this.textarea} matchedStrings={this.props.typeaheadMatchedStrings} suggestedUsers={this.props.suggestedUsers} /> ); } return ( <div className={css.inputBar}> {joinButton} {previews} {content} {typeaheadTooltip} </div> ); } textareaRef = (textarea: ?HTMLTextAreaElement) => { this.textarea = textarea; if (textarea) { textarea.focus(); } }; onChangeMessageText = (event: SyntheticEvent<HTMLTextAreaElement>) => { this.props.inputState.setDraft(event.currentTarget.value); this.props.inputState.setTextCursorPosition( event.currentTarget.selectionStart, ); }; onClickTextarea = (event: SyntheticEvent<HTMLTextAreaElement>) => { this.props.inputState.setTextCursorPosition( event.currentTarget.selectionStart, ); }; onSelectTextarea = (event: SyntheticEvent<HTMLTextAreaElement>) => { this.props.inputState.setTextCursorPosition( event.currentTarget.selectionStart, ); }; focusAndUpdateText = (text: string) => { // We need to call focus() first on Safari, otherwise the cursor // ends up at the start instead of the end for some reason const { textarea } = this; invariant(textarea, 'textarea should be set'); textarea.focus(); // We reset the textarea to an empty string at the start so that the cursor // always ends up at the end, even if the text doesn't actually change textarea.value = ''; const currentText = this.props.inputState.draft; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.props.inputState.setDraft(prependedText); textarea.value = prependedText; } else { textarea.value = currentText; } // The above strategies make sure the cursor is at the end, // but we also need to make sure that we're scrolled to the bottom textarea.scrollTop = textarea.scrollHeight; }; onKeyDown = (event: SyntheticKeyboardEvent<HTMLTextAreaElement>) => { const { accept, close, moveChoiceUp, moveChoiceDown } = this.props.inputState.typeaheadState; const actions = { Enter: accept, Tab: accept, ArrowDown: moveChoiceDown, ArrowUp: moveChoiceUp, Escape: close, }; if ( this.props.inputState.typeaheadState.canBeVisible && actions[event.key] ) { event.preventDefault(); actions[event.key](); } else if (event.key === 'Enter' && !event.shiftKey) { event.preventDefault(); this.send(); } }; onSend = (event: SyntheticEvent<HTMLAnchorElement>) => { event.preventDefault(); this.send(); }; send() { let { nextLocalID } = this.props; const text = trimMessage(this.props.inputState.draft); if (text) { this.dispatchTextMessageAction(text, nextLocalID); nextLocalID++; } if (this.props.inputState.pendingUploads.length > 0) { this.props.inputState.createMultimediaMessage( nextLocalID, this.props.threadInfo, ); } } dispatchTextMessageAction(text: string, nextLocalID: number) { this.props.inputState.setDraft(''); const localID = `${localIDPrefix}${nextLocalID}`; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); this.props.inputState.sendTextMessage( { type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }, this.props.threadInfo, this.props.parentThreadInfo, ); } multimediaInputRef = (multimediaInput: ?HTMLInputElement) => { this.multimediaInput = multimediaInput; }; onMultimediaClick = () => { if (this.multimediaInput) { this.multimediaInput.click(); } }; onMultimediaFileChange = async ( event: SyntheticInputEvent<HTMLInputElement>, ) => { const result = await this.props.inputState.appendFiles([ ...event.target.files, ]); if (!result && this.multimediaInput) { this.multimediaInput.value = ''; } }; onClickJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } } const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); const createThreadLoadingStatusSelector = createLoadingStatusSelector(newThreadActionTypes); const ConnectedChatInputBar: React.ComponentType<BaseProps> = React.memo<BaseProps>(function ConnectedChatInputBar(props) { const viewerID = useSelector( state => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useSelector(state => state.nextLocalID); const isThreadActive = useSelector( state => props.threadInfo.id === state.navInfo.activeChatThreadID, ); const userInfos = useSelector(state => state.userStore.userInfos); const joinThreadLoadingStatus = useSelector( joinThreadLoadingStatusSelector, ); const createThreadLoadingStatus = useSelector( createThreadLoadingStatusSelector, ); const threadCreationInProgress = createThreadLoadingStatus === 'loading'; const calendarQuery = useSelector(nonThreadCalendarQuery); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); const userSearchIndex = useSelector(userStoreSearchIndex); const { parentThreadID } = props.threadInfo; const parentThreadInfo = useSelector(state => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const mentionsCandidates = getMentionsCandidates( props.threadInfo, parentThreadInfo, ); const typeaheadRegexMatches = React.useMemo( () => getTypeaheadRegexMatches( props.inputState.draft, { start: props.inputState.textCursorPosition, end: props.inputState.textCursorPosition, }, webTypeaheadRegex, ), [props.inputState.textCursorPosition, props.inputState.draft], ); const typeaheadMatchedStrings: ?TypeaheadMatchedStrings = React.useMemo( () => typeaheadRegexMatches !== null ? { textBeforeAtSymbol: typeaheadRegexMatches.groups?.textPrefix ?? '', - usernamePrefix: typeaheadRegexMatches.groups?.username ?? '', + query: typeaheadRegexMatches.groups?.username ?? '', } : null, [typeaheadRegexMatches], ); React.useEffect(() => { if (props.inputState.typeaheadState.keepUpdatingThreadMembers) { const setter = props.inputState.setTypeaheadState; setter({ frozenMentionsCandidates: mentionsCandidates, }); } }, [ mentionsCandidates, props.inputState.setTypeaheadState, props.inputState.typeaheadState.keepUpdatingThreadMembers, ]); const suggestedUsers: $ReadOnlyArray<RelativeMemberInfo> = React.useMemo(() => { if (!typeaheadMatchedStrings) { return []; } return getTypeaheadUserSuggestions( userSearchIndex, props.inputState.typeaheadState.frozenMentionsCandidates, viewerID, - typeaheadMatchedStrings.usernamePrefix, + typeaheadMatchedStrings.query, ); }, [ userSearchIndex, props.inputState.typeaheadState.frozenMentionsCandidates, viewerID, typeaheadMatchedStrings, ]); return ( <ChatInputBar {...props} viewerID={viewerID} joinThreadLoadingStatus={joinThreadLoadingStatus} threadCreationInProgress={threadCreationInProgress} calendarQuery={calendarQuery} nextLocalID={nextLocalID} isThreadActive={isThreadActive} userInfos={userInfos} dispatchActionPromise={dispatchActionPromise} joinThread={callJoinThread} typeaheadMatchedStrings={typeaheadMatchedStrings} suggestedUsers={suggestedUsers} parentThreadInfo={parentThreadInfo} /> ); }); export default ConnectedChatInputBar; diff --git a/web/chat/typeahead-tooltip.react.js b/web/chat/typeahead-tooltip.react.js index 2be4b6712..b8a958ca5 100644 --- a/web/chat/typeahead-tooltip.react.js +++ b/web/chat/typeahead-tooltip.react.js @@ -1,187 +1,187 @@ // @flow 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 { leastPositiveResidue } from 'lib/utils/math-utils.js'; import css from './typeahead-tooltip.css'; import type { InputState } from '../input/input-state.js'; import { getTypeaheadOverlayScroll, getTypeaheadTooltipActions, getTypeaheadTooltipButtons, getTypeaheadTooltipPosition, } from '../utils/typeahead-utils.js'; export type TypeaheadTooltipProps = { +inputState: InputState, +textarea: HTMLTextAreaElement, +matchedStrings: TypeaheadMatchedStrings, +suggestedUsers: $ReadOnlyArray<RelativeMemberInfo>, }; function TypeaheadTooltip(props: TypeaheadTooltipProps): React.Node { const { inputState, textarea, matchedStrings, suggestedUsers } = props; - const { textBeforeAtSymbol, usernamePrefix } = matchedStrings; + const { textBeforeAtSymbol, query } = matchedStrings; const [isVisibleForAnimation, setIsVisibleForAnimation] = React.useState(false); const [chosenPositionInOverlay, setChosenPositionInOverlay] = React.useState<number>(0); const overlayRef = React.useRef<?HTMLDivElement>(); React.useEffect(() => { setChosenPositionInOverlay(0); }, [suggestedUsers]); React.useEffect(() => { setIsVisibleForAnimation(true); const setter = inputState.setTypeaheadState; setter({ keepUpdatingThreadMembers: false, }); return () => { setter({ keepUpdatingThreadMembers: true, }); setIsVisibleForAnimation(false); }; }, [inputState.setTypeaheadState]); const actions = React.useMemo( () => getTypeaheadTooltipActions({ inputStateDraft: inputState.draft, inputStateSetDraft: inputState.setDraft, inputStateSetTextCursorPosition: inputState.setTextCursorPosition, suggestedUsers, textBeforeAtSymbol, - usernamePrefix, + query, }), [ inputState.draft, inputState.setDraft, inputState.setTextCursorPosition, suggestedUsers, textBeforeAtSymbol, - usernamePrefix, + query, ], ); const tooltipPosition = React.useMemo( () => getTypeaheadTooltipPosition(textarea, actions.length, textBeforeAtSymbol), [textarea, actions.length, textBeforeAtSymbol], ); const tooltipPositionStyle = React.useMemo( () => ({ top: tooltipPosition.top, left: tooltipPosition.left, }), [tooltipPosition], ); const tooltipButtons = React.useMemo( () => getTypeaheadTooltipButtons( setChosenPositionInOverlay, chosenPositionInOverlay, actions, ), [setChosenPositionInOverlay, actions, chosenPositionInOverlay], ); const close = React.useCallback(() => { const setter = inputState.setTypeaheadState; setter({ canBeVisible: false, moveChoiceUp: null, moveChoiceDown: null, close: null, accept: null, }); }, [inputState.setTypeaheadState]); const accept = React.useCallback(() => { actions[chosenPositionInOverlay].execute(); close(); }, [actions, chosenPositionInOverlay, close]); const moveChoiceUp = React.useCallback(() => { if (actions.length === 0) { return; } setChosenPositionInOverlay(previousPosition => leastPositiveResidue(previousPosition - 1, actions.length), ); }, [setChosenPositionInOverlay, actions.length]); const moveChoiceDown = React.useCallback(() => { if (actions.length === 0) { return; } setChosenPositionInOverlay(previousPosition => leastPositiveResidue(previousPosition + 1, actions.length), ); }, [setChosenPositionInOverlay, actions.length]); React.useEffect(() => { const setter = inputState.setTypeaheadState; setter({ canBeVisible: true, moveChoiceUp, moveChoiceDown, close, accept, }); return close; }, [ close, accept, moveChoiceUp, moveChoiceDown, actions, inputState.setTypeaheadState, ]); React.useEffect(() => { const current = overlayRef.current; if (current) { const newScrollTop = getTypeaheadOverlayScroll( current.scrollTop, chosenPositionInOverlay, ); current.scrollTo(0, newScrollTop); } }, [chosenPositionInOverlay]); if (suggestedUsers.length === 0) { return null; } const overlayClasses = classNames(css.suggestionsContainer, { [css.notVisible]: !isVisibleForAnimation, [css.visible]: isVisibleForAnimation, }); return ( <div ref={overlayRef} className={overlayClasses} style={tooltipPositionStyle} > {tooltipButtons} </div> ); } export default TypeaheadTooltip; diff --git a/web/utils/typeahead-utils.js b/web/utils/typeahead-utils.js index cc44faf4a..2e64e6414 100644 --- a/web/utils/typeahead-utils.js +++ b/web/utils/typeahead-utils.js @@ -1,215 +1,215 @@ // @flow import classNames from 'classnames'; import * as React from 'react'; import { oldValidUsernameRegexString } from 'lib/shared/account-utils.js'; import { getNewTextAndSelection } 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'; import css from '../chat/typeahead-tooltip.css'; import Button from '../components/button.react.js'; const webTypeaheadRegex: RegExp = new RegExp( `(?<textPrefix>(?:^(?:.|\n)*\\s+)|^)@(?<username>${oldValidUsernameRegexString})?$`, ); export type TypeaheadTooltipAction = { +key: string, +execute: () => mixed, +actionButtonContent: { +userID: string, +username: string }, }; export type TooltipPosition = { +top: number, +left: number, }; 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, }; } export type GetTypeaheadTooltipActionsParams = { +inputStateDraft: string, +inputStateSetDraft: (draft: string) => mixed, +inputStateSetTextCursorPosition: (newPosition: number) => mixed, +suggestedUsers: $ReadOnlyArray<RelativeMemberInfo>, +textBeforeAtSymbol: string, - +usernamePrefix: string, + +query: string, }; function getTypeaheadTooltipActions( params: GetTypeaheadTooltipActionsParams, ): $ReadOnlyArray<TypeaheadTooltipAction> { const { inputStateDraft, inputStateSetDraft, inputStateSetTextCursorPosition, suggestedUsers, textBeforeAtSymbol, - usernamePrefix, + query, } = params; return suggestedUsers .filter( suggestedUser => stringForUserExplicit(suggestedUser) !== 'anonymous', ) .map(suggestedUser => ({ key: suggestedUser.id, execute: () => { const { newText, newSelectionStart } = getNewTextAndSelection( textBeforeAtSymbol, inputStateDraft, - usernamePrefix, + query, suggestedUser, ); inputStateSetDraft(newText); inputStateSetTextCursorPosition(newSelectionStart); }, actionButtonContent: { userID: suggestedUser.id, username: stringForUserExplicit(suggestedUser), }, })); } function getTypeaheadTooltipButtons( setChosenPositionInOverlay: SetState<number>, chosenPositionInOverlay: number, actions: $ReadOnlyArray<TypeaheadTooltipAction>, ): $ReadOnlyArray<React.Node> { return actions.map((action, idx) => { const { key, execute, actionButtonContent } = action; const buttonClasses = classNames(css.suggestion, { [css.suggestionHover]: idx === chosenPositionInOverlay, }); const onMouseMove: ( event: SyntheticEvent<HTMLButtonElement>, ) => mixed = () => { setChosenPositionInOverlay(idx); }; return ( <Button key={key} onClick={execute} onMouseMove={onMouseMove} className={buttonClasses} > <UserAvatar size="small" userID={actionButtonContent.userID} /> <span className={css.username}>@{actionButtonContent.username}</span> </Button> ); }); } 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, textBeforeAtSymbol: string, ): TooltipPosition { const { caretTopOffset, caretLeftOffset } = getCaretOffsets( textarea, textBeforeAtSymbol, ); 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 { webTypeaheadRegex, getCaretOffsets, getTypeaheadTooltipActions, getTypeaheadTooltipButtons, getTypeaheadOverlayScroll, getTypeaheadTooltipPosition, };