// 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 { Alert, AppState, BackHandler, findNodeHandle, Keyboard, NativeModules, Platform, Text, TouchableOpacity, ScrollView, View, Image, } from 'react-native'; import {intlShape} from 'react-intl'; import RNFetchBlob from 'rn-fetch-blob'; import Button from 'react-native-button'; import HWKeyboardEvent from 'react-native-hw-keyboard-event'; import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import slashForwardBoxIcon from 'assets/images/icons/slash-forward-box.png'; import {General, RequestStatus} from '@mm-redux/constants'; import EventEmitter from '@mm-redux/utils/event_emitter'; import {getFormattedFileSize} from '@mm-redux/utils/file_utils'; import FileUploadButton from './components/file_upload_button'; import ImageUploadButton from './components/image_upload_button'; import CameraButton from './components/camera_button'; import FormattedMarkdownText from 'app/components/formatted_markdown_text'; import FormattedText from 'app/components/formatted_text'; import PasteableTextInput from 'app/components/pasteable_text_input'; import {paddingHorizontal as padding} from 'app/components/safe_area_view/iphone_x_spacing'; import SendButton from 'app/components/send_button'; import {INSERT_TO_COMMENT, INSERT_TO_DRAFT, IS_REACTION_REGEX, MAX_FILE_COUNT, ICON_SIZE} from 'app/constants/post_textbox'; import {NOTIFY_ALL_MEMBERS} from 'app/constants/view'; import FileUploadPreview from 'app/components/file_upload_preview'; import EphemeralStore from 'app/store/ephemeral_store'; import {t} from 'app/utils/i18n'; import {confirmOutOfOfficeDisabled} from 'app/utils/status'; import {switchKeyboardForCodeBlocks} from 'app/utils/markdown'; import { changeOpacity, makeStyleSheetFromTheme, getKeyboardAppearanceFromTheme, } from 'app/utils/theme'; const {RNTextInputReset} = NativeModules; const INPUT_LINE_HEIGHT = 20; const EXTRA_INPUT_PADDING = 3; const HW_SHIFT_ENTER_TEXT = Platform.OS === 'ios' ? '\n' : ''; export default class PostTextBoxBase extends PureComponent { static propTypes = { actions: PropTypes.shape({ addReactionToLatestPost: PropTypes.func.isRequired, createPost: PropTypes.func.isRequired, executeCommand: PropTypes.func.isRequired, handleCommentDraftChanged: PropTypes.func.isRequired, handlePostDraftChanged: PropTypes.func.isRequired, handleClearFiles: PropTypes.func.isRequired, handleClearFailedFiles: PropTypes.func.isRequired, handleRemoveLastFile: PropTypes.func.isRequired, initUploadFiles: PropTypes.func.isRequired, userTyping: PropTypes.func.isRequired, handleCommentDraftSelectionChanged: PropTypes.func.isRequired, setStatus: PropTypes.func.isRequired, selectPenultimateChannel: PropTypes.func.isRequired, getChannelTimezones: PropTypes.func.isRequired, }).isRequired, canUploadFiles: PropTypes.bool.isRequired, channelId: PropTypes.string.isRequired, channelDisplayName: PropTypes.string, channelTeamId: PropTypes.string.isRequired, channelIsReadOnly: PropTypes.bool.isRequired, currentUserId: PropTypes.string.isRequired, deactivatedChannel: PropTypes.bool.isRequired, files: PropTypes.array, maxFileSize: PropTypes.number.isRequired, maxMessageLength: PropTypes.number.isRequired, rootId: PropTypes.string, theme: PropTypes.object.isRequired, uploadFileRequestStatus: PropTypes.string.isRequired, value: PropTypes.string.isRequired, userIsOutOfOffice: PropTypes.bool.isRequired, channelIsArchived: PropTypes.bool, onCloseChannel: PropTypes.func, cursorPositionEvent: PropTypes.string, valueEvent: PropTypes.string, currentChannelMembersCount: PropTypes.number, enableConfirmNotificationsToChannel: PropTypes.bool, isTimezoneEnabled: PropTypes.bool, currentChannel: PropTypes.object, isLandscape: PropTypes.bool.isRequired, screenId: PropTypes.string.isRequired, canPost: PropTypes.bool.isRequired, useChannelMentions: PropTypes.bool.isRequired, }; static defaultProps = { files: [], rootId: '', value: '', canPost: true, }; static contextTypes = { intl: intlShape, }; constructor(props) { super(props); this.input = React.createRef(); this.state = { cursorPosition: 0, keyboardType: 'default', top: 0, value: props.value, rootId: props.rootId, channelId: props.channelId, channelTimezoneCount: 0, longMessageAlertShown: false, extraInputPadding: 0, }; } componentDidMount(prevProps) { const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; EventEmitter.on(event, this.handleInsertTextToDraft); AppState.addEventListener('change', this.handleAppStateChange); HWKeyboardEvent.onHWKeyPressed(this.handleHardwareEnterPress); if (Platform.OS === 'android') { Keyboard.addListener('keyboardDidHide', this.handleAndroidKeyboard); BackHandler.addEventListener('hardwareBackPress', this.handleAndroidBack); } if (this.props.isTimezoneEnabled !== prevProps?.isTimezoneEnabled || prevProps?.channelId !== this.props.channelId) { this.numberOfTimezones().then((channelTimezoneCount) => this.setState({channelTimezoneCount})); } } static getDerivedStateFromProps(nextProps, state) { if (nextProps.channelId !== state.channelId || nextProps.rootId !== state.rootId) { return { value: nextProps.value, channelId: nextProps.channelId, rootId: nextProps.rootId, }; } return null; } componentWillUnmount() { const event = this.props.rootId ? INSERT_TO_COMMENT : INSERT_TO_DRAFT; EventEmitter.off(event, this.handleInsertTextToDraft); AppState.removeEventListener('change', this.handleAppStateChange); HWKeyboardEvent.removeOnHWKeyPressed(); if (Platform.OS === 'android') { Keyboard.removeListener('keyboardDidHide', this.handleAndroidKeyboard); BackHandler.removeEventListener('hardwareBackPress', this.handleAndroidBack); } } blur = () => { if (this.input.current) { this.input.current.blur(); } }; focus = () => { if (this.input.current) { this.input.current.focus(); } } numberOfTimezones = async () => { const {data} = await this.props.actions.getChannelTimezones(this.props.channelId); return data?.length || 0; }; canSend = () => { const {files, maxMessageLength, uploadFileRequestStatus} = this.props; const {value} = this.state; const messageLength = value.trim().length; if (messageLength > maxMessageLength) { return false; } if (files.length) { const loadingComplete = !this.isFileLoading(); const alreadySendingFiles = uploadFileRequestStatus === RequestStatus.STARTED; return loadingComplete && !alreadySendingFiles; } return messageLength > 0; }; changeDraft = (text) => { const { actions, channelId, rootId, } = this.props; if (rootId) { actions.handleCommentDraftChanged(rootId, text); } else { actions.handlePostDraftChanged(channelId, text); } }; checkMessageLength = (value) => { const {intl} = this.context; const {maxMessageLength} = this.props; const valueLength = value.trim().length; if (valueLength > maxMessageLength) { // Check if component is already aware message is too long if (!this.state.longMessageAlertShown) { Alert.alert( intl.formatMessage({ id: 'mobile.message_length.title', defaultMessage: 'Message Length', }), intl.formatMessage({ id: 'mobile.message_length.message', defaultMessage: 'Your current message is too long. Current character count: {count}/{max}', }, { max: maxMessageLength, count: valueLength, }), ); this.setState({longMessageAlertShown: true}); } } else if (this.state.longMessageAlertShown) { this.setState({longMessageAlertShown: false}); } }; startAtMention = () => { this.handleTextChange(`${this.state.value}@`, true); this.focus(); }; startSlashCommand = () => { this.handleTextChange('/', true); this.focus(); }; getTextInputButton = (actionType) => { const {channelIsReadOnly, theme, canPost} = this.props; const style = getStyleSheet(theme); let button = null; const buttonStyle = []; let iconColor = changeOpacity(theme.centerChannelColor, 0.64); let isDisabled = false; if (!channelIsReadOnly && canPost) { switch (actionType) { case 'at': isDisabled = this.state.value[this.state.value.length - 1] === '@'; if (isDisabled) { iconColor = changeOpacity(theme.centerChannelColor, 0.16); } button = ( ); break; case 'slash': isDisabled = this.state.value.length > 0; buttonStyle.push(style.slashIcon); if (isDisabled) { buttonStyle.push(style.iconDisabled); } button = ( ); break; } } return button; } getMediaButton = (actionType) => { const {canUploadFiles, channelIsReadOnly, files, maxFileSize, theme, canPost} = this.props; const style = getStyleSheet(theme); let button = null; const props = { blurTextBox: this.blur, fileCount: files.length, maxFileCount: MAX_FILE_COUNT, onShowFileMaxWarning: this.onShowFileMaxWarning, onShowFileSizeWarning: this.onShowFileSizeWarning, uploadFiles: this.handleUploadFiles, maxFileSize, theme, buttonContainerStyle: style.iconWrapper, }; if (canUploadFiles && !channelIsReadOnly && canPost) { switch (actionType) { case 'file': button = ( ); break; case 'image': button = ( ); break; case 'camera': button = ( ); } } return button; } getInputContainerStyle = () => { const {channelIsReadOnly, theme, canPost} = this.props; const style = getStyleSheet(theme); const inputContainerStyle = [style.inputContainer]; if (channelIsReadOnly || !canPost) { inputContainerStyle.push(style.readonlyContainer); } return inputContainerStyle; }; getPlaceHolder = () => { const {channelIsReadOnly, rootId, canPost} = this.props; let placeholder; if (channelIsReadOnly || !canPost) { placeholder = {id: t('mobile.create_post.read_only'), defaultMessage: 'This channel is read-only.'}; } else if (rootId) { placeholder = {id: t('create_comment.addComment'), defaultMessage: 'Add a comment...'}; } else { placeholder = {id: t('create_post.write'), defaultMessage: 'Write to {channelDisplayName}'}; } return placeholder; }; handleAndroidKeyboard = () => { this.blur(); }; handleHardwareEnterPress = (keyEvent) => { switch (keyEvent.pressedKey) { case 'enter': this.handleSendMessage(); break; case 'shift-enter': this.handleInsertTextToDraft(HW_SHIFT_ENTER_TEXT); } } handleAndroidBack = () => { const {channelId, files, rootId} = this.props; if (files.length) { this.props.actions.handleRemoveLastFile(channelId, rootId); return true; } return false; }; handleAppStateChange = (nextAppState) => { if (nextAppState !== 'active') { this.changeDraft(this.state.value); } }; handleEndEditing = (e) => { if (e && e.nativeEvent) { this.changeDraft(e.nativeEvent.text || ''); } }; handleOnSelectionChange = (event) => { this.handlePostDraftSelectionChanged(event, false); }; handlePostDraftSelectionChanged = (event, fromHandleTextChange) => { const cursorPosition = fromHandleTextChange ? this.state.cursorPosition : event.nativeEvent.selection.end; const {cursorPositionEvent} = this.props; if (cursorPositionEvent) { EventEmitter.emit(cursorPositionEvent, cursorPosition); } if (Platform.OS === 'ios') { const keyboardType = switchKeyboardForCodeBlocks(this.state.value, cursorPosition); this.setState({cursorPosition, keyboardType}); } else { this.setState({cursorPosition}); } }; handleSendMessage = () => { if (!this.isSendButtonEnabled()) { return; } this.setState({sendingMessage: true}); const {actions, channelId, files, rootId} = this.props; const {value} = this.state; 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.setState({sendingMessage: false}); }, }, { text: intl.formatMessage({id: 'mobile.channel_info.alertYes', defaultMessage: 'Yes'}), onPress: () => { // Remove only failed files actions.handleClearFailedFiles(channelId, rootId); this.sendMessage(); }, }], ); } else { this.sendMessage(); } }; handleInsertTextToDraft = (text) => { const {cursorPosition, value} = this.state; let completed; if (value.length === 0) { completed = text; } else { const firstPart = value.substring(0, cursorPosition); const secondPart = value.substring(cursorPosition); completed = `${firstPart}${text}${secondPart}`; } this.setState({ value: completed, }); }; handleTextChange = (value, autocomplete = false) => { const { actions, channelId, rootId, cursorPositionEvent, valueEvent, } = this.props; if (valueEvent) { EventEmitter.emit(valueEvent, value); } const nextState = {value}; // Workaround for some Android keyboards that don't play well with cursors (e.g. Samsung keyboards) if (autocomplete && this.input?.current) { if (Platform.OS === 'android') { RNTextInputReset.resetKeyboardInput(findNodeHandle(this.input.current)); } else { nextState.cursorPosition = value.length; if (cursorPositionEvent) { EventEmitter.emit(cursorPositionEvent, nextState.cursorPosition); } } } this.checkMessageLength(value); // Workaround to avoid iOS emdash autocorrect in Code Blocks if (Platform.OS === 'ios') { const callback = () => this.handlePostDraftSelectionChanged(null, true); this.setState(nextState, callback); } else { this.setState(nextState); } if (value) { actions.userTyping(channelId, rootId); } }; 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.actions.initUploadFiles(files, this.props.rootId); } }; isFileLoading = () => { const {files} = this.props; return files.some((file) => file.loading); }; isSendButtonVisible = () => { return this.canSend() || this.isFileLoading(); }; isSendButtonEnabled = () => { return this.canSend() && !this.isFileLoading() && !this.state.sendingMessage; }; sendMessage = () => { const {value} = this.state; const currentMembersCount = this.props.currentChannelMembersCount; const notificationsToChannel = this.props.enableConfirmNotificationsToChannel && this.props.useChannelMentions; const toAllOrChannel = this.textContainsAtAllAtChannel(value); if (value.indexOf('/') === 0) { this.sendCommand(value); } else if (notificationsToChannel && currentMembersCount > NOTIFY_ALL_MEMBERS && toAllOrChannel) { this.showSendToAllOrChannelAlert(currentMembersCount); } else { this.doSubmitMessage(); } }; textContainsAtAllAtChannel = (text) => { const textWithoutCode = text.replace(/(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)| *(`{3,}|~{3,})[ .]*(\S+)? *\n([\s\S]*?\s*)\3 *(?:\n+|$)/g, ''); return (/\B@(all|channel)\b/i).test(textWithoutCode); }; showSendToAllOrChannelAlert = (currentMembersCount) => { 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: currentMembersCount - 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: currentMembersCount - 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.setState({sendingMessage: false}); }, }, { text: intl.formatMessage({ id: 'mobile.post_textbox.entire_channel.confirm', defaultMessage: 'Confirm', }), onPress: () => this.doSubmitMessage(), }, ], ); }; doSubmitMessage = () => { const {actions, currentUserId, channelId, files, rootId} = this.props; const {value} = this.state; const postFiles = files.filter((f) => !f.failed); const post = { user_id: currentUserId, channel_id: channelId, root_id: rootId, parent_id: rootId, message: value, }; actions.createPost(post, postFiles); if (postFiles.length) { actions.handleClearFiles(channelId, rootId); } if (Platform.OS === 'ios') { // On iOS, if the PostTextbox height increases from its // initial height (due to a multiline post or a post whose // message wraps, for example), then when the text is cleared // the PostTextbox height decrease will be animated. This // animation in conjunction with the PostList animation as it // receives the newly created post is causing issues in the iOS // PostList component as it fails to properly react to its content // size changes. While a proper fix is determined for the PostList // component, a small delay in triggering the height decrease // animation gives the PostList enough time to first handle content // size changes from the new post. setTimeout(() => { this.handleTextChange(''); this.setState({sendingMessage: false}); }, 250); } else { this.handleTextChange(''); this.setState({sendingMessage: false}); } this.changeDraft(''); let callback; 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', }; 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 ''; }; isStatusSlashCommand = (command) => { return command === General.ONLINE || command === General.AWAY || command === General.DND || command === General.OFFLINE; }; updateStatus = (status) => { const {actions, currentUserId} = this.props; actions.setStatus({ user_id: currentUserId, status, }); }; sendCommand = async (msg) => { const {intl} = this.context; const {userIsOutOfOffice, actions, channelId, rootId} = 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 actions.executeCommand(msg, channelId, rootId); this.setState({sendingMessage: false}); if (error) { Alert.alert( intl.formatMessage({ id: 'mobile.commands.error_title', defaultMessage: 'Error Executing Command', }), error.message, ); return; } this.handleTextChange(''); this.changeDraft(''); }; sendReaction = (emoji) => { const {actions, rootId} = this.props; actions.addReactionToLatestPost(emoji, rootId); this.handleTextChange(''); this.changeDraft(''); this.setState({sendingMessage: false}); }; 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); }; onCloseChannelPress = () => { const {onCloseChannel, channelTeamId} = this.props; this.props.actions.selectPenultimateChannel(channelTeamId); if (onCloseChannel) { onCloseChannel(); } }; archivedView = (theme, style) => { return ( ); }; handleLayout = (e) => { this.setState({ top: e.nativeEvent.layout.y, }); }; 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', }), }, ], ); }; 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); } }; handleInputSizeChange = ({nativeEvent: {contentSize}}) => { const {extraInputPadding} = this.state; const numLines = contentSize.height / INPUT_LINE_HEIGHT; if (numLines >= 2 && extraInputPadding !== EXTRA_INPUT_PADDING) { this.setState({extraInputPadding: EXTRA_INPUT_PADDING}); } else if (numLines < 2 && extraInputPadding !== 0) { this.setState({extraInputPadding: 0}); } } renderDeactivatedChannel = () => { const {intl} = this.context; const style = getStyleSheet(this.props.theme); return ( {intl.formatMessage({ id: 'create_post.deactivated', defaultMessage: 'You are viewing an archived channel with a deactivated user.', })} ); }; renderTextBox = () => { const {intl} = this.context; const {channelDisplayName, channelIsArchived, channelIsReadOnly, theme, isLandscape, files, rootId, canPost} = this.props; const style = getStyleSheet(theme); if (channelIsArchived) { return this.archivedView(theme, style); } const {value, extraInputPadding} = this.state; const placeholder = this.getPlaceHolder(); let maxHeight = 150; if (isLandscape) { maxHeight = 88; } const inputStyle = {}; if (extraInputPadding) { inputStyle.paddingBottom = style.input.paddingBottom + extraInputPadding; inputStyle.paddingTop = style.input.paddingTop + extraInputPadding; } return ( {!channelIsReadOnly && canPost && {this.getTextInputButton('at')} {this.getTextInputButton('slash')} {this.getMediaButton('file')} {this.getMediaButton('image')} {this.getMediaButton('camera')} } ); }; } const getStyleSheet = makeStyleSheetFromTheme((theme) => { return { buttonsContainer: { display: 'flex', flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingBottom: Platform.select({ ios: 1, android: 2, }), }, slashIcon: { width: ICON_SIZE, height: ICON_SIZE, opacity: 1, tintColor: changeOpacity(theme.centerChannelColor, 0.64), }, iconDisabled: { tintColor: changeOpacity(theme.centerChannelColor, 0.16), }, iconWrapper: { alignItems: 'center', justifyContent: 'center', padding: 10, }, quickActionsContainer: { display: 'flex', flexDirection: 'row', height: 44, }, input: { color: theme.centerChannelColor, fontSize: 15, lineHeight: INPUT_LINE_HEIGHT, paddingHorizontal: 12, paddingTop: Platform.select({ ios: 6, android: 8, }), paddingBottom: Platform.select({ ios: 6, android: 2, }), minHeight: 30, }, 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), }, deactivatedMessage: { color: changeOpacity(theme.centerChannelColor, 0.8), fontSize: 15, lineHeight: 22, alignItems: 'flex-end', flexDirection: 'row', paddingVertical: 4, backgroundColor: theme.centerChannelBg, borderTopWidth: 1, borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), marginLeft: 10, marginRight: 10, }, archivedWrapper: { paddingLeft: 20, paddingRight: 20, paddingTop: 10, paddingBottom: 10, borderTopWidth: 1, backgroundColor: theme.centerChannelBg, borderTopColor: changeOpacity(theme.centerChannelColor, 0.20), }, archivedText: { textAlign: 'center', color: theme.centerChannelColor, }, closeButton: { backgroundColor: theme.buttonBg, alignItems: 'center', paddingTop: 5, paddingBottom: 5, borderRadius: 4, marginTop: 10, height: 40, }, closeButtonText: { marginTop: 7, color: 'white', fontWeight: 'bold', }, readonlyContainer: { marginLeft: 10, }, }; });