diff --git a/.eslintrc.json b/.eslintrc.json index 2fdc1cdd3..9f9fcc020 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -44,7 +44,8 @@ "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-use-before-define": 0, "@typescript-eslint/no-var-requires": 0, - "@typescript-eslint/explicit-function-return-type": 0 + "@typescript-eslint/explicit-function-return-type": 0, + "@typescript-eslint/explicit-module-boundary-types": "off" }, "overrides": [ { diff --git a/app/components/__snapshots__/profile_picture_button.test.js.snap b/app/components/__snapshots__/profile_picture_button.test.js.snap index fab53b130..713489f12 100644 --- a/app/components/__snapshots__/profile_picture_button.test.js.snap +++ b/app/components/__snapshots__/profile_picture_button.test.js.snap @@ -2,7 +2,6 @@ exports[`profile_picture_button should match snapshot 1`] = ` { const formatMessage = jest.fn(); const baseProps = { theme: Preferences.THEMES.default, - blurTextBox: jest.fn(), maxFileSize: 10, uploadFiles: jest.fn(), }; diff --git a/app/components/pasteable_text_input/index.js b/app/components/pasteable_text_input/index.js index a681860a4..bee514471 100644 --- a/app/components/pasteable_text_input/index.js +++ b/app/components/pasteable_text_input/index.js @@ -5,13 +5,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import {NativeEventEmitter, NativeModules, Platform, TextInput} from 'react-native'; +import {PASTE_FILES} from '@constants/post_draft'; +import EventEmitter from '@mm-redux/utils/event_emitter'; + const {OnPasteEventManager} = NativeModules; const OnPasteEventEmitter = new NativeEventEmitter(OnPasteEventManager); export class PasteableTextInput extends React.PureComponent { static propTypes = { ...TextInput.PropTypes, - onPaste: PropTypes.func, forwardRef: PropTypes.any, } @@ -26,7 +28,6 @@ export class PasteableTextInput extends React.PureComponent { } onPaste = (event) => { - const {onPaste} = this.props; let data = null; let error = null; @@ -38,7 +39,7 @@ export class PasteableTextInput extends React.PureComponent { data = event; } - return onPaste?.(error, data); + EventEmitter.emit(PASTE_FILES, error, data); } render() { diff --git a/app/components/pasteable_text_input/index.test.js b/app/components/pasteable_text_input/index.test.js index 7c34d931b..bf6631207 100644 --- a/app/components/pasteable_text_input/index.test.js +++ b/app/components/pasteable_text_input/index.test.js @@ -4,6 +4,9 @@ import React from 'react'; import {NativeEventEmitter} from 'react-native'; import {shallow} from 'enzyme'; +import {PASTE_FILES} from '@constants/post_draft'; +import EventEmitter from '@mm-redux/utils/event_emitter'; + import {PasteableTextInput} from './index'; const nativeEventEmitter = new NativeEventEmitter(); @@ -19,22 +22,21 @@ describe('PasteableTextInput', () => { }); test('should call onPaste props if native onPaste trigger', () => { - const onPaste = jest.fn(); const event = {someData: 'data'}; const text = 'My Text'; + const onPaste = jest.spyOn(EventEmitter, 'emit'); shallow( - {text}, + {text}, ); nativeEventEmitter.emit('onPaste', event); - expect(onPaste).toHaveBeenCalledWith(null, event); + expect(onPaste).toHaveBeenCalledWith(PASTE_FILES, null, event); }); test('should remove onPaste listener when unmount', () => { const mockRemove = jest.fn(); - const onPaste = jest.fn(); const text = 'My Text'; const component = shallow( - {text}, + {text}, ); component.instance().subscription.remove = mockRemove; diff --git a/app/components/post_draft/__snapshots__/post_draft.test.js.snap b/app/components/post_draft/__snapshots__/post_draft.test.js.snap new file mode 100644 index 000000000..91eca468f --- /dev/null +++ b/app/components/post_draft/__snapshots__/post_draft.test.js.snap @@ -0,0 +1,661 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PostDraft Should render the Archived for channelIsArchived 1`] = ` + + + + You are viewing an + + + archived channel + + + . New messages cannot be posted. + + + + + Close Channel + + + +`; + +exports[`PostDraft Should render the Archived for deactivatedChannel 1`] = ` + + + + You are viewing an + + + archived channel + + + . New messages cannot be posted. + + + + + Close Channel + + + +`; + +exports[`PostDraft Should render the DraftInput 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`PostDraft Should render the ReadOnly for canPost 1`] = ` + + + + This channel is read-only. + + + +`; + +exports[`PostDraft Should render the ReadOnly for channelIsReadOnly 1`] = ` + + + + This channel is read-only. + + + +`; diff --git a/app/components/post_draft/draft_input/draft_input.js b/app/components/post_draft/draft_input/draft_input.js new file mode 100644 index 000000000..06235d8a8 --- /dev/null +++ b/app/components/post_draft/draft_input/draft_input.js @@ -0,0 +1,514 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {Platform, ScrollView, View} from 'react-native'; +import {intlShape} from 'react-intl'; +import HWKeyboardEvent from 'react-native-hw-keyboard-event'; + +import Autocomplete from '@components/autocomplete'; +import PostInput from '@components/post_draft/post_input'; +import QuickActions from '@components/post_draft/quick_actions'; +import SendAction from '@components/post_draft/send_action'; +import Typing from '@components/post_draft/typing'; +import Uploads from '@components/post_draft/uploads'; +import {paddingHorizontal as padding} from '@components/safe_area_view/iphone_x_spacing'; +import {CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE, IS_REACTION_REGEX} from '@constants/post_draft'; +import {NOTIFY_ALL_MEMBERS} from '@constants/view'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import EphemeralStore from '@store/ephemeral_store'; +import * as DraftUtils from '@utils/draft'; +import {confirmOutOfOfficeDisabled} from '@utils/status'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +const AUTOCOMPLETE_MARGIN = 20; +const AUTOCOMPLETE_MAX_HEIGHT = 200; +const HW_SHIFT_ENTER_TEXT = Platform.OS === 'ios' ? '\n' : ''; +const HW_EVENT_IN_SCREEN = ['Channel', 'Thread']; + +export default class DraftInput extends PureComponent { + static propTypes = { + registerTypingAnimation: PropTypes.func.isRequired, + addReactionToLatestPost: PropTypes.func.isRequired, + getChannelMemberCountsByGroup: PropTypes.func.isRequired, + channelDisplayName: PropTypes.string, + channelId: PropTypes.string.isRequired, + createPost: PropTypes.func.isRequired, + currentUserId: PropTypes.string.isRequired, + cursorPositionEvent: PropTypes.string, + enableConfirmNotificationsToChannel: PropTypes.bool, + executeCommand: PropTypes.func.isRequired, + files: PropTypes.array, + getChannelTimezones: PropTypes.func.isRequired, + handleClearFiles: PropTypes.func.isRequired, + handleClearFailedFiles: PropTypes.func.isRequired, + isLandscape: PropTypes.bool.isRequired, + isTimezoneEnabled: PropTypes.bool, + maxMessageLength: PropTypes.number.isRequired, + membersCount: PropTypes.number, + rootId: PropTypes.string, + screenId: PropTypes.string.isRequired, + setStatus: PropTypes.func.isRequired, + theme: PropTypes.object.isRequired, + useChannelMentions: PropTypes.bool.isRequired, + userIsOutOfOffice: PropTypes.bool.isRequired, + value: PropTypes.string.isRequired, + valueEvent: PropTypes.string, + useGroupMentions: PropTypes.bool.isRequired, + channelMemberCountsByGroup: PropTypes.object, + groupsWithAllowReference: PropTypes.object, + }; + + static defaultProps = { + cursorPositionEvent: CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, + files: [], + rootId: '', + valueEvent: CHANNEL_POST_TEXTBOX_VALUE_CHANGE, + }; + + static contextTypes = { + intl: intlShape, + }; + + constructor(props) { + super(props); + + this.input = React.createRef(); + this.quickActions = React.createRef(); + + this.state = { + canSubmit: false, + channelTimezoneCount: 0, + sendingMessage: false, + top: 0, + }; + } + + componentDidMount() { + const {getChannelMemberCountsByGroup, channelId, isTimezoneEnabled, useGroupMentions, value} = this.props; + + HWKeyboardEvent.onHWKeyPressed(this.handleHardwareEnterPress); + + if (value) { + this.setInputValue(value); + } + + if (useGroupMentions) { + getChannelMemberCountsByGroup(channelId, isTimezoneEnabled); + } + } + + componentDidUpdate(prevProps) { + const {channelId, rootId, value, files, useGroupMentions, getChannelMemberCountsByGroup, isTimezoneEnabled} = this.props; + const diffChannel = channelId !== prevProps?.channelId; + const diffTimezoneEnabled = isTimezoneEnabled !== prevProps?.isTimezoneEnabled; + + if (this.input.current) { + const diffThread = rootId !== prevProps.rootId; + if (diffChannel || diffThread) { + const trimmed = value.trim(); + this.setInputValue(trimmed); + this.updateQuickActionValue(trimmed); + } + } + + if (diffTimezoneEnabled || diffChannel) { + this.numberOfTimezones(); + if (useGroupMentions) { + getChannelMemberCountsByGroup(channelId, isTimezoneEnabled); + } + } + + if (prevProps.files !== files) { + this.updateCanSubmit(); + } + } + + componentWillUnmount() { + HWKeyboardEvent.removeOnHWKeyPressed(); + } + + canSend = () => { + const {files, maxMessageLength} = this.props; + const value = this.input.current?.getValue() || ''; + const messageLength = value.trim().length; + + if (messageLength > maxMessageLength) { + return false; + } + + if (files.length) { + const loadingComplete = !this.isFileLoading(); + return loadingComplete; + } + + return messageLength > 0; + }; + + doSubmitMessage = () => { + const {createPost, currentUserId, channelId, files, handleClearFiles, rootId} = this.props; + const value = this.input.current?.getValue() || ''; + const postFiles = files.filter((f) => !f.failed); + const post = { + user_id: currentUserId, + channel_id: channelId, + root_id: rootId, + parent_id: rootId, + message: value, + }; + + createPost(post, postFiles); + + if (postFiles.length) { + handleClearFiles(channelId, rootId); + } + + if (this.input.current) { + this.setInputValue(''); + this.input.current.changeDraft(''); + } + + this.setState({sendingMessage: false}); + + if (Platform.OS === 'android') { + // Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages + // are typed successively without blurring the input + const nextState = { + keyboardType: 'email-address', + }; + + const callback = () => this.setState({keyboardType: 'default'}); + + this.setState(nextState, callback); + } + + EventEmitter.emit('scroll-to-bottom'); + }; + + handleHardwareEnterPress = (keyEvent) => { + if (HW_EVENT_IN_SCREEN.includes(EphemeralStore.getNavigationTopComponentId())) { + switch (keyEvent.pressedKey) { + case 'enter': + this.handleSendMessage(); + break; + case 'shift-enter': + this.onInsertTextToDraft(HW_SHIFT_ENTER_TEXT); + break; + } + } + } + + handleInputQuickAction = (inputValue) => { + if (this.input.current) { + this.setInputValue(inputValue, true); + this.input.current.focus(); + } + }; + + onInsertTextToDraft = (text) => { + if (this.input.current) { + this.input.current.handleInsertTextToDraft(text); + } + }; + + handleLayout = (e) => { + this.setState({ + top: e.nativeEvent.layout.y, + }); + }; + + handleSendMessage = () => { + if (!this.input.current) { + return; + } + + this.input.current.resetTextInput(); + + requestAnimationFrame(() => { + const value = this.input.current.getValue(); + if (!this.isSendButtonEnabled()) { + this.input.current.setValue(value); + return; + } + + this.setState({sendingMessage: true}); + + const {channelId, files, handleClearFailedFiles, rootId} = this.props; + + const isReactionMatch = value.match(IS_REACTION_REGEX); + if (isReactionMatch) { + const emoji = isReactionMatch[2]; + this.sendReaction(emoji); + return; + } + + const hasFailedAttachments = files.some((f) => f.failed); + if (hasFailedAttachments) { + const {formatMessage} = this.context.intl; + const cancel = () => { + this.setInputValue(value); + this.setState({sendingMessage: false}); + }; + const accept = () => { + // Remove only failed files + handleClearFailedFiles(channelId, rootId); + this.sendMessage(); + }; + + DraftUtils.alertAttachmentFail(formatMessage, accept, cancel); + } else { + this.sendMessage(); + } + }); + } + + isFileLoading = () => { + const {files} = this.props; + + return files.some((file) => file.loading); + }; + + isSendButtonEnabled = () => { + return this.canSend() && !this.isFileLoading() && !this.state.sendingMessage; + }; + + numberOfTimezones = async () => { + const {channelId, getChannelTimezones} = this.props; + const {data} = await getChannelTimezones(channelId); + this.setState({channelTimezoneCount: data?.length || 0}); + }; + + sendCommand = async (msg) => { + const {intl} = this.context; + const {channelId, executeCommand, rootId, userIsOutOfOffice} = this.props; + + const status = DraftUtils.getStatusFromSlashCommand(msg); + if (userIsOutOfOffice && DraftUtils.isStatusSlashCommand(status)) { + confirmOutOfOfficeDisabled(intl, status, this.updateStatus); + this.setState({sendingMessage: false}); + return; + } + + const {error} = await executeCommand(msg, channelId, rootId); + this.setState({sendingMessage: false}); + + if (error) { + this.setInputValue(msg); + DraftUtils.alertSlashCommandFailed(intl.formatMessage, error.message); + return; + } + + this.setInputValue(''); + this.input.current.changeDraft(''); + }; + + sendMessage = () => { + const value = this.input.current?.getValue() || ''; + const {channelMemberCountsByGroup, enableConfirmNotificationsToChannel, groupsWithAllowReference, membersCount, useGroupMentions, useChannelMentions} = this.props; + const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions; + const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions; + const toAllOrChannel = DraftUtils.textContainsAtAllAtChannel(value); + const groupMentions = (!toAllOrChannel && notificationsToGroups) ? DraftUtils.groupsMentionedInText(groupsWithAllowReference, value) : []; + + if (value.indexOf('/') === 0) { + this.sendCommand(value); + } else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) { + this.showSendToAllOrChannelAlert(membersCount, value); + } else if (groupMentions.length > 0) { + const {groupMentionsSet, memberNotifyCount, channelTimezoneCount} = DraftUtils.mapGroupMentions(channelMemberCountsByGroup, groupMentions); + if (memberNotifyCount > 0) { + this.showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, channelTimezoneCount, value); + } else { + this.doSubmitMessage(); + } + } else { + this.doSubmitMessage(); + } + }; + + sendReaction = (emoji) => { + const {addReactionToLatestPost, rootId} = this.props; + addReactionToLatestPost(emoji, rootId); + + this.setInputValue(''); + this.input.current.changeDraft(''); + + this.setState({sendingMessage: false}); + }; + + setInputValue = (value, autocomplete = false) => { + if (this.input.current) { + this.input.current.setValue(value, autocomplete); + this.updateCanSubmit(); + } + } + + showSendToAllOrChannelAlert = (membersCount, msg) => { + const {formatMessage} = this.context.intl; + const {channelTimezoneCount} = this.state; + const {isTimezoneEnabled} = this.props; + const notifyAllMessage = DraftUtils.buildChannelWideMentionMessage(formatMessage, membersCount, isTimezoneEnabled, channelTimezoneCount); + const cancel = () => { + this.setInputValue(msg); + this.setState({sendingMessage: false}); + }; + + DraftUtils.alertChannelWideMention(formatMessage, notifyAllMessage, this.doSubmitMessage, cancel); + }; + + showSendToGroupsAlert = (groupMentions, memberNotifyCount, channelTimezoneCount, msg) => { + const {formatMessage} = this.context.intl; + const notifyAllMessage = DraftUtils.buildGroupMentionsMessage(formatMessage, groupMentions, memberNotifyCount, channelTimezoneCount); + const cancel = () => { + this.setInputValue(msg); + this.setState({sendingMessage: false}); + }; + + DraftUtils.alertSendToGroups(formatMessage, notifyAllMessage, this.doSubmitMessage, cancel); + }; + + updateCanSubmit = () => { + const {canSubmit} = this.state; + const enabled = this.isSendButtonEnabled(); + + if (canSubmit !== enabled) { + this.setState({canSubmit: enabled}); + } + } + + updateQuickActionValue = (value) => { + if (this.quickActions.current) { + this.quickActions.current.handleInputEvent(value); + } + + this.updateCanSubmit(); + } + + updateStatus = (status) => { + const {currentUserId, setStatus} = this.props; + + setStatus({user_id: currentUserId, status}); + }; + + render() { + const { + channelDisplayName, + channelId, + cursorPositionEvent, + isLandscape, + files, + maxMessageLength, + screenId, + valueEvent, + registerTypingAnimation, + rootId, + theme, + } = this.props; + const style = getStyleSheet(theme); + + return ( + <> + + {Platform.OS === 'android' && + + } + + + + + + + + + + + + ); + } +} + +const getStyleSheet = makeStyleSheetFromTheme((theme) => { + return { + actionsContainer: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingBottom: Platform.select({ + ios: 1, + android: 2, + }), + }, + inputContainer: { + flex: 1, + flexDirection: 'column', + }, + inputContentContainer: { + alignItems: 'stretch', + paddingTop: Platform.select({ + ios: 7, + android: 0, + }), + }, + inputWrapper: { + alignItems: 'flex-end', + flexDirection: 'row', + justifyContent: 'center', + paddingBottom: 2, + backgroundColor: theme.centerChannelBg, + borderTopWidth: 1, + borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), + }, + }; +}); \ No newline at end of file diff --git a/app/components/post_draft/draft_input/draft_input.test.js b/app/components/post_draft/draft_input/draft_input.test.js new file mode 100644 index 000000000..49bcdd479 --- /dev/null +++ b/app/components/post_draft/draft_input/draft_input.test.js @@ -0,0 +1,194 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {Alert} from 'react-native'; +import {shallowWithIntl} from 'test/intl-test-helper'; + +import Preferences from '@mm-redux/constants/preferences'; +import DraftInput from './draft_input'; + +jest.mock('react-native-image-picker', () => ({ + launchCamera: jest.fn(), +})); + +describe('DraftInput', () => { + const baseProps = { + registerTypingAnimation: jest.fn(), + addReactionToLatestPost: jest.fn(), + createPost: jest.fn(), + executeCommand: jest.fn(), + handleCommentDraftChanged: jest.fn(), + handlePostDraftChanged: jest.fn(), + handleClearFiles: jest.fn(), + handleClearFailedFiles: jest.fn(), + handleRemoveLastFile: jest.fn(), + initUploadFiles: jest.fn(), + userTyping: jest.fn(), + handleCommentDraftSelectionChanged: jest.fn(), + setStatus: jest.fn(), + selectPenultimateChannel: jest.fn(), + getChannelTimezones: jest.fn(), + getChannelMemberCountsByGroup: jest.fn(), + canUploadFiles: true, + channelId: 'channel-id', + channelDisplayName: 'Test Channel', + channelTeamId: 'channel-team-id', + channelIsReadOnly: false, + currentUserId: 'current-user-id', + deactivatedChannel: false, + files: [], + maxFileSize: 1024, + maxMessageLength: 4000, + rootId: '', + theme: Preferences.THEMES.default, + uploadFileRequestStatus: 'NOT_STARTED', + value: '', + userIsOutOfOffice: false, + channelIsArchived: false, + onCloseChannel: jest.fn(), + cursorPositionEvent: '', + valueEvent: '', + isLandscape: false, + screenId: 'NavigationScreen1', + canPost: true, + currentChannelMembersCount: 50, + enableConfirmNotificationsToChannel: true, + useChannelMentions: true, + useGroupMentions: true, + groupsWithAllowReference: new Map([ + ['@developers', { + id: 'developers', + name: 'developers', + }], + ['@qa', { + id: 'qa', + name: 'qa', + }], + ]), + channelMemberCountsByGroup: { + developers: { + channel_member_count: 10, + channel_member_timezones_count: 0, + }, + qa: { + channel_member_count: 3, + channel_member_timezones_count: 0, + }, + }, + membersCount: 10, + }; + const ref = React.createRef(); + + test('should send an alert when sending a message with a channel mention', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@all'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + + instance.sendMessage(); + expect(Alert.alert).toBeCalled(); + expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything()); + }); + + test('should send an alert when sending a message with a group mention with group with count more than NOTIFY_ALL', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@developers'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + instance.sendMessage(); + expect(Alert.alert).toBeCalled(); + }); + + test('should not send an alert when sending a message with a group mention with group with count less than NOTIFY_ALL', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@qa'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + + instance.sendMessage(); + expect(Alert.alert).not.toBeCalled(); + }); + + test('should not send an alert when sending a message with a channel mention when the user does not have channel mentions permission', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@all'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + + instance.sendMessage(); + expect(Alert.alert).not.toHaveBeenCalled(); + }); + + test('should not send an alert when sending a message with a channel mention when the user does not have group mentions permission', () => { + const wrapper = shallowWithIntl( + , + ); + const message = '@developer'; + const instance = wrapper.instance(); + expect(instance.input).toEqual({current: null}); + instance.input = { + current: { + getValue: () => message, + setValue: jest.fn(), + changeDraft: jest.fn(), + }, + }; + + instance.sendMessage(); + expect(Alert.alert).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/post_draft/draft_input/index.js b/app/components/post_draft/draft_input/index.js new file mode 100644 index 000000000..def9f4d1a --- /dev/null +++ b/app/components/post_draft/draft_input/index.js @@ -0,0 +1,104 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {connect} from 'react-redux'; + +import {executeCommand} from '@actions/views/command'; +import {addReactionToLatestPost} from '@actions/views/emoji'; +import {handleClearFiles, handleClearFailedFiles} from '@actions/views/file_upload'; +import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft'; +import {getChannelTimezones, getChannelMemberCountsByGroup} from '@mm-redux/actions/channels'; +import {createPost} from '@mm-redux/actions/posts'; +import {setStatus} from '@mm-redux/actions/users'; +import {General, Permissions} from '@mm-redux/constants'; +import {getCurrentChannel, getChannel, getChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from '@mm-redux/selectors/entities/channels'; +import {getConfig, getLicense} from '@mm-redux/selectors/entities/general'; +import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/groups'; +import {getTheme} from '@mm-redux/selectors/entities/preferences'; +import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; +import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users'; +import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; +import {isLandscape} from '@selectors/device'; +import {getCurrentChannelDraft, getThreadDraft} from '@selectors/views'; + +import PostDraft from './draft_input'; + +export function mapStateToProps(state, ownProps) { + const channelId = ownProps.channelId; + const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state); + const config = getConfig(state); + const channel = ownProps.rootId ? getChannel(state, channelId) : getCurrentChannel(state); + const currentUserId = getCurrentUserId(state); + const status = getStatusForUserId(state, currentUserId); + const userIsOutOfOffice = status === General.OUT_OF_OFFICE; + const enableConfirmNotificationsToChannel = config?.EnableConfirmNotificationsToChannel === 'true'; + const currentChannelStats = getChannelStats(state, channelId); + const membersCount = currentChannelStats?.member_count || 0; // eslint-disable-line camelcase + const isTimezoneEnabled = config?.ExperimentalTimezone === 'true'; + const channelTeamId = channel ? channel.team_id : ''; + const license = getLicense(state); + let useChannelMentions = true; + let useGroupMentions = false; + const channelMemberCountsByGroup = selectChannelMemberCountsByGroup(state, channelId); + let groupsWithAllowReference = new Map(); + + if (channel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) { + useChannelMentions = haveIChannelPermission( + state, + { + channel: channel.id, + permission: Permissions.USE_CHANNEL_MENTIONS, + default: true, + }, + ); + } + + if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 24) && license && license.IsLicensed === 'true') { + useGroupMentions = haveIChannelPermission( + state, + { + channel: channel.id, + team: channel.team_id, + permission: Permissions.USE_GROUP_MENTIONS, + }, + ); + + if (useGroupMentions) { + groupsWithAllowReference = getAssociatedGroupsForReferenceMap(state, channelTeamId, channelId); + } + } + + return { + currentChannel: channel, + channelId, + channelTeamId, + channelDisplayName: state.views.channel.displayName || (channel ? channel.display_name : ''), + currentUserId, + enableConfirmNotificationsToChannel, + files: currentDraft.files, + isLandscape: isLandscape(state), + isTimezoneEnabled, + maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH_FALLBACK, + membersCount, + theme: getTheme(state), + useChannelMentions, + userIsOutOfOffice, + value: currentDraft.draft, + groupsWithAllowReference, + useGroupMentions, + channelMemberCountsByGroup, + }; +} + +const mapDispatchToProps = { + addReactionToLatestPost, + createPost, + executeCommand, + getChannelTimezones, + handleClearFiles, + handleClearFailedFiles, + setStatus, + getChannelMemberCountsByGroup, +}; + +export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft); diff --git a/app/components/post_draft/index.js b/app/components/post_draft/index.js index f52f3ce09..c72c8f347 100644 --- a/app/components/post_draft/index.js +++ b/app/components/post_draft/index.js @@ -5,89 +5,38 @@ import {connect} from 'react-redux'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; import {General, Permissions} from '@mm-redux/constants'; -import {createPost} from '@mm-redux/actions/posts'; -import {setStatus} from '@mm-redux/actions/users'; -import {getCurrentChannel, isCurrentChannelReadOnly, getCurrentChannelStats, getChannelMemberCountsByGroup as selectChannelMemberCountsByGroup} from '@mm-redux/selectors/entities/channels'; +import {getCurrentChannel, getChannel, isCurrentChannelReadOnly} from '@mm-redux/selectors/entities/channels'; import {haveIChannelPermission} from '@mm-redux/selectors/entities/roles'; -import {getConfig, getLicense} from '@mm-redux/selectors/entities/general'; import {getTheme} from '@mm-redux/selectors/entities/preferences'; -import {getCurrentUserId, getStatusForUserId} from '@mm-redux/selectors/entities/users'; -import {getChannelTimezones, getChannelMemberCountsByGroup} from '@mm-redux/actions/channels'; -import {getAssociatedGroupsForReferenceMap} from '@mm-redux/selectors/entities/groups'; - -import {executeCommand} from '@actions/views/command'; -import {addReactionToLatestPost} from '@actions/views/emoji'; -import {handleClearFiles, handleClearFailedFiles, initUploadFiles} from '@actions/views/file_upload'; -import {MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft'; -import {getCurrentChannelDraft, getThreadDraft} from '@selectors/views'; +import {getCurrentUserId} from '@mm-redux/selectors/entities/users'; import {getChannelMembersForDm} from '@selectors/channel'; -import {getAllowedServerMaxFileSize} from '@utils/file'; -import {isLandscape} from '@selectors/device'; import PostDraft from './post_draft'; export function mapStateToProps(state, ownProps) { - const currentDraft = ownProps.rootId ? getThreadDraft(state, ownProps.rootId) : getCurrentChannelDraft(state); - const config = getConfig(state); - const currentChannel = getCurrentChannel(state); + const channel = ownProps.rootId ? getChannel(state) : getCurrentChannel(state); const currentUserId = getCurrentUserId(state); - const status = getStatusForUserId(state, currentUserId); - const userIsOutOfOffice = status === General.OUT_OF_OFFICE; - const enableConfirmNotificationsToChannel = config?.EnableConfirmNotificationsToChannel === 'true'; - const currentChannelStats = getCurrentChannelStats(state); - const membersCount = currentChannelStats?.member_count || 0; // eslint-disable-line camelcase - const isTimezoneEnabled = config?.ExperimentalTimezone === 'true'; - const channelId = ownProps.channelId || (currentChannel ? currentChannel.id : ''); - const channelTeamId = currentChannel ? currentChannel.team_id : ''; - const license = getLicense(state); + const channelId = ownProps.channelId || (channel ? channel.id : ''); let canPost = true; - let useChannelMentions = true; let deactivatedChannel = false; - let useGroupMentions = false; - const channelMemberCountsByGroup = selectChannelMemberCountsByGroup(state, channelId); - let groupsWithAllowReference = new Map(); - if (currentChannel && currentChannel.type === General.DM_CHANNEL) { - const teammate = getChannelMembersForDm(state, currentChannel); + if (channel && channel.type === General.DM_CHANNEL) { + const teammate = getChannelMembersForDm(state, channel); if (teammate.length && teammate[0].delete_at) { deactivatedChannel = true; } } - if (currentChannel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) { + if (channel && isMinimumServerVersion(state.entities.general.serverVersion, 5, 22)) { canPost = haveIChannelPermission( state, { - channel: currentChannel.id, - team: currentChannel.team_id, + channel: channel.id, + team: channel.team_id, permission: Permissions.CREATE_POST, default: true, }, ); - - useChannelMentions = haveIChannelPermission( - state, - { - channel: currentChannel.id, - permission: Permissions.USE_CHANNEL_MENTIONS, - default: true, - }, - ); - } - - if (isMinimumServerVersion(state.entities.general.serverVersion, 5, 24) && license && license.IsLicensed === 'true') { - useGroupMentions = haveIChannelPermission( - state, - { - channel: currentChannel.id, - team: currentChannel.team_id, - permission: Permissions.USE_GROUP_MENTIONS, - }, - ); - - if (useGroupMentions) { - groupsWithAllowReference = getAssociatedGroupsForReferenceMap(state, channelTeamId, channelId); - } } let channelIsReadOnly = false; @@ -97,41 +46,12 @@ export function mapStateToProps(state, ownProps) { return { canPost, - currentChannel, channelId, - channelTeamId, - channelDisplayName: state.views.channel.displayName || (currentChannel ? currentChannel.display_name : ''), - channelIsArchived: ownProps.channelIsArchived || (currentChannel ? currentChannel.delete_at !== 0 : false), + channelIsArchived: ownProps.channelIsArchived || (channel ? channel.delete_at !== 0 : false), channelIsReadOnly, - currentUserId, deactivatedChannel, - enableConfirmNotificationsToChannel, - files: currentDraft.files, - isLandscape: isLandscape(state), - isTimezoneEnabled, - maxMessageLength: (config && parseInt(config.MaxPostSize || 0, 10)) || MAX_MESSAGE_LENGTH_FALLBACK, - maxFileSize: getAllowedServerMaxFileSize(config), - membersCount, theme: getTheme(state), - useChannelMentions, - userIsOutOfOffice, - value: currentDraft.draft, - groupsWithAllowReference, - useGroupMentions, - channelMemberCountsByGroup, }; } -const mapDispatchToProps = { - addReactionToLatestPost, - createPost, - executeCommand, - getChannelTimezones, - handleClearFiles, - handleClearFailedFiles, - initUploadFiles, - setStatus, - getChannelMemberCountsByGroup, -}; - -export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(PostDraft); +export default connect(mapStateToProps, null, null, {forwardRef: true})(PostDraft); diff --git a/app/components/post_draft/index.test.js b/app/components/post_draft/index.test.js index 2c9a7e881..cbab99396 100644 --- a/app/components/post_draft/index.test.js +++ b/app/components/post_draft/index.test.js @@ -5,11 +5,8 @@ import {Permissions} from '@mm-redux/constants'; import * as channelSelectors from '@mm-redux/selectors/entities/channels'; -import * as userSelectors from '@mm-redux/selectors/entities/users'; -import * as generalSelectors from '@mm-redux/selectors/entities/general'; import * as preferenceSelectors from '@mm-redux/selectors/entities/preferences'; import * as roleSelectors from '@mm-redux/selectors/entities/roles'; -import * as deviceSelectors from '@selectors/device'; import {isMinimumServerVersion} from '@mm-redux/utils/helpers'; @@ -22,12 +19,8 @@ jest.mock('./post_draft', () => ({ channelSelectors.getCurrentChannel = jest.fn().mockReturnValue({}); channelSelectors.isCurrentChannelReadOnly = jest.fn(); -channelSelectors.getCurrentChannelStats = jest.fn(); -userSelectors.getStatusForUserId = jest.fn(); -generalSelectors.canUploadFilesOnMobile = jest.fn(); preferenceSelectors.getTheme = jest.fn(); roleSelectors.haveIChannelPermission = jest.fn(); -deviceSelectors.isLandscape = jest.fn(); describe('mapStateToProps', () => { const baseState = { @@ -102,12 +95,6 @@ describe('mapStateToProps', () => { permission: Permissions.CREATE_POST, default: true, }); - - expect(roleSelectors.haveIChannelPermission).toHaveBeenCalledWith(state, { - channel: undefined, - permission: Permissions.USE_CHANNEL_MENTIONS, - default: true, - }); }); test('haveIChannelPermission is not called when isMinimumServerVersion is 5.22v but currentChannel is null', () => { diff --git a/app/components/post_draft/post_draft.js b/app/components/post_draft/post_draft.js index 35b878147..d8d1745e1 100644 --- a/app/components/post_draft/post_draft.js +++ b/app/components/post_draft/post_draft.js @@ -3,642 +3,73 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Alert, Platform, ScrollView, View} from 'react-native'; -import {intlShape} from 'react-intl'; -import RNFetchBlob from 'rn-fetch-blob'; +import {Platform} from 'react-native'; +import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; -import Autocomplete from '@components/autocomplete'; -import {paddingHorizontal as padding} from '@components/safe_area_view/iphone_x_spacing'; -import {CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, CHANNEL_POST_TEXTBOX_VALUE_CHANGE, IS_REACTION_REGEX, MAX_FILE_COUNT} from '@constants/post_draft'; -import {NOTIFY_ALL_MEMBERS} from '@constants/view'; -import {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from 'app/constants/autocomplete'; -import {General} from '@mm-redux/constants'; +import {UPDATE_NATIVE_SCROLLVIEW} from '@constants/post_draft'; import EventEmitter from '@mm-redux/utils/event_emitter'; -import {getFormattedFileSize} from '@mm-redux/utils/file_utils'; -import EphemeralStore from '@store/ephemeral_store'; -import {confirmOutOfOfficeDisabled} from '@utils/status'; -import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; import Archived from './archived'; -import PostInput from './post_input'; -import QuickActions from './quick_actions'; -import Typing from './typing'; -import Uploads from './uploads'; - -const AUTOCOMPLETE_MARGIN = 20; -const AUTOCOMPLETE_MAX_HEIGHT = 200; +import DraftInput from './draft_input'; +import ReadOnly from './read_only'; export default class PostDraft extends PureComponent { static propTypes = { - registerTypingAnimation: PropTypes.func.isRequired, - addReactionToLatestPost: PropTypes.func.isRequired, - getChannelMemberCountsByGroup: PropTypes.func.isRequired, + accessoriesContainerID: PropTypes.string, canPost: PropTypes.bool.isRequired, - channelDisplayName: PropTypes.string, channelId: PropTypes.string.isRequired, channelIsArchived: PropTypes.bool, channelIsReadOnly: PropTypes.bool.isRequired, - createPost: PropTypes.func.isRequired, - currentUserId: PropTypes.string.isRequired, cursorPositionEvent: PropTypes.string, deactivatedChannel: PropTypes.bool.isRequired, - enableConfirmNotificationsToChannel: PropTypes.bool, - executeCommand: PropTypes.func.isRequired, - files: PropTypes.array, - getChannelTimezones: PropTypes.func.isRequired, - handleClearFiles: PropTypes.func.isRequired, - handleClearFailedFiles: PropTypes.func.isRequired, - initUploadFiles: PropTypes.func.isRequired, - isLandscape: PropTypes.bool.isRequired, - isTimezoneEnabled: PropTypes.bool, - maxMessageLength: PropTypes.number.isRequired, - maxFileSize: PropTypes.number.isRequired, - membersCount: PropTypes.number, + registerTypingAnimation: PropTypes.func.isRequired, rootId: PropTypes.string, screenId: PropTypes.string.isRequired, - setStatus: PropTypes.func.isRequired, - theme: PropTypes.object.isRequired, - useChannelMentions: PropTypes.bool.isRequired, - userIsOutOfOffice: PropTypes.bool.isRequired, - value: PropTypes.string.isRequired, + scrollViewNativeID: PropTypes.string, valueEvent: PropTypes.string, - useGroupMentions: PropTypes.bool.isRequired, - channelMemberCountsByGroup: PropTypes.object, - groupsWithAllowReference: PropTypes.object, + theme: PropTypes.object.isRequired, }; - static defaultProps = { - canPost: true, - cursorPositionEvent: CHANNEL_POST_TEXTBOX_CURSOR_CHANGE, - files: [], - rootId: '', - value: '', - valueEvent: CHANNEL_POST_TEXTBOX_VALUE_CHANGE, - }; - - static contextTypes = { - intl: intlShape, - }; - - constructor(props) { - super(props); - - this.input = React.createRef(); - - this.state = { - top: 0, - value: props.value, - rootId: props.rootId, - channelId: props.channelId, - channelTimezoneCount: 0, - }; - } + draftInput = React.createRef(); + keyboardTracker = React.createRef(); componentDidMount() { - const {getChannelMemberCountsByGroup, channelId, isTimezoneEnabled, useGroupMentions} = this.props; - if (useGroupMentions) { - getChannelMemberCountsByGroup(channelId, isTimezoneEnabled); - } + EventEmitter.on(UPDATE_NATIVE_SCROLLVIEW, this.updateNativeScrollView); } - componentDidUpdate(prevProps) { - const {channelId, rootId, value, useGroupMentions, getChannelMemberCountsByGroup, isTimezoneEnabled} = this.props; - const diffChannel = channelId !== prevProps?.channelId; - const diffTimezoneEnabled = isTimezoneEnabled !== prevProps?.isTimezoneEnabled; - - if (this.input.current) { - const diffThread = rootId !== prevProps.rootId; - if (diffChannel || diffThread) { - const trimmed = value.trim(); - this.input.current.setValue(trimmed); - this.updateInitialValue(trimmed); - } - } - - if (diffTimezoneEnabled || diffChannel) { - this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount})); - if (useGroupMentions) { - getChannelMemberCountsByGroup(channelId, isTimezoneEnabled); - } - } + componentWillUnmount() { + EventEmitter.off(UPDATE_NATIVE_SCROLLVIEW, this.updateNativeScrollView); } - blurTextBox = () => { - if (this.input.current) { - this.input.current.blur(); - } - } - - canSend = () => { - const {files, maxMessageLength} = this.props; - const value = this.input.current?.getValue() || ''; - const messageLength = value.trim().length; - - if (messageLength > maxMessageLength) { - return false; - } - - if (files.length) { - const loadingComplete = !this.isFileLoading(); - return loadingComplete; - } - - return messageLength > 0; - }; - - showSendToGroupsAlert = (groupMentions, memberNotifyCount, channelTimezoneCount, msg) => { - const {intl} = this.context; - - let notifyAllMessage = ''; - if (groupMentions.length === 1) { - if (channelTimezoneCount > 0) { - notifyAllMessage = ( - intl.formatMessage( - { - id: 'mobile.post_textbox.one_group.message.with_timezones', - defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', - }, - { - mention: groupMentions[0], - totalMembers: memberNotifyCount, - timezones: channelTimezoneCount, - }, - ) - ); - } else { - notifyAllMessage = ( - intl.formatMessage( - { - id: 'mobile.post_textbox.one_group.message.without_timezones', - defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?', - }, - { - mention: groupMentions[0], - totalMembers: memberNotifyCount, - }, - ) - ); - } - } else if (channelTimezoneCount > 0) { - notifyAllMessage = ( - intl.formatMessage( - { - id: 'mobile.post_textbox.multi_group.message.with_timezones', - defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', - }, - { - mentions: groupMentions.slice(0, -1).join(', '), - finalMention: groupMentions[groupMentions.length - 1], - totalMembers: memberNotifyCount, - timezones: channelTimezoneCount, - }, - ) - ); - } else { - notifyAllMessage = ( - intl.formatMessage( - { - id: 'mobile.post_textbox.multi_group.message.without_timezones', - defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?', - }, - { - mentions: groupMentions.slice(0, -1).join(', '), - finalMention: groupMentions[groupMentions.length - 1], - totalMembers: memberNotifyCount, - }, - ) - ); - } - - Alert.alert( - intl.formatMessage({ - id: 'mobile.post_textbox.groups.title', - defaultMessage: 'Confirm sending notifications to groups', - }), - notifyAllMessage, - [ - { - text: intl.formatMessage({ - id: 'mobile.post_textbox.entire_channel.cancel', - defaultMessage: 'Cancel', - }), - onPress: () => { - this.input.current.setValue(msg); - this.setState({sendingMessage: false}); - }, - }, - { - text: intl.formatMessage({ - id: 'mobile.post_textbox.entire_channel.confirm', - defaultMessage: 'Confirm', - }), - onPress: () => this.doSubmitMessage(), - }, - ], - ); - }; - - doSubmitMessage = () => { - const {createPost, currentUserId, channelId, files, handleClearFiles, rootId} = this.props; - const value = this.input.current?.getValue() || ''; - const postFiles = files.filter((f) => !f.failed); - const post = { - user_id: currentUserId, - channel_id: channelId, - root_id: rootId, - parent_id: rootId, - message: value, - }; - - createPost(post, postFiles); - - if (postFiles.length) { - handleClearFiles(channelId, rootId); - } - - if (this.input.current) { - this.input.current.setValue(''); - this.input.current.changeDraft(''); - } - - this.setState({sendingMessage: false}); - - if (Platform.OS === 'android') { - // Fixes the issue where Android predictive text would prepend suggestions to the post draft when messages - // are typed successively without blurring the input - const nextState = { - keyboardType: 'email-address', - }; - - const callback = () => this.setState({keyboardType: 'default'}); - - this.setState(nextState, callback); - } - - EventEmitter.emit('scroll-to-bottom'); - }; - - getStatusFromSlashCommand = (message) => { - const tokens = message.split(' '); - - if (tokens.length > 0) { - return tokens[0].substring(1); - } - return ''; - }; - - handleInputQuickAction = (inputValue) => { - if (this.input.current) { - this.input.current.setValue(inputValue, true); - this.input.current.focus(); + handleInputQuickAction = (value) => { + if (this.draftInput?.current) { + this.draftInput.current.handleInputQuickAction(value); } }; - handleLayout = (e) => { - this.setState({ - top: e.nativeEvent.layout.y, - }); - }; - - handlePasteFiles = (error, files) => { - if (this.props.screenId === EphemeralStore.getNavigationTopComponentId()) { - if (error) { - this.showPasteFilesErrorDialog(); - return; - } - - const {maxFileSize} = this.props; - const availableCount = MAX_FILE_COUNT - this.props.files.length; - if (files.length > availableCount) { - this.onShowFileMaxWarning(); - return; - } - - const largeFile = files.find((image) => image.fileSize > maxFileSize); - if (largeFile) { - this.onShowFileSizeWarning(largeFile.fileName); - return; - } - - this.handleUploadFiles(files); + updateNativeScrollView = (scrollViewNativeID) => { + if (this.keyboardTracker?.current) { + this.keyboardTracker.current.resetScrollView(scrollViewNativeID); } }; - handleSendMessage = () => { - if (!this.input.current) { - return; - } - - this.input.current.resetTextInput(); - - requestAnimationFrame(() => { - const value = this.input.current.getValue(); - if (!this.isSendButtonEnabled()) { - this.input.current.setValue(value); - return; - } - - this.setState({sendingMessage: true}); - - const {channelId, files, handleClearFailedFiles, rootId} = this.props; - - const isReactionMatch = value.match(IS_REACTION_REGEX); - if (isReactionMatch) { - const emoji = isReactionMatch[2]; - this.sendReaction(emoji); - return; - } - - const hasFailedAttachments = files.some((f) => f.failed); - if (hasFailedAttachments) { - const {intl} = this.context; - - Alert.alert( - intl.formatMessage({ - id: 'mobile.post_textbox.uploadFailedTitle', - defaultMessage: 'Attachment failure', - }), - intl.formatMessage({ - id: 'mobile.post_textbox.uploadFailedDesc', - defaultMessage: 'Some attachments failed to upload to the server. Are you sure you want to post the message?', - }), - [{ - text: intl.formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}), - onPress: () => { - this.input.current.setValue(value); - this.setState({sendingMessage: false}); - }, - }, { - text: intl.formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}), - onPress: () => { - // Remove only failed files - handleClearFailedFiles(channelId, rootId); - this.sendMessage(); - }, - }], - ); - } else { - this.sendMessage(); - } - }); - }; - - handleUploadFiles = async (files) => { - const file = files[0]; - if (!file.fileSize | !file.fileName) { - const path = (file.path || file.uri).replace('file://', ''); - const fileInfo = await RNFetchBlob.fs.stat(path); - file.fileSize = fileInfo.size; - file.fileName = fileInfo.filename; - } - - if (file.fileSize > this.props.maxFileSize) { - this.onShowFileSizeWarning(file.fileName); - } else { - this.props.initUploadFiles(files, this.props.rootId); - } - }; - - isFileLoading = () => { - const {files} = this.props; - - return files.some((file) => file.loading); - }; - - isSendButtonEnabled = () => { - return this.canSend() && !this.isFileLoading() && !this.state.sendingMessage; - }; - - isStatusSlashCommand = (command) => { - return command === General.ONLINE || command === General.AWAY || - command === General.DND || command === General.OFFLINE; - }; - - onShowFileMaxWarning = () => { - EventEmitter.emit('fileMaxWarning'); - }; - - onShowFileSizeWarning = (filename) => { - const {formatMessage} = this.context.intl; - const fileSizeWarning = formatMessage({ - id: 'file_upload.fileAbove', - defaultMessage: 'File above {max} cannot be uploaded: {filename}', - }, { - max: getFormattedFileSize({size: this.props.maxFileSize}), - filename, - }); - - EventEmitter.emit('fileSizeWarning', fileSizeWarning); - setTimeout(() => { - EventEmitter.emit('fileSizeWarning', null); - }, 5000); - }; - - numberOfTimezones = async () => { - const {channelId, getChannelTimezones} = this.props; - const {data} = await getChannelTimezones(channelId); - return data?.length || 0; - }; - - sendCommand = async (msg) => { - const {intl} = this.context; - const {channelId, executeCommand, rootId, userIsOutOfOffice} = this.props; - - const status = this.getStatusFromSlashCommand(msg); - if (userIsOutOfOffice && this.isStatusSlashCommand(status)) { - confirmOutOfOfficeDisabled(intl, status, this.updateStatus); - this.setState({sendingMessage: false}); - return; - } - - const {error} = await executeCommand(msg, channelId, rootId); - this.setState({sendingMessage: false}); - - if (error) { - this.input.current.setValue(msg); - Alert.alert( - intl.formatMessage({ - id: 'mobile.commands.error_title', - defaultMessage: 'Error Executing Command', - }), - error.message, - ); - return; - } - - this.input.current.setValue(''); - this.input.current.changeDraft(''); - }; - - mapGroupMentions = (groupMentions) => { - const {channelMemberCountsByGroup} = this.props; - let memberNotifyCount = 0; - let channelTimezoneCount = 0; - const groupMentionsSet = new Set(); - groupMentions. - forEach((group) => { - const mappedValue = channelMemberCountsByGroup[group.id]; - if (mappedValue?.channel_member_count > NOTIFY_ALL_MEMBERS && mappedValue?.channel_member_count > memberNotifyCount) { - memberNotifyCount = mappedValue.channel_member_count; - channelTimezoneCount = mappedValue.channel_member_timezones_count; - } - groupMentionsSet.add(`@${group.name}`); - }); - return {groupMentionsSet, memberNotifyCount, channelTimezoneCount}; - } - - sendMessage = () => { - const value = this.input.current?.getValue() || ''; - const {enableConfirmNotificationsToChannel, membersCount, useGroupMentions, useChannelMentions} = this.props; - const notificationsToChannel = enableConfirmNotificationsToChannel && useChannelMentions; - const notificationsToGroups = enableConfirmNotificationsToChannel && useGroupMentions; - const toAllOrChannel = this.textContainsAtAllAtChannel(value); - const groupMentions = (!toAllOrChannel && notificationsToGroups) ? this.groupsMentionedInText(value) : []; - - if (value.indexOf('/') === 0) { - this.sendCommand(value); - } else if (notificationsToChannel && membersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) { - this.showSendToAllOrChannelAlert(membersCount, value); - } else if (groupMentions.length > 0) { - const {groupMentionsSet, memberNotifyCount, channelTimezoneCount} = this.mapGroupMentions(groupMentions); - if (memberNotifyCount > 0) { - this.showSendToGroupsAlert(Array.from(groupMentionsSet), memberNotifyCount, channelTimezoneCount, value); - } else { - this.doSubmitMessage(); - } - } else { - this.doSubmitMessage(); - } - }; - - sendReaction = (emoji) => { - const {addReactionToLatestPost, rootId} = this.props; - addReactionToLatestPost(emoji, rootId); - - this.input.current.setValue(''); - this.input.current.changeDraft(''); - - this.setState({sendingMessage: false}); - }; - - showPasteFilesErrorDialog = () => { - const {formatMessage} = this.context.intl; - Alert.alert( - formatMessage({ - id: 'mobile.files_paste.error_title', - defaultMessage: 'Paste failed', - }), - formatMessage({ - id: 'mobile.files_paste.error_description', - defaultMessage: 'An error occurred while pasting the file(s). Please try again.', - }), - [ - { - text: formatMessage({ - id: 'mobile.files_paste.error_dismiss', - defaultMessage: 'Dismiss', - }), - }, - ], - ); - }; - - showSendToAllOrChannelAlert = (membersCount, msg) => { - const {intl} = this.context; - const {channelTimezoneCount} = this.state; - const {isTimezoneEnabled} = this.props; - - let notifyAllMessage = ''; - if (isTimezoneEnabled && channelTimezoneCount) { - notifyAllMessage = ( - intl.formatMessage( - { - id: 'mobile.post_textbox.entire_channel.message.with_timezones', - defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', - }, - { - totalMembers: membersCount - 1, - timezones: channelTimezoneCount, - }, - ) - ); - } else { - notifyAllMessage = ( - intl.formatMessage( - { - id: 'mobile.post_textbox.entire_channel.message', - defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?', - }, - { - totalMembers: membersCount - 1, - }, - ) - ); - } - - Alert.alert( - intl.formatMessage({ - id: 'mobile.post_textbox.entire_channel.title', - defaultMessage: 'Confirm sending notifications to entire channel', - }), - notifyAllMessage, - [ - { - text: intl.formatMessage({ - id: 'mobile.post_textbox.entire_channel.cancel', - defaultMessage: 'Cancel', - }), - onPress: () => { - this.input.current.setValue(msg); - this.setState({sendingMessage: false}); - }, - }, - { - text: intl.formatMessage({ - id: 'mobile.post_textbox.entire_channel.confirm', - defaultMessage: 'Confirm', - }), - onPress: () => this.doSubmitMessage(), - }, - ], - ); - }; - - textContainsAtAllAtChannel = (text) => { - const textWithoutCode = text.replace(CODE_REGEX, ''); - return (/(?:\B|\b_+)@(channel|all)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode); - }; - - groupsMentionedInText = (text) => { - const {groupsWithAllowReference} = this.props; - const groups = []; - if (groupsWithAllowReference.size > 0) { - const textWithoutCode = text.replace(CODE_REGEX, ''); - const mentions = textWithoutCode.match(AT_MENTION_REGEX_GLOBAL) || []; - mentions.forEach((mention) => { - const group = groupsWithAllowReference.get(mention); - if (group) { - groups.push(group); - } - }); - } - return groups; - } - - updateInitialValue = (value) => { - this.setState({value}); - } - - updateStatus = (status) => { - const {currentUserId, setStatus} = this.props; - setStatus({ - user_id: currentUserId, - status, - }); - }; - render = () => { - const {channelIsArchived, deactivatedChannel, rootId, theme} = this.props; + const { + accessoriesContainerID, + canPost, + channelId, + channelIsArchived, + channelIsReadOnly, + cursorPositionEvent, + deactivatedChannel, + registerTypingAnimation, + rootId, + screenId, + scrollViewNativeID, + theme, + valueEvent, + } = this.props; + if (channelIsArchived || deactivatedChannel) { return ( + ); + } + + const draftInput = ( + + ); + + if (Platform.OS === 'android') { + return draftInput; + } + return ( - <> - - {Platform.OS === 'android' && - - } - - - - - - - - + + {draftInput} + ); }; } - -const getStyleSheet = makeStyleSheetFromTheme((theme) => { - return { - inputContainer: { - flex: 1, - flexDirection: 'column', - }, - inputContentContainer: { - alignItems: 'stretch', - paddingTop: Platform.select({ - ios: 7, - android: 0, - }), - }, - inputWrapper: { - alignItems: 'flex-end', - flexDirection: 'row', - justifyContent: 'center', - paddingBottom: 2, - backgroundColor: theme.centerChannelBg, - borderTopWidth: 1, - borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), - }, - readonlyContainer: { - marginLeft: 10, - }, - }; -}); diff --git a/app/components/post_draft/post_draft.test.js b/app/components/post_draft/post_draft.test.js index f868a2920..013172a23 100644 --- a/app/components/post_draft/post_draft.test.js +++ b/app/components/post_draft/post_draft.test.js @@ -2,348 +2,133 @@ // See LICENSE.txt for license information. import React from 'react'; -import {Alert} from 'react-native'; -import assert from 'assert'; -import {shallowWithIntl} from 'test/intl-test-helper'; +import TestRenderer from 'react-test-renderer'; +import {IntlProvider} from 'react-intl'; +import {Provider} from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; import Preferences from '@mm-redux/constants/preferences'; +import intitialState from '@store/initial_state'; + import PostDraft from './post_draft'; +const mockStore = configureMockStore([thunk]); +const state = { + ...intitialState, + entities: { + ...intitialState.entities, + channels: { + ...intitialState.entities.channels, + channels: { + 'channel-id': { + id: 'channel-id', + name: 'test-channel', + display_name: 'Display Name', + type: 'O', + }, + }, + channelMemberCountsByGroup: {}, + }, + }, + device: { + ...intitialState.device, + dimension: { + deviceWidth: 375, + deviceHeight: 812, + }, + }, +}; +const store = mockStore(state); + jest.mock('react-native-image-picker', () => ({ launchCamera: jest.fn(), })); describe('PostDraft', () => { const baseProps = { - addReactionToLatestPost: jest.fn(), - createPost: jest.fn(), - executeCommand: jest.fn(), - handleCommentDraftChanged: jest.fn(), - handlePostDraftChanged: jest.fn(), - handleClearFiles: jest.fn(), - handleClearFailedFiles: jest.fn(), - handleRemoveLastFile: jest.fn(), - initUploadFiles: jest.fn(), - userTyping: jest.fn(), - handleCommentDraftSelectionChanged: jest.fn(), - setStatus: jest.fn(), - selectPenultimateChannel: jest.fn(), - getChannelTimezones: jest.fn(), - getChannelMemberCountsByGroup: jest.fn(), - canUploadFiles: true, - channelId: 'channel-id', - channelDisplayName: 'Test Channel', - channelTeamId: 'channel-team-id', - channelIsReadOnly: false, - currentUserId: 'current-user-id', - deactivatedChannel: false, - files: [], - maxFileSize: 1024, - maxMessageLength: 4000, - rootId: '', - theme: Preferences.THEMES.default, - uploadFileRequestStatus: 'NOT_STARTED', - value: '', - userIsOutOfOffice: false, - channelIsArchived: false, - onCloseChannel: jest.fn(), - cursorPositionEvent: '', - valueEvent: '', - isLandscape: false, - screenId: 'NavigationScreen1', canPost: true, - currentChannelMembersCount: 50, - enableConfirmNotificationsToChannel: true, - useChannelMentions: true, - useGroupMentions: true, - groupsWithAllowReference: new Map([ - ['@developers', { - id: 'developers', - name: 'developers', - }], - ['@qa', { - id: 'qa', - name: 'qa', - }], - ]), - channelMemberCountsByGroup: { - developers: { - channel_member_count: 10, - channel_member_timezones_count: 0, - }, - qa: { - channel_member_count: 3, - channel_member_timezones_count: 0, - }, - }, - membersCount: 10, + channelId: 'channel-id', + channelIsArchived: false, + channelIsReadOnly: false, + deactivatedChannel: false, + registerTypingAnimation: jest.fn(), + rootId: '', + screenId: 'NavigationScreen1', + theme: Preferences.THEMES.default, }; - const ref = React.createRef(); - test('should send an alert when sending a message with a channel mention', () => { - const wrapper = shallowWithIntl( + test('Should render the DraftInput', () => { + const wrapper = renderWithRedux( , ); - const message = '@all'; - const instance = wrapper.instance(); - expect(instance.input).toEqual({current: null}); - instance.input = { - current: { - getValue: () => message, - setValue: jest.fn(), - changeDraft: jest.fn(), - }, - }; - instance.sendMessage(); - expect(Alert.alert).toBeCalled(); - expect(Alert.alert).toHaveBeenCalledWith('Confirm sending notifications to entire channel', expect.anything(), expect.anything()); + expect(wrapper.toJSON()).toMatchSnapshot(); + const element = wrapper.root.find((el) => el.type === 'TextInput'); + expect(element).toBeTruthy(); }); - test('should send an alert when sending a message with a group mention with group with count more than NOTIFY_ALL', () => { - const wrapper = shallowWithIntl( + test('Should render the Archived for channelIsArchived', () => { + const wrapper = renderWithRedux( , ); - const message = '@developers'; - const instance = wrapper.instance(); - expect(instance.input).toEqual({current: null}); - instance.input = { - current: { - getValue: () => message, - setValue: jest.fn(), - changeDraft: jest.fn(), - }, - }; - instance.sendMessage(); - expect(Alert.alert).toBeCalled(); + + expect(wrapper.toJSON()).toMatchSnapshot(); + const element = wrapper.root.find((el) => el.type === 'Text' && el.children && el.children[0] === 'archived channel'); + expect(element).toBeTruthy(); }); - test('should not send an alert when sending a message with a group mention with group with count less than NOTIFY_ALL', () => { - const wrapper = shallowWithIntl( + test('Should render the Archived for deactivatedChannel', () => { + const wrapper = renderWithRedux( , ); - const message = '@qa'; - const instance = wrapper.instance(); - expect(instance.input).toEqual({current: null}); - instance.input = { - current: { - getValue: () => message, - setValue: jest.fn(), - changeDraft: jest.fn(), - }, - }; - instance.sendMessage(); - expect(Alert.alert).not.toBeCalled(); + expect(wrapper.toJSON()).toMatchSnapshot(); + const element = wrapper.root.find((el) => el.type === 'Text' && el.children && el.children[0] === 'archived channel'); + expect(element).toBeTruthy(); }); - test('should not send an alert when sending a message with a channel mention when the user does not have channel mentions permission', () => { - const wrapper = shallowWithIntl( + test('Should render the ReadOnly for channelIsReadOnly', () => { + const wrapper = renderWithRedux( , ); - const message = '@all'; - const instance = wrapper.instance(); - expect(instance.input).toEqual({current: null}); - instance.input = { - current: { - getValue: () => message, - setValue: jest.fn(), - changeDraft: jest.fn(), - }, - }; - instance.sendMessage(); - expect(Alert.alert).not.toHaveBeenCalled(); + expect(wrapper.toJSON()).toMatchSnapshot(); + const element = wrapper.root.find((el) => el.type === 'Text' && el.children && el.children[0] === 'This channel is read-only.'); + expect(element).toBeTruthy(); }); - test('should not send an alert when sending a message with a channel mention when the user does not have group mentions permission', () => { - const wrapper = shallowWithIntl( + test('Should render the ReadOnly for canPost', () => { + const wrapper = renderWithRedux( , ); - const message = '@developer'; - const instance = wrapper.instance(); - expect(instance.input).toEqual({current: null}); - instance.input = { - current: { - getValue: () => message, - setValue: jest.fn(), - changeDraft: jest.fn(), - }, - }; - instance.sendMessage(); - expect(Alert.alert).not.toHaveBeenCalled(); - }); - - test('should return correct @all (same for @channel)', () => { - for (const data of [ - { - text: '', - result: false, - }, - { - text: 'all', - result: false, - }, - { - text: '@allison', - result: false, - }, - { - text: '@ALLISON', - result: false, - }, - { - text: '@all123', - result: false, - }, - { - text: '123@all', - result: false, - }, - { - text: 'hey@all', - result: false, - }, - { - text: 'hey@all.com', - result: false, - }, - { - text: '@all', - result: true, - }, - { - text: '@ALL', - result: true, - }, - { - text: '@all hey', - result: true, - }, - { - text: 'hey @all', - result: true, - }, - { - text: 'HEY @ALL', - result: true, - }, - { - text: 'hey @all!', - result: true, - }, - { - text: 'hey @all:+1:', - result: true, - }, - { - text: 'hey @ALL:+1:', - result: true, - }, - { - text: '`@all`', - result: false, - }, - { - text: '@someone `@all`', - result: false, - }, - { - text: '``@all``', - result: false, - }, - { - text: '```@all```', - result: false, - }, - { - text: '```\n@all\n```', - result: false, - }, - { - text: '```````\n@all\n```````', - result: false, - }, - { - text: '```code\n@all\n```', - result: false, - }, - { - text: '~~~@all~~~', - result: true, - }, - { - text: '~~~\n@all\n~~~', - result: false, - }, - { - text: ' /not_cmd @all', - result: true, - }, - { - text: '@channel', - result: true, - }, - { - text: '@channel.', - result: true, - }, - { - text: '@channel/test', - result: true, - }, - { - text: 'test/@channel', - result: true, - }, - { - text: '@all/@channel', - result: true, - }, - { - text: '@cha*nnel*', - result: false, - }, - { - text: '@cha**nnel**', - result: false, - }, - { - text: '*@cha*nnel', - result: false, - }, - { - text: '[@chan](https://google.com)nel', - result: false, - }, - { - text: '@cha![](https://myimage)nnel', - result: false, - }, - ]) { - const wrapper = shallowWithIntl( - , - ); - const containsAtChannel = wrapper.instance().textContainsAtAllAtChannel(data.text); - assert.equal(containsAtChannel, data.result, data.text); - } + expect(wrapper.toJSON()).toMatchSnapshot(); + const element = wrapper.root.find((el) => el.type === 'Text' && el.children && el.children[0] === 'This channel is read-only.'); + expect(element).toBeTruthy(); }); }); + +function renderWithRedux(component) { + return TestRenderer.create( + + + {component} + + , + ); +} \ No newline at end of file diff --git a/app/components/post_draft/post_input/__snapshots__/post_input.test.js.snap b/app/components/post_draft/post_input/__snapshots__/post_input.test.js.snap index f0889ab53..1329b1d49 100644 --- a/app/components/post_draft/post_input/__snapshots__/post_input.test.js.snap +++ b/app/components/post_draft/post_input/__snapshots__/post_input.test.js.snap @@ -4,13 +4,11 @@ exports[`PostInput should match, full snapshot 1`] = ` { - const {readonly, rootId} = this.props; + const {rootId} = this.props; let placeholder; - if (readonly) { - placeholder = {id: t('mobile.create_post.read_only'), defaultMessage: 'This channel is read-only.'}; - } else if (rootId) { + if (rootId) { placeholder = {id: t('create_comment.addComment'), defaultMessage: 'Add a comment...'}; } else { placeholder = {id: t('create_post.write'), defaultMessage: 'Write to {channelDisplayName}'}; @@ -161,19 +152,6 @@ export default class PostInput extends PureComponent { this.blur(); }; - handleHardwareEnterPress = (keyEvent) => { - if (HW_EVENT_IN_SCREEN.includes(EphemeralStore.getNavigationTopComponentId())) { - switch (keyEvent.pressedKey) { - case 'enter': - this.props.onSend(); - break; - case 'shift-enter': - this.handleInsertTextToDraft(HW_SHIFT_ENTER_TEXT); - break; - } - } - } - handleAppStateChange = (nextAppState) => { if (nextAppState !== 'active') { this.changeDraft(this.getValue()); @@ -249,7 +227,8 @@ export default class PostInput extends PureComponent { }; handleInsertTextToDraft = (text) => { - const {cursorPosition, value} = this.state; + const {cursorPosition} = this.state; + const value = this.getValue(); let completed; if (value.length === 0) { @@ -260,8 +239,9 @@ export default class PostInput extends PureComponent { completed = `${firstPart}${text}${secondPart}`; } - this.setState({ - value: completed, + this.value = completed; + this.input.current.setNativeProps({ + text: completed, }); }; @@ -285,7 +265,7 @@ export default class PostInput extends PureComponent { render() { const {formatMessage} = this.context.intl; - const {channelDisplayName, isLandscape, onPasteFiles, readonly, theme} = this.props; + const {channelDisplayName, isLandscape, theme} = this.props; const style = getStyleSheet(theme); const placeholder = this.getPlaceHolder(); let maxHeight = 150; @@ -308,8 +288,6 @@ export default class PostInput extends PureComponent { keyboardType={this.state.keyboardType} onEndEditing={this.handleEndEditing} disableFullscreenUI={true} - editable={!readonly} - onPaste={onPasteFiles} keyboardAppearance={getKeyboardAppearanceFromTheme(theme)} /> ); diff --git a/app/components/post_draft/quick_actions/camera_quick_action/camera_quick_action.test.js b/app/components/post_draft/quick_actions/camera_quick_action/camera_quick_action.test.js index ef57dad81..d2b23da74 100644 --- a/app/components/post_draft/quick_actions/camera_quick_action/camera_quick_action.test.js +++ b/app/components/post_draft/quick_actions/camera_quick_action/camera_quick_action.test.js @@ -18,7 +18,6 @@ jest.mock('react-native-image-picker', () => ({ describe('CameraButton', () => { const formatMessage = jest.fn(); const baseProps = { - blurTextBox: jest.fn(), fileCount: 0, maxFileCount: 5, onShowFileMaxWarning: jest.fn(), diff --git a/app/components/post_draft/quick_actions/camera_quick_action/index.js b/app/components/post_draft/quick_actions/camera_quick_action/index.js index 33c4d559f..8c6fa3d02 100644 --- a/app/components/post_draft/quick_actions/camera_quick_action/index.js +++ b/app/components/post_draft/quick_actions/camera_quick_action/index.js @@ -15,16 +15,16 @@ import ImagePicker from 'react-native-image-picker'; import Permissions from 'react-native-permissions'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {ICON_SIZE} from '@constants/post_draft'; +import {NavigationTypes} from '@constants'; +import {ICON_SIZE, MAX_FILE_COUNT_WARNING} from '@constants/post_draft'; +import EventEmitter from '@mm-redux/utils/event_emitter'; import {changeOpacity} from '@utils/theme'; export default class CameraQuickAction extends PureComponent { static propTypes = { - blurTextBox: PropTypes.func.isRequired, disabled: PropTypes.bool, fileCount: PropTypes.number, maxFileCount: PropTypes.number, - onShowFileMaxWarning: PropTypes.func, onUploadFiles: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, }; @@ -88,18 +88,16 @@ export default class CameraQuickAction extends PureComponent { handleButtonPress = () => { const { - blurTextBox, fileCount, maxFileCount, - onShowFileMaxWarning, } = this.props; if (fileCount === maxFileCount) { - onShowFileMaxWarning(); + EventEmitter.emit(MAX_FILE_COUNT_WARNING); return; } - blurTextBox(); + EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT); this.attachFileFromCamera(); }; diff --git a/app/components/post_draft/quick_actions/file_quick_action/file_quick_action.test.js b/app/components/post_draft/quick_actions/file_quick_action/file_quick_action.test.js index 227248e5f..1dd32c697 100644 --- a/app/components/post_draft/quick_actions/file_quick_action/file_quick_action.test.js +++ b/app/components/post_draft/quick_actions/file_quick_action/file_quick_action.test.js @@ -18,7 +18,6 @@ jest.mock('react-native-image-picker', () => ({ describe('FileQuickAction', () => { const formatMessage = jest.fn(); const baseProps = { - blurTextBox: jest.fn(), fileCount: 0, maxFileCount: 5, onShowFileMaxWarning: jest.fn(), diff --git a/app/components/post_draft/quick_actions/file_quick_action/index.js b/app/components/post_draft/quick_actions/file_quick_action/index.js index 337f7b859..8ae0cef38 100644 --- a/app/components/post_draft/quick_actions/file_quick_action/index.js +++ b/app/components/post_draft/quick_actions/file_quick_action/index.js @@ -11,18 +11,18 @@ import DocumentPicker from 'react-native-document-picker'; import Permissions from 'react-native-permissions'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {ICON_SIZE} from '@constants/post_draft'; +import {NavigationTypes} from '@constants'; +import {ICON_SIZE, MAX_FILE_COUNT_WARNING} from '@constants/post_draft'; +import EventEmitter from '@mm-redux/utils/event_emitter'; import {changeOpacity} from '@utils/theme'; const ShareExtension = NativeModules.MattermostShare; export default class FileQuickAction extends PureComponent { static propTypes = { - blurTextBox: PropTypes.func.isRequired, disabled: PropTypes.bool, fileCount: PropTypes.number, maxFileCount: PropTypes.number, - onShowFileMaxWarning: PropTypes.func, onUploadFiles: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, }; @@ -125,18 +125,16 @@ export default class FileQuickAction extends PureComponent { handleButtonPress = () => { const { - blurTextBox, fileCount, maxFileCount, - onShowFileMaxWarning, } = this.props; if (fileCount === maxFileCount) { - onShowFileMaxWarning(); + EventEmitter.emit(MAX_FILE_COUNT_WARNING); return; } - blurTextBox(); + EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT); this.attachFileFromFiles(); }; diff --git a/app/components/post_draft/quick_actions/image_quick_action/image_quick_action.test.js b/app/components/post_draft/quick_actions/image_quick_action/image_quick_action.test.js index 0434bd232..19a098087 100644 --- a/app/components/post_draft/quick_actions/image_quick_action/image_quick_action.test.js +++ b/app/components/post_draft/quick_actions/image_quick_action/image_quick_action.test.js @@ -18,7 +18,6 @@ jest.mock('react-native-image-picker', () => ({ describe('ImageQuickAction', () => { const formatMessage = jest.fn(); const baseProps = { - blurTextBox: jest.fn(), fileCount: 0, maxFileCount: 5, onShowFileMaxWarning: jest.fn(), diff --git a/app/components/post_draft/quick_actions/image_quick_action/index.js b/app/components/post_draft/quick_actions/image_quick_action/index.js index 809946cc5..f94aca274 100644 --- a/app/components/post_draft/quick_actions/image_quick_action/index.js +++ b/app/components/post_draft/quick_actions/image_quick_action/index.js @@ -10,16 +10,16 @@ import ImagePicker from 'react-native-image-picker'; import Permissions from 'react-native-permissions'; import TouchableWithFeedback from '@components/touchable_with_feedback'; -import {ICON_SIZE} from '@constants/post_draft'; +import {NavigationTypes} from '@constants'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {ICON_SIZE, MAX_FILE_COUNT_WARNING} from '@constants/post_draft'; import {changeOpacity} from '@utils/theme'; export default class ImageQuickAction extends PureComponent { static propTypes = { - blurTextBox: PropTypes.func.isRequired, disabled: PropTypes.bool, fileCount: PropTypes.number, maxFileCount: PropTypes.number, - onShowFileMaxWarning: PropTypes.func, onUploadFiles: PropTypes.func.isRequired, theme: PropTypes.object.isRequired, }; @@ -95,18 +95,16 @@ export default class ImageQuickAction extends PureComponent { handleButtonPress = () => { const { - blurTextBox, fileCount, maxFileCount, - onShowFileMaxWarning, } = this.props; if (fileCount === maxFileCount) { - onShowFileMaxWarning(); + EventEmitter.emit(MAX_FILE_COUNT_WARNING); return; } - blurTextBox(); + EventEmitter.emit(NavigationTypes.BLUR_POST_DRAFT); this.attachFileFromLibrary(); }; diff --git a/app/components/post_draft/quick_actions/index.js b/app/components/post_draft/quick_actions/index.js index 5572c4385..b0bde3f31 100644 --- a/app/components/post_draft/quick_actions/index.js +++ b/app/components/post_draft/quick_actions/index.js @@ -3,14 +3,18 @@ import {connect} from 'react-redux'; -import {canUploadFilesOnMobile} from '@mm-redux/selectors/entities/general'; +import {canUploadFilesOnMobile, getConfig} from '@mm-redux/selectors/entities/general'; +import {getAllowedServerMaxFileSize} from '@utils/file'; import QuickActions from './quick_actions'; function mapStateToProps(state) { + const config = getConfig(state); + return { canUploadFiles: canUploadFilesOnMobile(state), + maxFileSize: getAllowedServerMaxFileSize(config), }; } -export default connect(mapStateToProps)(QuickActions); \ No newline at end of file +export default connect(mapStateToProps, null, null, {forwardRef: true})(QuickActions); \ No newline at end of file diff --git a/app/components/post_draft/quick_actions/quick_actions.js b/app/components/post_draft/quick_actions/quick_actions.js index 9d55f9f61..5bb0404f6 100644 --- a/app/components/post_draft/quick_actions/quick_actions.js +++ b/app/components/post_draft/quick_actions/quick_actions.js @@ -5,36 +5,27 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import {Platform, StyleSheet, View} from 'react-native'; -import {MAX_FILE_COUNT} from '@constants/post_draft'; +import {MAX_FILE_COUNT, UPLOAD_FILES} from '@constants/post_draft'; import EventEmitter from '@mm-redux/utils/event_emitter'; import CameraAction from './camera_quick_action'; import ImageAction from './image_quick_action'; import FileAction from './file_quick_action'; import InputAction from './input_quick_action'; -import SendAction from './send_action'; export default class QuickActions extends PureComponent { static propTypes = { - blurTextBox: PropTypes.func.isRequired, canUploadFiles: PropTypes.bool, - canSend: PropTypes.bool, fileCount: PropTypes.number, - initialValue: PropTypes.string, inputEventType: PropTypes.string.isRequired, maxFileSize: PropTypes.number.isRequired, - onSend: PropTypes.func.isRequired, - onShowFileMaxWarning: PropTypes.func.isRequired, onTextChange: PropTypes.func.isRequired, - onUploadFiles: PropTypes.func.isRequired, - readonly: PropTypes.bool, theme: PropTypes.object.isRequired, }; static defaultProps = { canUploadFiles: true, fileCount: 0, - initialValue: '', }; constructor(props) { @@ -42,8 +33,8 @@ export default class QuickActions extends PureComponent { this.state = { inputValue: '', - atDisabled: props.readonly, - slashDisabled: props.readonly, + atDisabled: false, + slashDisabled: false, }; } @@ -51,76 +42,58 @@ export default class QuickActions extends PureComponent { EventEmitter.on(this.props.inputEventType, this.handleInputEvent); } - componentDidUpdate(prevProps) { - if (prevProps.readonly !== this.props.readonly || prevProps.initialValue !== this.props.initialValue) { - this.handleInputEvent(this.props.initialValue); - } - } - componentWillUnmount() { EventEmitter.off(this.props.inputEventType, this.handleInputEvent); } handleInputEvent = (inputValue) => { - const {readonly} = this.props; - const atDisabled = readonly || inputValue[inputValue.length - 1] === '@'; - const slashDisabled = readonly || inputValue.length > 0; + const atDisabled = inputValue[inputValue.length - 1] === '@'; + const slashDisabled = inputValue.length > 0; this.setState({atDisabled, slashDisabled, inputValue}); }; - onShowFileMaxWarning = () => { - EventEmitter.emit('fileMaxWarning'); - }; + handleOnTextChange = (newValue) => { + this.handleInputEvent(newValue); + this.props.onTextChange(newValue); + } + + handleUploadFiles(files) { + EventEmitter.emit(UPLOAD_FILES, files); + } render() { const { - blurTextBox, canUploadFiles, - canSend, fileCount, - onSend, - onTextChange, - onShowFileMaxWarning, - readonly, theme, - onUploadFiles, } = this.props; const uploadProps = { - blurTextBox, - disabled: !canUploadFiles || readonly, + disabled: !canUploadFiles, fileCount, maxFileCount: MAX_FILE_COUNT, - onShowFileMaxWarning, theme, - onUploadFiles, + onUploadFiles: this.handleUploadFiles, }; return ( - - - - - - - - - + + + + + ); } diff --git a/app/components/post_draft/read_only/__snapshots__/read_only.test.js.snap b/app/components/post_draft/read_only/__snapshots__/read_only.test.js.snap new file mode 100644 index 000000000..b13fde48e --- /dev/null +++ b/app/components/post_draft/read_only/__snapshots__/read_only.test.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PostDraft ReadOnly Should match snapshot 1`] = ` + + + + This channel is read-only. + + + +`; diff --git a/app/components/post_draft/read_only/index.tsx b/app/components/post_draft/read_only/index.tsx new file mode 100644 index 000000000..50d5630cf --- /dev/null +++ b/app/components/post_draft/read_only/index.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {ReactNode} from 'react'; +import {SafeAreaView, View} from 'react-native'; +import Icon from 'react-native-vector-icons/FontAwesome5'; + +import FormattedText from '@components/formatted_text'; +import type {Theme} from '@mm-redux/types/preferences'; +import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme'; + +interface ReadOnlyProps { + theme: Theme; +} + +const ReadOnlyChannnel = ({theme}: ReadOnlyProps): ReactNode => { + const style = getStyle(theme); + return ( + + + + + + + ); +}; + +const getStyle = makeStyleSheetFromTheme((theme: Theme) => ({ + background: { + backgroundColor: changeOpacity(theme.centerChannelColor, 0.04), + }, + container: { + alignItems: 'center', + borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), + borderTopWidth: 1, + flexDirection: 'row', + height: 50, + paddingHorizontal: 12, + }, + icon: { + fontSize: 20, + lineHeight: 22, + opacity: 0.56, + }, + text: { + color: theme.centerChannelColor, + fontSize: 15, + lineHeight: 20, + marginLeft: 9, + opacity: 0.56, + }, +})); + +export default ReadOnlyChannnel; \ No newline at end of file diff --git a/app/components/post_draft/read_only/read_only.test.js b/app/components/post_draft/read_only/read_only.test.js new file mode 100644 index 000000000..862fef6b6 --- /dev/null +++ b/app/components/post_draft/read_only/read_only.test.js @@ -0,0 +1,30 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import TestRenderer from 'react-test-renderer'; +import {IntlProvider} from 'react-intl'; + +import Preferences from '@mm-redux/constants/preferences'; + +import ReadOnly from './index'; + +describe('PostDraft ReadOnly', () => { + test('Should match snapshot', () => { + const wrapper = renderWithIntl( + , + ); + + expect(wrapper.toJSON()).toMatchSnapshot(); + }); +}); + +function renderWithIntl(component) { + return TestRenderer.create( + + {component} + , + ); +} \ No newline at end of file diff --git a/app/components/post_draft/quick_actions/send_action/__snapshots__/send.test.js.snap b/app/components/post_draft/send_action/__snapshots__/send.test.js.snap similarity index 100% rename from app/components/post_draft/quick_actions/send_action/__snapshots__/send.test.js.snap rename to app/components/post_draft/send_action/__snapshots__/send.test.js.snap diff --git a/app/components/post_draft/quick_actions/send_action/index.js b/app/components/post_draft/send_action/index.js similarity index 100% rename from app/components/post_draft/quick_actions/send_action/index.js rename to app/components/post_draft/send_action/index.js diff --git a/app/components/post_draft/quick_actions/send_action/paper_plane.js b/app/components/post_draft/send_action/paper_plane.js similarity index 100% rename from app/components/post_draft/quick_actions/send_action/paper_plane.js rename to app/components/post_draft/send_action/paper_plane.js diff --git a/app/components/post_draft/quick_actions/send_action/send.test.js b/app/components/post_draft/send_action/send.test.js similarity index 100% rename from app/components/post_draft/quick_actions/send_action/send.test.js rename to app/components/post_draft/send_action/send.test.js diff --git a/app/components/post_draft/uploads/index.js b/app/components/post_draft/uploads/index.js index 31b6294df..34a8c5acc 100644 --- a/app/components/post_draft/uploads/index.js +++ b/app/components/post_draft/uploads/index.js @@ -3,27 +3,32 @@ import {connect} from 'react-redux'; -import {handleRemoveLastFile} from '@actions/views/file_upload'; +import {handleRemoveLastFile, initUploadFiles} from '@actions/views/file_upload'; import {getCurrentChannelId} from '@mm-redux/selectors/entities/channels'; +import {getConfig} from '@mm-redux/selectors/entities/general'; import {getDimensions} from '@selectors/device'; import {checkForFileUploadingInChannel} from '@selectors/file'; +import {getAllowedServerMaxFileSize} from '@utils/file'; import Uploads from './uploads'; function mapStateToProps(state, ownProps) { const {deviceHeight} = getDimensions(state); const channelId = getCurrentChannelId(state); + const config = getConfig(state); return { channelId, channelIsLoading: state.views.channel.loading, deviceHeight, filesUploadingForCurrentChannel: checkForFileUploadingInChannel(state, channelId, ownProps.rootId), + maxFileSize: getAllowedServerMaxFileSize(config), }; } const mapDispatchToProps = { handleRemoveLastFile, + initUploadFiles, }; export default connect(mapStateToProps, mapDispatchToProps)(Uploads); diff --git a/app/components/post_draft/uploads/uploads.js b/app/components/post_draft/uploads/uploads.js index d2fc589fa..3023d6228 100644 --- a/app/components/post_draft/uploads/uploads.js +++ b/app/components/post_draft/uploads/uploads.js @@ -4,6 +4,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; import { + Alert, BackHandler, ScrollView, Text, @@ -11,10 +12,14 @@ import { Platform, } from 'react-native'; import * as Animatable from 'react-native-animatable'; - -import EventEmitter from '@mm-redux/utils/event_emitter'; +import {intlShape} from 'react-intl'; +import RNFetchBlob from 'rn-fetch-blob'; import FormattedText from '@components/formatted_text'; +import {MAX_FILE_COUNT, MAX_FILE_COUNT_WARNING, UPLOAD_FILES, PASTE_FILES} from '@constants/post_draft'; +import EventEmitter from '@mm-redux/utils/event_emitter'; +import {getFormattedFileSize} from '@mm-redux/utils/file_utils'; +import EphemeralStore from '@store/ephemeral_store'; import {makeStyleSheetFromTheme} from '@utils/theme'; import UploadItem from './upload_item'; @@ -30,7 +35,10 @@ export default class Uploads extends PureComponent { files: PropTypes.array.isRequired, filesUploadingForCurrentChannel: PropTypes.bool.isRequired, handleRemoveLastFile: PropTypes.func.isRequired, + initUploadFiles: PropTypes.func.isRequired, + maxFileSize: PropTypes.number.isRequired, rootId: PropTypes.string, + screenId: PropTypes.string, theme: PropTypes.object.isRequired, }; @@ -38,6 +46,10 @@ export default class Uploads extends PureComponent { files: [], }; + static contextTypes = { + intl: intlShape, + }; + state = { fileSizeWarning: null, showFileMaxWarning: false, @@ -48,8 +60,9 @@ export default class Uploads extends PureComponent { containerRef = React.createRef(); componentDidMount() { - EventEmitter.on('fileMaxWarning', this.handleFileMaxWarning); - EventEmitter.on('fileSizeWarning', this.handleFileSizeWarning); + EventEmitter.on(MAX_FILE_COUNT_WARNING, this.handleFileMaxWarning); + EventEmitter.on(UPLOAD_FILES, this.handleUploadFiles); + EventEmitter.on(PASTE_FILES, this.handlePasteFiles); if (this.props.files.length) { this.showOrHideContainer(); @@ -61,8 +74,9 @@ export default class Uploads extends PureComponent { } componentWillUnmount() { - EventEmitter.off('fileMaxWarning', this.handleFileMaxWarning); - EventEmitter.off('fileSizeWarning', this.handleFileSizeWarning); + EventEmitter.off(MAX_FILE_COUNT_WARNING, this.handleFileMaxWarning); + EventEmitter.off(UPLOAD_FILES, this.handleUploadFiles); + EventEmitter.off(PASTE_FILES, this.handlePasteFiles); if (Platform.OS === 'android') { BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); @@ -117,14 +131,75 @@ export default class Uploads extends PureComponent { } }; - handleFileSizeWarning = (message) => { + handleFileSizeWarning = () => { if (this.errorRef.current) { - if (message) { - this.setState({fileSizeWarning: message.replace(': ', ':\n')}); - this.makeErrorVisible(true, 40); - } else { + const {formatMessage} = this.context.intl; + const message = formatMessage({ + id: 'file_upload.fileAbove', + defaultMessage: 'Files must be less than {max}', + }, { + max: getFormattedFileSize({size: this.props.maxFileSize}), + }); + + this.setState({fileSizeWarning: message}); + this.makeErrorVisible(true, 20); + setTimeout(() => { this.makeErrorVisible(false, 20); + }, 5000); + } + }; + + handlePasteFiles = (error, files) => { + if (this.props.screenId === EphemeralStore.getNavigationTopComponentId()) { + if (error) { + this.showPasteFilesErrorDialog(); + return; } + + const {maxFileSize} = this.props; + const availableCount = MAX_FILE_COUNT - this.props.files.length; + if (files.length > availableCount) { + this.handleFileMaxWarning(); + return; + } + + const largeFile = files.find((image) => image.fileSize > maxFileSize); + if (largeFile) { + this.handleFileSizeWarning(); + return; + } + + this.handleUploadFiles(files); + } + }; + + handleUploadFiles = async (files) => { + let exceed = false; + + const totalFiles = files.length; + let i = 0; + while (i < totalFiles) { + const file = files[i]; + if (!file.fileSize | !file.fileName) { + const path = (file.path || file.uri).replace('file://', ''); + // eslint-disable-next-line no-await-in-loop + const fileInfo = await RNFetchBlob.fs.stat(path); + file.fileSize = fileInfo.size; + file.fileName = fileInfo.filename; + } + + if (file.fileSize > this.props.maxFileSize) { + exceed = true; + break; + } + + i++; + } + + if (exceed) { + this.handleFileSizeWarning(); + } else { + this.props.initUploadFiles(files, this.props.rootId); } }; @@ -154,6 +229,28 @@ export default class Uploads extends PureComponent { } } + showPasteFilesErrorDialog = () => { + const {formatMessage} = this.context.intl; + Alert.alert( + formatMessage({ + id: 'mobile.files_paste.error_title', + defaultMessage: 'Paste failed', + }), + formatMessage({ + id: 'mobile.files_paste.error_description', + defaultMessage: 'An error occurred while pasting the file(s). Please try again.', + }), + [ + { + text: formatMessage({ + id: 'mobile.files_paste.error_dismiss', + defaultMessage: 'Dismiss', + }), + }, + ], + ); + }; + render() { const {fileSizeWarning, showFileMaxWarning} = this.state; const {theme, files} = this.props; diff --git a/app/components/profile_picture_button.test.js b/app/components/profile_picture_button.test.js index 13b603572..b331f8a27 100644 --- a/app/components/profile_picture_button.test.js +++ b/app/components/profile_picture_button.test.js @@ -24,7 +24,6 @@ describe('profile_picture_button', () => { nickname: 'Dragon', position: 'position', }, - blurTextBox: jest.fn(), maxFileSize: 20 * 1024 * 1024, uploadFiles: jest.fn(), }; diff --git a/app/components/progressive_image/progressive_image.js b/app/components/progressive_image/progressive_image.js index 7f99a33d1..c27c72ef2 100644 --- a/app/components/progressive_image/progressive_image.js +++ b/app/components/progressive_image/progressive_image.js @@ -17,7 +17,7 @@ export default class ProgressiveImage extends PureComponent { static propTypes = { isBackgroundImage: PropTypes.bool, children: CustomPropTypes.Children, - defaultSource: PropTypes.oneOfType([PropTypes.object, PropTypes.number]), // this should be provided by the component + defaultSource: PropTypes.oneOfType([PropTypes.string, PropTypes.object, PropTypes.number]), // this should be provided by the component imageUri: PropTypes.string, imageStyle: CustomPropTypes.Style, onError: PropTypes.func, diff --git a/app/components/progressive_image/progressive_image.test.js b/app/components/progressive_image/progressive_image.test.js index 701c304b4..5587485f2 100644 --- a/app/components/progressive_image/progressive_image.test.js +++ b/app/components/progressive_image/progressive_image.test.js @@ -20,7 +20,7 @@ describe('ProgressiveImage', () => { resizeMode: 'contain', theme: Preferences.THEMES.default, tintDefaultSource: false, - defaultSource: null, + defaultSource: undefined, }; const wrapper = shallow(); diff --git a/app/constants/post_draft.js b/app/constants/post_draft.js index e8ecfc948..855dc5044 100644 --- a/app/constants/post_draft.js +++ b/app/constants/post_draft.js @@ -12,3 +12,7 @@ export const MAX_FILE_COUNT = 5; export const MAX_MESSAGE_LENGTH_FALLBACK = 4000; export const TYPING_VISIBLE = 'typingVisible'; export const TYPING_HEIGHT = 18; +export const MAX_FILE_COUNT_WARNING = 'onMaxFileCountWarning'; +export const UPLOAD_FILES = 'onUploadFiles'; +export const PASTE_FILES = 'onPasteFiles'; +export const UPDATE_NATIVE_SCROLLVIEW = 'onUpdateNativeScrollView'; diff --git a/app/mm-redux/selectors/entities/channels.ts b/app/mm-redux/selectors/entities/channels.ts index a09fa820e..c3ced7863 100644 --- a/app/mm-redux/selectors/entities/channels.ts +++ b/app/mm-redux/selectors/entities/channels.ts @@ -147,6 +147,9 @@ export const getMyChannelMember: (b: GlobalState, a: string) => ChannelMembershi export const getCurrentChannelStats: (a: GlobalState) => ChannelStats = createSelector(getAllChannelStats, getCurrentChannelId, (allChannelStats: RelationOneToOne, currentChannelId: string): ChannelStats => { return allChannelStats[currentChannelId]; }); +export const getChannelStats: (a: GlobalState, b: string) => ChannelStats = createSelector(getAllChannelStats, (state: GlobalState, channelId: string): string => channelId, (allChannelStats: RelationOneToOne, channelId: string): ChannelStats => { + return allChannelStats[channelId]; +}); export const isCurrentChannelFavorite: (a: GlobalState) => boolean = createSelector(getMyPreferences, getCurrentChannelId, (preferences: { [x: string]: PreferenceType; }, channelId: string): boolean => isFavoriteChannel(preferences, channelId)); diff --git a/app/screens/channel/channel.ios.js b/app/screens/channel/channel.ios.js index d71ce39a5..274453f6a 100644 --- a/app/screens/channel/channel.ios.js +++ b/app/screens/channel/channel.ios.js @@ -3,7 +3,6 @@ import React from 'react'; import {View} from 'react-native'; -import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; import LocalConfig from '@assets/config'; import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from '@components/autocomplete'; @@ -93,19 +92,15 @@ export default class ChannelIOS extends ChannelBase { {component} {renderDraftArea && - - } ); diff --git a/app/screens/channel/channel_base.js b/app/screens/channel/channel_base.js index 5bb2f21f4..46336b5e2 100644 --- a/app/screens/channel/channel_base.js +++ b/app/screens/channel/channel_base.js @@ -9,8 +9,7 @@ import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import {showModal, showModalOverCurrentContext} from '@actions/navigation'; import LocalConfig from '@assets/config'; -import {NavigationTypes} from '@constants'; -import {TYPING_VISIBLE} from '@constants/post_draft'; +import {UPDATE_NATIVE_SCROLLVIEW, TYPING_VISIBLE} from '@constants/post_draft'; import EventEmitter from '@mm-redux/utils/event_emitter'; import EphemeralStore from '@store/ephemeral_store'; import {unsupportedServer} from '@utils/supported_server'; @@ -56,7 +55,6 @@ export default class ChannelBase extends PureComponent { super(props); this.postDraft = React.createRef(); - this.keyboardTracker = React.createRef(); this.state = { channelsRequestFailed: false, @@ -80,7 +78,7 @@ export default class ChannelBase extends PureComponent { showTermsOfService, skipMetrics, } = this.props; - EventEmitter.on(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft); + EventEmitter.on('leave_team', this.handleLeaveTeam); EventEmitter.on(TYPING_VISIBLE, this.runTypingAnimations); @@ -151,7 +149,6 @@ export default class ChannelBase extends PureComponent { } componentWillUnmount() { - EventEmitter.off(NavigationTypes.BLUR_POST_DRAFT, this.blurPostDraft); EventEmitter.off('leave_team', this.handleLeaveTeam); EventEmitter.off(TYPING_VISIBLE, this.runTypingAnimations); } @@ -172,12 +169,6 @@ export default class ChannelBase extends PureComponent { ).start(); } - blurPostDraft = () => { - if (this.postDraft?.current) { - this.postDraft.current.blurTextBox(); - } - }; - goToChannelInfo = preventDoubleTap(() => { const {intl} = this.context; const {theme} = this.props; @@ -296,9 +287,7 @@ export default class ChannelBase extends PureComponent { }; updateNativeScrollView = () => { - if (this.keyboardTracker?.current) { - this.keyboardTracker.current.resetScrollView(this.props.currentChannelId); - } + EventEmitter.emit(UPDATE_NATIVE_SCROLLVIEW, this.props.currentChannelId); }; render() { diff --git a/app/screens/edit_profile/edit_profile.js b/app/screens/edit_profile/edit_profile.js index 0a568e885..0bb5647ec 100644 --- a/app/screens/edit_profile/edit_profile.js +++ b/app/screens/edit_profile/edit_profile.js @@ -14,7 +14,6 @@ import {Client4} from '@mm-redux/client'; import {getFormattedFileSize} from '@mm-redux/utils/file_utils'; import {buildFileUploadData, encodeHeaderURIStringToUTF8} from 'app/utils/file'; -import {emptyFunction} from 'app/utils/general'; import {preventDoubleTap} from 'app/utils/tap'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; import {t} from 'app/utils/i18n'; @@ -294,14 +293,13 @@ export default class EditProfile extends PureComponent { }); }; - onShowFileSizeWarning = (filename) => { + onShowFileSizeWarning = () => { const {formatMessage} = this.context.intl; const fileSizeWarning = formatMessage({ id: 'file_upload.fileAbove', - defaultMessage: 'File above {max}MB cannot be uploaded: {filename}', + defaultMessage: 'Files must be less than {max}', }, { max: getFormattedFileSize({size: MAX_SIZE}), - filename, }); Alert.alert(fileSizeWarning); @@ -541,7 +539,6 @@ export default class EditProfile extends PureComponent { - - - + valueEvent="onThreadTextBoxValueChange" + /> `; diff --git a/app/screens/thread/thread.ios.js b/app/screens/thread/thread.ios.js index 2d8dbf708..82b497487 100644 --- a/app/screens/thread/thread.ios.js +++ b/app/screens/thread/thread.ios.js @@ -3,7 +3,6 @@ import React from 'react'; import {Animated, View} from 'react-native'; -import {KeyboardTrackingView} from 'react-native-keyboard-tracking-view'; import Autocomplete, {AUTOCOMPLETE_MAX_HEIGHT} from '@components/autocomplete'; import Loading from '@components/loading'; @@ -71,21 +70,18 @@ export default class ThreadIOS extends ThreadBase { ); postDraft = ( - - - + channelId={channelId} + channelIsArchived={channelIsArchived} + cursorPositionEvent={THREAD_POST_TEXTBOX_CURSOR_CHANGE} + ref={this.postDraft} + rootId={rootId} + screenId={this.props.componentId} + scrollViewNativeID={SCROLLVIEW_NATIVE_ID} + valueEvent={THREAD_POST_TEXTBOX_VALUE_CHANGE} + registerTypingAnimation={this.registerTypingAnimation} + /> ); } else { content = ( diff --git a/app/selectors/file.js b/app/selectors/file.js index febcb2f24..45c83020b 100644 --- a/app/selectors/file.js +++ b/app/selectors/file.js @@ -3,19 +3,22 @@ import {createSelector} from 'reselect'; -export const checkForFileUploadingInChannel = createSelector( - (state, channelId, rootId) => { - if (rootId) { - return state.views.thread.drafts[rootId]; - } +import {selectDraft} from './views'; - return state.views.channel.drafts[channelId]; - }, +export const selectFilesFromDraft = createSelector( + selectDraft, (draft) => { if (!draft || !draft.files) { - return false; + return []; } - return draft.files.some((f) => f.loading); + return draft.files; + }, +); + +export const checkForFileUploadingInChannel = createSelector( + selectFilesFromDraft, + (files) => { + return files.some((f) => f.loading); }, ); diff --git a/app/selectors/views.js b/app/selectors/views.js index ae398d6a0..7980cbb57 100644 --- a/app/selectors/views.js +++ b/app/selectors/views.js @@ -10,6 +10,14 @@ const emptyDraft = { files: [], }; +export function selectDraft(state, channelId, rootId) { + if (rootId) { + return state.views.thread.drafts[rootId]; + } + + return state.views.channel.drafts[channelId]; +} + function getChannelDrafts(state) { return state.views.channel.drafts; } diff --git a/app/utils/draft.js b/app/utils/draft.js new file mode 100644 index 000000000..1d26f9025 --- /dev/null +++ b/app/utils/draft.js @@ -0,0 +1,238 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Alert} from 'react-native'; + +import {AT_MENTION_REGEX_GLOBAL, CODE_REGEX} from '@constants/autocomplete'; +import {NOTIFY_ALL_MEMBERS} from '@constants/view'; +import {General} from '@mm-redux/constants'; + +export function alertAttachmentFail(formatMessage, accept, cancel) { + Alert.alert( + formatMessage({ + id: 'mobile.post_textbox.uploadFailedTitle', + defaultMessage: 'Attachment failure', + }), + formatMessage({ + id: 'mobile.post_textbox.uploadFailedDesc', + defaultMessage: 'Some attachments failed to upload to the server. Are you sure you want to post the message?', + }), + [{ + text: formatMessage({id: 'mobile.channel_info.alertNo', defaultMessage: 'No'}), + onPress: cancel, + }, { + text: formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}), + onPress: accept, + }], + ); +} + +export function alertChannelWideMention(formatMessage, notifyAllMessage, accept, cancel) { + Alert.alert( + formatMessage({ + id: 'mobile.post_textbox.entire_channel.title', + defaultMessage: 'Confirm sending notifications to entire channel', + }), + notifyAllMessage, + [ + { + text: formatMessage({ + id: 'mobile.post_textbox.entire_channel.cancel', + defaultMessage: 'Cancel', + }), + onPress: cancel, + }, + { + text: formatMessage({ + id: 'mobile.post_textbox.entire_channel.confirm', + defaultMessage: 'Confirm', + }), + onPress: accept, + }, + ], + ); +} + +export function alertSendToGroups(formatMessage, notifyAllMessage, accept, cancel) { + Alert.alert( + formatMessage({ + id: 'mobile.post_textbox.groups.title', + defaultMessage: 'Confirm sending notifications to groups', + }), + notifyAllMessage, + [ + { + text: formatMessage({ + id: 'mobile.post_textbox.entire_channel.cancel', + defaultMessage: 'Cancel', + }), + onPress: cancel, + }, + { + text: formatMessage({ + id: 'mobile.post_textbox.entire_channel.confirm', + defaultMessage: 'Confirm', + }), + onPress: accept, + }, + ], + ); +} + +export function alertSlashCommandFailed(formatMessage, error) { + Alert.alert( + formatMessage({ + id: 'mobile.commands.error_title', + defaultMessage: 'Error Executing Command', + }), + error, + ); +} + +export function buildChannelWideMentionMessage(formatMessage, membersCount, isTimezoneEnabled, channelTimezoneCount) { + let notifyAllMessage = ''; + if (isTimezoneEnabled && channelTimezoneCount) { + notifyAllMessage = ( + formatMessage( + { + id: 'mobile.post_textbox.entire_channel.message.with_timezones', + defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}} in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', + }, + { + totalMembers: membersCount - 1, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + notifyAllMessage = ( + formatMessage( + { + id: 'mobile.post_textbox.entire_channel.message', + defaultMessage: 'By using @all or @channel you are about to send notifications to {totalMembers, number} {totalMembers, plural, one {person} other {people}}. Are you sure you want to do this?', + }, + { + totalMembers: membersCount - 1, + }, + ) + ); + } + + return notifyAllMessage; +} + +export function buildGroupMentionsMessage(formatMessage, groupMentions, memberNotifyCount, channelTimezoneCount) { + let notifyAllMessage = ''; + + if (groupMentions.length === 1) { + if (channelTimezoneCount > 0) { + notifyAllMessage = ( + formatMessage( + { + id: 'mobile.post_textbox.one_group.message.with_timezones', + defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', + }, + { + mention: groupMentions[0], + totalMembers: memberNotifyCount, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + notifyAllMessage = ( + formatMessage( + { + id: 'mobile.post_textbox.one_group.message.without_timezones', + defaultMessage: 'By using {mention} you are about to send notifications to {totalMembers} people. Are you sure you want to do this?', + }, + { + mention: groupMentions[0], + totalMembers: memberNotifyCount, + }, + ) + ); + } + } else if (channelTimezoneCount > 0) { + notifyAllMessage = ( + formatMessage( + { + id: 'mobile.post_textbox.multi_group.message.with_timezones', + defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people in {timezones, number} {timezones, plural, one {timezone} other {timezones}}. Are you sure you want to do this?', + }, + { + mentions: groupMentions.slice(0, -1).join(', '), + finalMention: groupMentions[groupMentions.length - 1], + totalMembers: memberNotifyCount, + timezones: channelTimezoneCount, + }, + ) + ); + } else { + notifyAllMessage = ( + formatMessage( + { + id: 'mobile.post_textbox.multi_group.message.without_timezones', + defaultMessage: 'By using {mentions} and {finalMention} you are about to send notifications to at least {totalMembers} people. Are you sure you want to do this?', + }, + { + mentions: groupMentions.slice(0, -1).join(', '), + finalMention: groupMentions[groupMentions.length - 1], + totalMembers: memberNotifyCount, + }, + ) + ); + } + + return notifyAllMessage; +} + +export const getStatusFromSlashCommand = (message) => { + const tokens = message.split(' '); + + if (tokens.length > 0) { + return tokens[0].substring(1); + } + return ''; +}; + +export const groupsMentionedInText = (groupsWithAllowReference, text) => { + const groups = []; + if (groupsWithAllowReference.size > 0) { + const textWithoutCode = text.replace(CODE_REGEX, ''); + const mentions = textWithoutCode.match(AT_MENTION_REGEX_GLOBAL) || []; + mentions.forEach((mention) => { + const group = groupsWithAllowReference.get(mention); + if (group) { + groups.push(group); + } + }); + } + return groups; +}; + +export const isStatusSlashCommand = (command) => { + return command === General.ONLINE || command === General.AWAY || + command === General.DND || command === General.OFFLINE; +}; + +export const mapGroupMentions = (channelMemberCountsByGroup, groupMentions) => { + let memberNotifyCount = 0; + let channelTimezoneCount = 0; + const groupMentionsSet = new Set(); + groupMentions. + forEach((group) => { + const mappedValue = channelMemberCountsByGroup[group.id]; + if (mappedValue?.channel_member_count > NOTIFY_ALL_MEMBERS && mappedValue?.channel_member_count > memberNotifyCount) { + memberNotifyCount = mappedValue.channel_member_count; + channelTimezoneCount = mappedValue.channel_member_timezones_count; + } + groupMentionsSet.add(`@${group.name}`); + }); + return {groupMentionsSet, memberNotifyCount, channelTimezoneCount}; +}; + +export const textContainsAtAllAtChannel = (text) => { + const textWithoutCode = text.replace(CODE_REGEX, ''); + return (/(?:\B|\b_+)@(channel|all)(?!(\.|-|_)*[^\W_])/i).test(textWithoutCode); +}; diff --git a/app/utils/draft.test.js b/app/utils/draft.test.js new file mode 100644 index 000000000..5a22171e5 --- /dev/null +++ b/app/utils/draft.test.js @@ -0,0 +1,160 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import assert from 'assert'; + +import * as DraftUtils from './draft'; + +describe('DraftUtils', () => { + test('should return correct @all (same for @channel)', () => { + for (const data of [ + { + text: '', + result: false, + }, + { + text: 'all', + result: false, + }, + { + text: '@allison', + result: false, + }, + { + text: '@ALLISON', + result: false, + }, + { + text: '@all123', + result: false, + }, + { + text: '123@all', + result: false, + }, + { + text: 'hey@all', + result: false, + }, + { + text: 'hey@all.com', + result: false, + }, + { + text: '@all', + result: true, + }, + { + text: '@ALL', + result: true, + }, + { + text: '@all hey', + result: true, + }, + { + text: 'hey @all', + result: true, + }, + { + text: 'HEY @ALL', + result: true, + }, + { + text: 'hey @all!', + result: true, + }, + { + text: 'hey @all:+1:', + result: true, + }, + { + text: 'hey @ALL:+1:', + result: true, + }, + { + text: '`@all`', + result: false, + }, + { + text: '@someone `@all`', + result: false, + }, + { + text: '``@all``', + result: false, + }, + { + text: '```@all```', + result: false, + }, + { + text: '```\n@all\n```', + result: false, + }, + { + text: '```````\n@all\n```````', + result: false, + }, + { + text: '```code\n@all\n```', + result: false, + }, + { + text: '~~~@all~~~', + result: true, + }, + { + text: '~~~\n@all\n~~~', + result: false, + }, + { + text: ' /not_cmd @all', + result: true, + }, + { + text: '@channel', + result: true, + }, + { + text: '@channel.', + result: true, + }, + { + text: '@channel/test', + result: true, + }, + { + text: 'test/@channel', + result: true, + }, + { + text: '@all/@channel', + result: true, + }, + { + text: '@cha*nnel*', + result: false, + }, + { + text: '@cha**nnel**', + result: false, + }, + { + text: '*@cha*nnel', + result: false, + }, + { + text: '[@chan](https://google.com)nel', + result: false, + }, + { + text: '@cha![](https://myimage)nnel', + result: false, + }, + ]) { + const containsAtChannel = DraftUtils.textContainsAtAllAtChannel(data.text); + assert.equal(containsAtChannel, data.result, data.text); + } + }); +}); diff --git a/assets/base/i18n/en.json b/assets/base/i18n/en.json index 2bcc8e904..6d6523130 100644 --- a/assets/base/i18n/en.json +++ b/assets/base/i18n/en.json @@ -79,7 +79,7 @@ "edit_post.editPost": "Edit the post...", "edit_post.save": "Save", "file_attachment.download": "Download", - "file_upload.fileAbove": "File above {max} cannot be uploaded: {filename}", + "file_upload.fileAbove": "Files must be less than {max}", "get_post_link_modal.title": "Copy Link", "integrations.add": "Add", "intro_messages.anyMember": " Any member can join and read this channel.", diff --git a/share_extension/android/extension_post/extension_post.js b/share_extension/android/extension_post/extension_post.js index 92d4e34f9..daa87ebc1 100644 --- a/share_extension/android/extension_post/extension_post.js +++ b/share_extension/android/extension_post/extension_post.js @@ -27,7 +27,7 @@ import {Preferences} from '@mm-redux/constants'; import {getFormattedFileSize, lookupMimeType} from '@mm-redux/utils/file_utils'; import Loading from '@components/loading'; -import PaperPlane from '@components/post_draft/quick_actions/send_action/paper_plane'; +import PaperPlane from '@components/post_draft/send_action/paper_plane'; import {MAX_FILE_COUNT, MAX_MESSAGE_LENGTH_FALLBACK} from '@constants/post_draft'; import {getCurrentServerUrl, getAppCredentials} from '@init/credentials'; import {getExtensionFromMime} from '@utils/file'; diff --git a/test/setup.js b/test/setup.js index f13b31e1c..369bdbe1d 100644 --- a/test/setup.js +++ b/test/setup.js @@ -110,6 +110,9 @@ jest.doMock('react-native', () => { }, ReactNative); }); +jest.mock('react-native-vector-icons/MaterialCommunityIcons'); +jest.mock('react-native-vector-icons/FontAwesome5'); + jest.mock('react-native/Libraries/Animated/src/NativeAnimatedHelper'); jest.mock('../node_modules/react-native/Libraries/EventEmitter/NativeEventEmitter'); diff --git a/tsconfig.json b/tsconfig.json index 3a4cd9a29..7fcb8f3a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -34,6 +34,7 @@ "paths": { "@actions/*": ["app/actions/*"], "@assets/*": ["dist/assets/*"], + "@components/*": ["app/components/*"], "@constants/*": ["app/constants/*"], "@constants": ["app/constants/index"], "@i18n": ["app/i18n/index"],