diff --git a/web/chat/chat-input-bar.react.js b/web/chat/chat-input-bar.react.js index db844e0a8..473e68500 100644 --- a/web/chat/chat-input-bar.react.js +++ b/web/chat/chat-input-bar.react.js @@ -1,708 +1,718 @@ // @flow import invariant from 'invariant'; import _difference from 'lodash/fp/difference.js'; import * as React from 'react'; import { joinThreadActionTypes, newThreadActionTypes, useJoinThread, } from 'lib/actions/thread-actions.js'; import type { UseJoinThreadInput } from 'lib/actions/thread-actions.js'; import SWMansionIcon from 'lib/components/swmansion-icon.react.js'; import { useChatMentionContext, useThreadChatMentionCandidates, } from 'lib/hooks/chat-mention-hooks.js'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js'; import { threadInfoSelector } from 'lib/selectors/thread-selectors.js'; import { getTypeaheadRegexMatches, type MentionTypeaheadSuggestionItem, type TypeaheadMatchedStrings, useMentionTypeaheadChatSuggestions, useMentionTypeaheadUserSuggestions, useUserMentionsCandidates, } from 'lib/shared/mention-utils.js'; import { trimMessage, getNextLocalID } from 'lib/shared/message-utils.js'; import { checkIfDefaultMembersAreVoiced, threadActualMembers, useThreadFrozenDueToViewerBlock, useThreadHasPermission, viewerIsMember, } 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 type { ThreadInfo, RawThreadInfo, } from 'lib/types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import { type ThreadJoinPayload } from 'lib/types/thread-types.js'; import { type DispatchActionPromise, useDispatchActionPromise, } from 'lib/utils/redux-promise-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 { getMentionTypeaheadTooltipActions, getMentionTypeaheadTooltipButtons, webMentionTypeaheadRegex, } from '../utils/typeahead-utils.js'; type BaseProps = { +threadInfo: ThreadInfo, +inputState: InputState, }; type Props = { ...BaseProps, +viewerID: ?string, +rawThreadInfo: RawThreadInfo, +joinThreadLoadingStatus: LoadingStatus, +threadCreationInProgress: boolean, +calendarQuery: () => CalendarQuery, +isThreadActive: boolean, +dispatchActionPromise: DispatchActionPromise, +joinThread: (input: UseJoinThreadInput) => Promise, +typeaheadMatchedStrings: ?TypeaheadMatchedStrings, +suggestions: $ReadOnlyArray, +parentThreadInfo: ?ThreadInfo, +currentUserIsVoiced: boolean, +currentUserCanJoinThread: boolean, +threadFrozen: boolean, + +cancelPendingUpload: (uploadID: string) => void, }; class ChatInputBar extends React.PureComponent { 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, ); const { multimediaInput, textarea } = this; if ( 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 multimediaInput.value = ''; } else if ( textarea && _difference(curUploadIDs)(prevUploadIDs).length > 0 ) { // Whenever a pending upload is added, we focus the textarea 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, ): Array { 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(): React.Node { const isMember = viewerIsMember(this.props.threadInfo); let joinButton = null; if ( !isMember && this.props.currentUserCanJoinThread && !this.props.threadCreationInProgress ) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( <>

Join Chat

); } joinButton = (
); } - const { pendingUploads, cancelPendingUpload } = this.props.inputState; + const { pendingUploads } = 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 ( ); }); const previews = multimediaPreviews.length > 0 ? (
{multimediaPreviews}
) : 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 = ( ); } if ( this.props.currentUserIsVoiced || (this.props.threadCreationInProgress && defaultMembersAreVoiced) ) { content = (