diff --git a/app/AppContainer.js b/app/AppContainer.js index 1d4a89234..76e2fee8e 100644 --- a/app/AppContainer.js +++ b/app/AppContainer.js @@ -10,6 +10,7 @@ import { defaultHeader, getActiveRouteName, navigationTheme } from './utils/navi import { ROOT_LOADING, ROOT_OUTSIDE, ROOT_NEW_SERVER, ROOT_INSIDE, ROOT_SET_USERNAME, ROOT_BACKGROUND } from './actions/app'; +import { ActionSheetProvider } from './containers/ActionSheet'; // Stacks import AuthLoadingView from './views/AuthLoadingView'; @@ -53,53 +54,55 @@ const App = React.memo(({ root, isMasterDetail }) => { return ( - { - const previousRouteName = Navigation.routeNameRef.current; - const currentRouteName = getActiveRouteName(state); - if (previousRouteName !== currentRouteName) { - setCurrentScreen(currentRouteName); - } - Navigation.routeNameRef.current = currentRouteName; - }} - > - - <> - {root === ROOT_LOADING || root === ROOT_BACKGROUND ? ( - - ) : null} - {root === ROOT_OUTSIDE || root === ROOT_NEW_SERVER ? ( - - ) : null} - {root === ROOT_INSIDE && isMasterDetail ? ( - - ) : null} - {root === ROOT_INSIDE && !isMasterDetail ? ( - - ) : null} - {root === ROOT_SET_USERNAME ? ( - - ) : null} - - - + + { + const previousRouteName = Navigation.routeNameRef.current; + const currentRouteName = getActiveRouteName(state); + if (previousRouteName !== currentRouteName) { + setCurrentScreen(currentRouteName); + } + Navigation.routeNameRef.current = currentRouteName; + }} + > + + <> + {root === ROOT_LOADING || root === ROOT_BACKGROUND ? ( + + ) : null} + {root === ROOT_OUTSIDE || root === ROOT_NEW_SERVER ? ( + + ) : null} + {root === ROOT_INSIDE && isMasterDetail ? ( + + ) : null} + {root === ROOT_INSIDE && !isMasterDetail ? ( + + ) : null} + {root === ROOT_SET_USERNAME ? ( + + ) : null} + + + + ); }); diff --git a/app/containers/ActionSheet/ActionSheet.js b/app/containers/ActionSheet/ActionSheet.js new file mode 100644 index 000000000..c450e0419 --- /dev/null +++ b/app/containers/ActionSheet/ActionSheet.js @@ -0,0 +1,214 @@ +import React, { + useRef, + useState, + useEffect, + forwardRef, + useImperativeHandle, + useCallback, + isValidElement +} from 'react'; +import PropTypes from 'prop-types'; +import { Keyboard, Text } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { TapGestureHandler, State } from 'react-native-gesture-handler'; +import ScrollBottomSheet from 'react-native-scroll-bottom-sheet'; +import Animated, { + Extrapolate, + interpolate, + Value, + Easing +} from 'react-native-reanimated'; +import * as Haptics from 'expo-haptics'; +import { + useDimensions, + useBackHandler, + useDeviceOrientation +} from '@react-native-community/hooks'; + +import { Item } from './Item'; +import { Handle } from './Handle'; +import { Button } from './Button'; +import { themes } from '../../constants/colors'; +import styles, { ITEM_HEIGHT } from './styles'; +import { isTablet, isIOS } from '../../utils/deviceInfo'; +import Separator from '../Separator'; +import I18n from '../../i18n'; + +const getItemLayout = (data, index) => ({ length: ITEM_HEIGHT, offset: ITEM_HEIGHT * index, index }); + +const HANDLE_HEIGHT = isIOS ? 40 : 56; +const MAX_SNAP_HEIGHT = 16; +const CANCEL_HEIGHT = 64; + +const ANIMATION_DURATION = 250; + +const ANIMATION_CONFIG = { + duration: ANIMATION_DURATION, + // https://easings.net/#easeInOutCubic + easing: Easing.bezier(0.645, 0.045, 0.355, 1.0) +}; + +const ActionSheet = React.memo(forwardRef(({ children, theme }, ref) => { + const bottomSheetRef = useRef(); + const [data, setData] = useState({}); + const [isVisible, setVisible] = useState(false); + const orientation = useDeviceOrientation(); + const { height } = useDimensions().window; + const insets = useSafeAreaInsets(); + const { landscape } = orientation; + + const maxSnap = Math.max( + ( + height + // Items height + - (ITEM_HEIGHT * (data?.options?.length || 0)) + // Handle height + - HANDLE_HEIGHT + // Custom header height + - (data?.headerHeight || 0) + // Insets bottom height (Notch devices) + - insets.bottom + // Cancel button height + - (data?.hasCancel ? CANCEL_HEIGHT : 0) + ), + MAX_SNAP_HEIGHT + ); + + /* + * if the action sheet cover more + * than 60% of the whole screen + * and it's not at the landscape mode + * we'll provide more one snap + * that point 50% of the whole screen + */ + const snaps = (height - maxSnap > height * 0.6) && !landscape ? [maxSnap, height * 0.5, height] : [maxSnap, height]; + const openedSnapIndex = snaps.length > 2 ? 1 : 0; + const closedSnapIndex = snaps.length - 1; + + const toggleVisible = () => setVisible(!isVisible); + + const hide = () => { + bottomSheetRef.current?.snapTo(closedSnapIndex); + }; + + const show = (options) => { + setData(options); + toggleVisible(); + }; + + const onBackdropPressed = ({ nativeEvent }) => { + if (nativeEvent.oldState === State.ACTIVE) { + hide(); + } + }; + + useBackHandler(() => { + if (isVisible) { + hide(); + } + return isVisible; + }); + + useEffect(() => { + if (isVisible) { + Keyboard.dismiss(); + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + bottomSheetRef.current?.snapTo(openedSnapIndex); + } + }, [isVisible]); + + // Hides action sheet when orientation changes + useEffect(() => { + setVisible(false); + }, [orientation.landscape]); + + useImperativeHandle(ref, () => ({ + showActionSheet: show, + hideActionSheet: hide + })); + + const renderHandle = useCallback(() => ( + <> + + {isValidElement(data?.customHeader) ? data.customHeader : null} + + )); + + const renderFooter = useCallback(() => (data?.hasCancel ? ( + + ) : null)); + + const renderSeparator = useCallback(() => ); + + const renderItem = useCallback(({ item }) => ); + + const animatedPosition = React.useRef(new Value(0)); + const opacity = interpolate(animatedPosition.current, { + inputRange: [0, 1], + outputRange: [0, 0.7], + extrapolate: Extrapolate.CLAMP + }); + + return ( + <> + {children} + {isVisible && ( + <> + + + + (index === closedSnapIndex) && toggleVisible()} + animatedPosition={animatedPosition.current} + containerStyle={[ + styles.container, + { backgroundColor: themes[theme].focusedBackground }, + (landscape || isTablet) && styles.bottomSheet + ]} + animationConfig={ANIMATION_CONFIG} + // FlatList props + data={data?.options} + renderItem={renderItem} + keyExtractor={item => item.title} + style={{ backgroundColor: themes[theme].focusedBackground }} + contentContainerStyle={styles.content} + ItemSeparatorComponent={renderSeparator} + ListHeaderComponent={renderSeparator} + ListFooterComponent={renderFooter} + getItemLayout={getItemLayout} + removeClippedSubviews={isIOS} + /> + + )} + + ); +})); +ActionSheet.propTypes = { + children: PropTypes.node, + theme: PropTypes.string +}; + +export default ActionSheet; diff --git a/app/containers/ActionSheet/Button.js b/app/containers/ActionSheet/Button.js new file mode 100644 index 000000000..5deb0f692 --- /dev/null +++ b/app/containers/ActionSheet/Button.js @@ -0,0 +1,7 @@ +import { TouchableOpacity } from 'react-native'; + +import { isAndroid } from '../../utils/deviceInfo'; +import Touch from '../../utils/touch'; + +// Taken from https://github.com/rgommezz/react-native-scroll-bottom-sheet#touchables +export const Button = isAndroid ? Touch : TouchableOpacity; diff --git a/app/containers/ActionSheet/Handle.js b/app/containers/ActionSheet/Handle.js new file mode 100644 index 000000000..0f19ce3f2 --- /dev/null +++ b/app/containers/ActionSheet/Handle.js @@ -0,0 +1,15 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { View } from 'react-native'; + +import styles from './styles'; +import { themes } from '../../constants/colors'; + +export const Handle = React.memo(({ theme }) => ( + + + +)); +Handle.propTypes = { + theme: PropTypes.string +}; diff --git a/app/containers/ActionSheet/Item.js b/app/containers/ActionSheet/Item.js new file mode 100644 index 000000000..7cd5e7b4d --- /dev/null +++ b/app/containers/ActionSheet/Item.js @@ -0,0 +1,41 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Text } from 'react-native'; + +import { themes } from '../../constants/colors'; +import { CustomIcon } from '../../lib/Icons'; +import styles from './styles'; +import { Button } from './Button'; + +export const Item = React.memo(({ item, hide, theme }) => { + const onPress = () => { + hide(); + item?.onPress(); + }; + + return ( + + ); +}); +Item.propTypes = { + item: PropTypes.shape({ + title: PropTypes.string, + icon: PropTypes.string, + danger: PropTypes.bool, + onPress: PropTypes.func + }), + hide: PropTypes.func, + theme: PropTypes.string +}; diff --git a/app/containers/ActionSheet/Provider.js b/app/containers/ActionSheet/Provider.js new file mode 100644 index 000000000..3dfcd0dcd --- /dev/null +++ b/app/containers/ActionSheet/Provider.js @@ -0,0 +1,50 @@ +import React, { useRef, useContext } from 'react'; +import hoistNonReactStatics from 'hoist-non-react-statics'; +import PropTypes from 'prop-types'; + +import ActionSheet from './ActionSheet'; +import { useTheme } from '../../theme'; + +const context = React.createContext({ + showActionSheet: () => {}, + hideActionSheet: () => {} +}); + +export const useActionSheet = () => useContext(context); + +const { Provider, Consumer } = context; + +export const withActionSheet = (Component) => { + const ConnectedActionSheet = props => ( + + {contexts => } + + ); + hoistNonReactStatics(ConnectedActionSheet, Component); + return ConnectedActionSheet; +}; + +export const ActionSheetProvider = React.memo(({ children }) => { + const ref = useRef(); + const { theme } = useTheme(); + + const getContext = () => ({ + showActionSheet: (options) => { + ref.current?.showActionSheet(options); + }, + hideActionSheet: () => { + ref.current?.hideActionSheet(); + } + }); + + return ( + + + {children} + + + ); +}); +ActionSheetProvider.propTypes = { + children: PropTypes.node +}; diff --git a/app/containers/ActionSheet/index.js b/app/containers/ActionSheet/index.js new file mode 100644 index 000000000..d714d22bb --- /dev/null +++ b/app/containers/ActionSheet/index.js @@ -0,0 +1,2 @@ +export * from './Provider'; +export * from './Button'; diff --git a/app/containers/ActionSheet/styles.js b/app/containers/ActionSheet/styles.js new file mode 100644 index 000000000..d87c35f12 --- /dev/null +++ b/app/containers/ActionSheet/styles.js @@ -0,0 +1,61 @@ +import { StyleSheet } from 'react-native'; + +import sharedStyles from '../../views/Styles'; + +export const ITEM_HEIGHT = 48; + +export default StyleSheet.create({ + container: { + overflow: 'hidden', + borderTopLeftRadius: 16, + borderTopRightRadius: 16 + }, + item: { + paddingHorizontal: 16, + height: ITEM_HEIGHT, + alignItems: 'center', + flexDirection: 'row' + }, + separator: { + marginHorizontal: 16 + }, + content: { + paddingTop: 16 + }, + title: { + fontSize: 16, + marginLeft: 16, + ...sharedStyles.textRegular + }, + handle: { + justifyContent: 'center', + alignItems: 'center' + }, + handleIndicator: { + width: 40, + height: 4, + borderRadius: 4, + margin: 8 + }, + backdrop: { + ...StyleSheet.absoluteFillObject + }, + bottomSheet: { + width: '50%', + alignSelf: 'center', + left: '25%' + }, + button: { + marginHorizontal: 16, + paddingHorizontal: 14, + justifyContent: 'center', + height: ITEM_HEIGHT, + borderRadius: 2, + marginBottom: 12 + }, + text: { + fontSize: 16, + textAlign: 'center', + ...sharedStyles.textMedium + } +}); diff --git a/app/containers/MessageActions.js b/app/containers/MessageActions.js deleted file mode 100644 index 4e8b126df..000000000 --- a/app/containers/MessageActions.js +++ /dev/null @@ -1,467 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Alert, Clipboard, Share } from 'react-native'; -import { connect } from 'react-redux'; -import ActionSheet from 'react-native-action-sheet'; -import moment from 'moment'; -import * as Haptics from 'expo-haptics'; - -import RocketChat from '../lib/rocketchat'; -import database from '../lib/database'; -import I18n from '../i18n'; -import log from '../utils/log'; -import Navigation from '../lib/Navigation'; -import { getMessageTranslation } from './message/utils'; -import { LISTENER } from './Toast'; -import EventEmitter from '../utils/events'; -import { showConfirmationAlert } from '../utils/info'; - -class MessageActions extends React.Component { - static propTypes = { - actionsHide: PropTypes.func.isRequired, - room: PropTypes.object.isRequired, - message: PropTypes.object, - user: PropTypes.object, - editInit: PropTypes.func.isRequired, - reactionInit: PropTypes.func.isRequired, - replyInit: PropTypes.func.isRequired, - isReadOnly: PropTypes.bool, - Message_AllowDeleting: PropTypes.bool, - Message_AllowDeleting_BlockDeleteInMinutes: PropTypes.number, - Message_AllowEditing: PropTypes.bool, - Message_AllowEditing_BlockEditInMinutes: PropTypes.number, - Message_AllowPinning: PropTypes.bool, - Message_AllowStarring: PropTypes.bool, - Message_Read_Receipt_Store_Users: PropTypes.bool, - isMasterDetail: PropTypes.bool - }; - - constructor(props) { - super(props); - this.handleActionPress = this.handleActionPress.bind(this); - } - - async componentDidMount() { - await this.setPermissions(); - - const { - Message_AllowStarring, Message_AllowPinning, Message_Read_Receipt_Store_Users, user, room, message, isReadOnly - } = this.props; - - // Cancel - this.options = [I18n.t('Cancel')]; - this.CANCEL_INDEX = 0; - - // Reply - if (!isReadOnly) { - this.options.push(I18n.t('Reply')); - this.REPLY_INDEX = this.options.length - 1; - } - - // Edit - if (this.allowEdit(this.props)) { - this.options.push(I18n.t('Edit')); - this.EDIT_INDEX = this.options.length - 1; - } - - // Create Discussion - this.options.push(I18n.t('Create_Discussion')); - this.CREATE_DISCUSSION_INDEX = this.options.length - 1; - - // Mark as unread - if (message.u && message.u._id !== user.id) { - this.options.push(I18n.t('Mark_unread')); - this.UNREAD_INDEX = this.options.length - 1; - } - - // Permalink - this.options.push(I18n.t('Permalink')); - this.PERMALINK_INDEX = this.options.length - 1; - - // Copy - this.options.push(I18n.t('Copy')); - this.COPY_INDEX = this.options.length - 1; - - // Share - this.options.push(I18n.t('Share')); - this.SHARE_INDEX = this.options.length - 1; - - // Quote - if (!isReadOnly) { - this.options.push(I18n.t('Quote')); - this.QUOTE_INDEX = this.options.length - 1; - } - - // Star - if (Message_AllowStarring) { - this.options.push(I18n.t(message.starred ? 'Unstar' : 'Star')); - this.STAR_INDEX = this.options.length - 1; - } - - // Pin - if (Message_AllowPinning) { - this.options.push(I18n.t(message.pinned ? 'Unpin' : 'Pin')); - this.PIN_INDEX = this.options.length - 1; - } - - // Reaction - if (!isReadOnly || this.canReactWhenReadOnly()) { - this.options.push(I18n.t('Add_Reaction')); - this.REACTION_INDEX = this.options.length - 1; - } - - // Read Receipts - if (Message_Read_Receipt_Store_Users) { - this.options.push(I18n.t('Read_Receipt')); - this.READ_RECEIPT_INDEX = this.options.length - 1; - } - - // Toggle Auto-translate - if (room.autoTranslate && message.u && message.u._id !== user.id) { - this.options.push(I18n.t(message.autoTranslate ? 'View_Original' : 'Translate')); - this.TOGGLE_TRANSLATION_INDEX = this.options.length - 1; - } - - // Report - this.options.push(I18n.t('Report')); - this.REPORT_INDEX = this.options.length - 1; - - // Delete - if (this.allowDelete(this.props)) { - this.options.push(I18n.t('Delete')); - this.DELETE_INDEX = this.options.length - 1; - } - setTimeout(() => { - this.showActionSheet(); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - }); - } - - async setPermissions() { - try { - const { room } = this.props; - const permissions = ['edit-message', 'delete-message', 'force-delete-message']; - const result = await RocketChat.hasPermission(permissions, room.rid); - this.hasEditPermission = result[permissions[0]]; - this.hasDeletePermission = result[permissions[1]]; - this.hasForceDeletePermission = result[permissions[2]]; - } catch (e) { - log(e); - } - Promise.resolve(); - } - - showActionSheet = () => { - ActionSheet.showActionSheetWithOptions({ - options: this.options, - cancelButtonIndex: this.CANCEL_INDEX, - destructiveButtonIndex: this.DELETE_INDEX, - title: I18n.t('Message_actions') - }, (actionIndex) => { - this.handleActionPress(actionIndex); - }); - } - - getPermalink = async(message) => { - try { - return await RocketChat.getPermalinkMessage(message); - } catch (error) { - return null; - } - } - - isOwn = props => props.message.u && props.message.u._id === props.user.id; - - canReactWhenReadOnly = () => { - const { room } = this.props; - return room.reactWhenReadOnly; - } - - allowEdit = (props) => { - if (props.isReadOnly) { - return false; - } - const editOwn = this.isOwn(props); - const { Message_AllowEditing: isEditAllowed, Message_AllowEditing_BlockEditInMinutes } = this.props; - - if (!(this.hasEditPermission || (isEditAllowed && editOwn))) { - return false; - } - const blockEditInMinutes = Message_AllowEditing_BlockEditInMinutes; - if (blockEditInMinutes) { - let msgTs; - if (props.message.ts != null) { - msgTs = moment(props.message.ts); - } - let currentTsDiff; - if (msgTs != null) { - currentTsDiff = moment().diff(msgTs, 'minutes'); - } - return currentTsDiff < blockEditInMinutes; - } - return true; - } - - allowDelete = (props) => { - if (props.isReadOnly) { - return false; - } - - // Prevent from deleting thread start message when positioned inside the thread - if (props.tmid && props.tmid === props.message.id) { - return false; - } - const deleteOwn = this.isOwn(props); - const { Message_AllowDeleting: isDeleteAllowed, Message_AllowDeleting_BlockDeleteInMinutes } = this.props; - if (!(this.hasDeletePermission || (isDeleteAllowed && deleteOwn) || this.hasForceDeletePermission)) { - return false; - } - if (this.hasForceDeletePermission) { - return true; - } - const blockDeleteInMinutes = Message_AllowDeleting_BlockDeleteInMinutes; - if (blockDeleteInMinutes != null && blockDeleteInMinutes !== 0) { - let msgTs; - if (props.message.ts != null) { - msgTs = moment(props.message.ts); - } - let currentTsDiff; - if (msgTs != null) { - currentTsDiff = moment().diff(msgTs, 'minutes'); - } - return currentTsDiff < blockDeleteInMinutes; - } - return true; - } - - handleDelete = () => { - showConfirmationAlert({ - message: I18n.t('You_will_not_be_able_to_recover_this_message'), - callToAction: I18n.t('Delete'), - onPress: async() => { - const { message } = this.props; - try { - await RocketChat.deleteMessage(message.id, message.subscription.id); - } catch (e) { - log(e); - } - } - }); - } - - handleEdit = () => { - const { message, editInit } = this.props; - editInit(message); - } - - handleUnread = async() => { - const { message, room, isMasterDetail } = this.props; - const { id: messageId, ts } = message; - const { rid } = room; - try { - const db = database.active; - const result = await RocketChat.markAsUnread({ messageId }); - if (result.success) { - const subCollection = db.collections.get('subscriptions'); - const subRecord = await subCollection.find(rid); - await db.action(async() => { - try { - await subRecord.update(sub => sub.lastOpen = ts); - } catch { - // do nothing - } - }); - if (isMasterDetail) { - Navigation.replace('RoomView'); - } else { - Navigation.navigate('RoomsListView'); - } - } - } catch (e) { - log(e); - } - } - - handleCopy = async() => { - const { message } = this.props; - await Clipboard.setString(message.msg); - EventEmitter.emit(LISTENER, { message: I18n.t('Copied_to_clipboard') }); - } - - handleShare = async() => { - const { message } = this.props; - const permalink = await this.getPermalink(message); - if (!permalink) { - return; - } - Share.share({ - message: permalink - }); - }; - - handleStar = async() => { - const { message } = this.props; - try { - await RocketChat.toggleStarMessage(message.id, message.starred); - EventEmitter.emit(LISTENER, { message: message.starred ? I18n.t('Message_unstarred') : I18n.t('Message_starred') }); - } catch (e) { - log(e); - } - } - - handlePermalink = async() => { - const { message } = this.props; - const permalink = await this.getPermalink(message); - Clipboard.setString(permalink); - EventEmitter.emit(LISTENER, { message: I18n.t('Permalink_copied_to_clipboard') }); - } - - handlePin = async() => { - const { message } = this.props; - try { - await RocketChat.togglePinMessage(message.id, message.pinned); - } catch (e) { - log(e); - } - } - - handleReply = () => { - const { message, replyInit } = this.props; - replyInit(message, true); - } - - handleQuote = () => { - const { message, replyInit } = this.props; - replyInit(message, false); - } - - handleReaction = () => { - const { message, reactionInit } = this.props; - reactionInit(message); - } - - handleReadReceipt = () => { - const { message } = this.props; - Navigation.navigate('ReadReceiptsView', { messageId: message.id }); - } - - handleReport = async() => { - const { message } = this.props; - try { - await RocketChat.reportMessage(message.id); - Alert.alert(I18n.t('Message_Reported')); - } catch (e) { - log(e); - } - } - - handleToggleTranslation = async() => { - const { message, room } = this.props; - try { - const db = database.active; - await db.action(async() => { - await message.update((m) => { - m.autoTranslate = !m.autoTranslate; - m._updatedAt = new Date(); - }); - }); - const translatedMessage = getMessageTranslation(message, room.autoTranslateLanguage); - if (!translatedMessage) { - const m = { - _id: message.id, - rid: message.subscription.id, - u: message.u, - msg: message.msg - }; - await RocketChat.translateMessage(m, room.autoTranslateLanguage); - } - } catch (e) { - log(e); - } - } - - handleCreateDiscussion = () => { - const { message, room: channel, isMasterDetail } = this.props; - const params = { message, channel, showCloseModal: true }; - if (isMasterDetail) { - Navigation.navigate('ModalStackNavigator', { screen: 'CreateDiscussionView', params }); - } else { - Navigation.navigate('NewMessageStackNavigator', { screen: 'CreateDiscussionView', params }); - } - } - - handleActionPress = (actionIndex) => { - if (actionIndex) { - switch (actionIndex) { - case this.REPLY_INDEX: - this.handleReply(); - break; - case this.EDIT_INDEX: - this.handleEdit(); - break; - case this.UNREAD_INDEX: - this.handleUnread(); - break; - case this.PERMALINK_INDEX: - this.handlePermalink(); - break; - case this.COPY_INDEX: - this.handleCopy(); - break; - case this.SHARE_INDEX: - this.handleShare(); - break; - case this.QUOTE_INDEX: - this.handleQuote(); - break; - case this.STAR_INDEX: - this.handleStar(); - break; - case this.PIN_INDEX: - this.handlePin(); - break; - case this.REACTION_INDEX: - this.handleReaction(); - break; - case this.REPORT_INDEX: - this.handleReport(); - break; - case this.DELETE_INDEX: - this.handleDelete(); - break; - case this.READ_RECEIPT_INDEX: - this.handleReadReceipt(); - break; - case this.CREATE_DISCUSSION_INDEX: - this.handleCreateDiscussion(); - break; - case this.TOGGLE_TRANSLATION_INDEX: - this.handleToggleTranslation(); - break; - default: - break; - } - } - const { actionsHide } = this.props; - actionsHide(); - } - - render() { - return ( - null - ); - } -} - -const mapStateToProps = state => ({ - Message_AllowDeleting: state.settings.Message_AllowDeleting, - Message_AllowDeleting_BlockDeleteInMinutes: state.settings.Message_AllowDeleting_BlockDeleteInMinutes, - Message_AllowEditing: state.settings.Message_AllowEditing, - Message_AllowEditing_BlockEditInMinutes: state.settings.Message_AllowEditing_BlockEditInMinutes, - Message_AllowPinning: state.settings.Message_AllowPinning, - Message_AllowStarring: state.settings.Message_AllowStarring, - Message_Read_Receipt_Store_Users: state.settings.Message_Read_Receipt_Store_Users, - isMasterDetail: state.app.isMasterDetail -}); - -export default connect(mapStateToProps)(MessageActions); diff --git a/app/containers/MessageActions/Header.js b/app/containers/MessageActions/Header.js new file mode 100644 index 000000000..040413906 --- /dev/null +++ b/app/containers/MessageActions/Header.js @@ -0,0 +1,139 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import PropTypes from 'prop-types'; +import { + View, Text, FlatList, StyleSheet, Dimensions +} from 'react-native'; + +import { withTheme } from '../../theme'; +import { themes } from '../../constants/colors'; +import { CustomIcon } from '../../lib/Icons'; +import shortnameToUnicode from '../../utils/shortnameToUnicode'; +import CustomEmoji from '../EmojiPicker/CustomEmoji'; +import database from '../../lib/database'; +import { Button } from '../ActionSheet'; + +export const HEADER_HEIGHT = 36; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + marginHorizontal: 8 + }, + headerItem: { + height: 36, + width: 36, + borderRadius: 20, + marginHorizontal: 8, + justifyContent: 'center', + alignItems: 'center' + }, + headerIcon: { + textAlign: 'center', + fontSize: 20, + color: '#fff' + }, + customEmoji: { + height: 20, + width: 20 + } +}); + +const keyExtractor = item => item?.id || item; + +const DEFAULT_EMOJIS = ['clap', '+1', 'heart_eyes', 'grinning', 'thinking_face', 'smiley']; + +const HeaderItem = React.memo(({ + item, onReaction, server, theme +}) => ( + +)); +HeaderItem.propTypes = { + item: PropTypes.string, + onReaction: PropTypes.func, + server: PropTypes.string, + theme: PropTypes.string +}; + +const HeaderFooter = React.memo(({ onReaction, theme }) => ( + +)); +HeaderFooter.propTypes = { + onReaction: PropTypes.func, + theme: PropTypes.string +}; + +const Header = React.memo(({ + handleReaction, server, message, theme +}) => { + const [items, setItems] = useState([]); + + const setEmojis = async() => { + try { + const db = database.active; + const freqEmojiCollection = db.collections.get('frequently_used_emojis'); + let freqEmojis = await freqEmojiCollection.query().fetch(); + + const { width, height } = Dimensions.get('window'); + const isLandscape = width > height; + const size = isLandscape ? width / 2 : width; + const quantity = (size / 50) - 1; + + freqEmojis = freqEmojis.concat(DEFAULT_EMOJIS).slice(0, quantity); + setItems(freqEmojis); + } catch { + // Do nothing + } + }; + + useEffect(() => { + setEmojis(); + }, []); + + const onReaction = ({ emoji }) => handleReaction(emoji, message); + + const renderItem = useCallback(({ item }) => ); + + const renderFooter = useCallback(() => ); + + return ( + + + + ); +}); +Header.propTypes = { + handleReaction: PropTypes.func, + server: PropTypes.string, + message: PropTypes.object, + theme: PropTypes.string +}; +export default withTheme(Header); diff --git a/app/containers/MessageActions/index.js b/app/containers/MessageActions/index.js new file mode 100644 index 000000000..99cc7df37 --- /dev/null +++ b/app/containers/MessageActions/index.js @@ -0,0 +1,418 @@ +import React, { forwardRef, useImperativeHandle } from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Clipboard, Share } from 'react-native'; +import { connect } from 'react-redux'; +import moment from 'moment'; + +import RocketChat from '../../lib/rocketchat'; +import database from '../../lib/database'; +import I18n from '../../i18n'; +import log from '../../utils/log'; +import Navigation from '../../lib/Navigation'; +import { getMessageTranslation } from '../message/utils'; +import { LISTENER } from '../Toast'; +import EventEmitter from '../../utils/events'; +import { showConfirmationAlert } from '../../utils/info'; +import { useActionSheet } from '../ActionSheet'; +import Header, { HEADER_HEIGHT } from './Header'; + +const MessageActions = React.memo(forwardRef(({ + room, + tmid, + user, + editInit, + reactionInit, + onReactionPress, + replyInit, + isReadOnly, + server, + Message_AllowDeleting, + Message_AllowDeleting_BlockDeleteInMinutes, + Message_AllowEditing, + Message_AllowEditing_BlockEditInMinutes, + Message_AllowPinning, + Message_AllowStarring, + Message_Read_Receipt_Store_Users +}, ref) => { + let permissions = {}; + const { showActionSheet, hideActionSheet } = useActionSheet(); + + const getPermissions = async() => { + try { + const permission = ['edit-message', 'delete-message', 'force-delete-message', 'pin-message']; + const result = await RocketChat.hasPermission(permission, room.rid); + permissions = { + hasEditPermission: result[permission[0]], + hasDeletePermission: result[permission[1]], + hasForceDeletePermission: result[permission[2]], + hasPinPermission: result[permission[3]] + }; + } catch { + // Do nothing + } + }; + + const isOwn = message => message.u && message.u._id === user.id; + + const allowEdit = (message) => { + if (isReadOnly) { + return false; + } + const editOwn = isOwn(message); + + if (!(permissions.hasEditPermission || (Message_AllowEditing && editOwn))) { + return false; + } + const blockEditInMinutes = Message_AllowEditing_BlockEditInMinutes; + if (blockEditInMinutes) { + let msgTs; + if (message.ts != null) { + msgTs = moment(message.ts); + } + let currentTsDiff; + if (msgTs != null) { + currentTsDiff = moment().diff(msgTs, 'minutes'); + } + return currentTsDiff < blockEditInMinutes; + } + return true; + }; + + const allowDelete = (message) => { + if (isReadOnly) { + return false; + } + + // Prevent from deleting thread start message when positioned inside the thread + if (tmid === message.id) { + return false; + } + const deleteOwn = isOwn(message); + if (!(permissions.hasDeletePermission || (Message_AllowDeleting && deleteOwn) || permissions.hasForceDeletePermission)) { + return false; + } + if (permissions.hasForceDeletePermission) { + return true; + } + const blockDeleteInMinutes = Message_AllowDeleting_BlockDeleteInMinutes; + if (blockDeleteInMinutes != null && blockDeleteInMinutes !== 0) { + let msgTs; + if (message.ts != null) { + msgTs = moment(message.ts); + } + let currentTsDiff; + if (msgTs != null) { + currentTsDiff = moment().diff(msgTs, 'minutes'); + } + return currentTsDiff < blockDeleteInMinutes; + } + return true; + }; + + const getPermalink = message => RocketChat.getPermalinkMessage(message); + + const handleReply = message => replyInit(message, true); + + const handleEdit = message => editInit(message); + + const handleCreateDiscussion = (message) => { + Navigation.navigate('CreateDiscussionView', { message, channel: room }); + }; + + const handleUnread = async(message) => { + const { id: messageId, ts } = message; + const { rid } = room; + try { + const db = database.active; + const result = await RocketChat.markAsUnread({ messageId }); + if (result.success) { + const subCollection = db.collections.get('subscriptions'); + const subRecord = await subCollection.find(rid); + await db.action(async() => { + try { + await subRecord.update(sub => sub.lastOpen = ts); + } catch { + // do nothing + } + }); + Navigation.navigate('RoomsListView'); + } + } catch (e) { + log(e); + } + }; + + const handlePermalink = async(message) => { + try { + const permalink = await getPermalink(message); + Clipboard.setString(permalink); + EventEmitter.emit(LISTENER, { message: I18n.t('Permalink_copied_to_clipboard') }); + } catch { + // Do nothing + } + }; + + const handleCopy = async(message) => { + await Clipboard.setString(message.msg); + EventEmitter.emit(LISTENER, { message: I18n.t('Copied_to_clipboard') }); + }; + + const handleShare = async(message) => { + try { + const permalink = await getPermalink(message); + Share.share({ message: permalink }); + } catch { + // Do nothing + } + }; + + const handleQuote = message => replyInit(message, false); + + const handleStar = async(message) => { + try { + await RocketChat.toggleStarMessage(message.id, message.starred); + EventEmitter.emit(LISTENER, { message: message.starred ? I18n.t('Message_unstarred') : I18n.t('Message_starred') }); + } catch (e) { + log(e); + } + }; + + const handlePin = async(message) => { + try { + await RocketChat.togglePinMessage(message.id, message.pinned); + } catch (e) { + log(e); + } + }; + + const handleReaction = (shortname, message) => { + if (shortname) { + onReactionPress(shortname, message.id); + } else { + reactionInit(message); + } + // close actionSheet when click at header + hideActionSheet(); + }; + + const handleReadReceipt = message => Navigation.navigate('ReadReceiptsView', { messageId: message.id }); + + const handleToggleTranslation = async(message) => { + try { + const db = database.active; + await db.action(async() => { + await message.update((m) => { + m.autoTranslate = !m.autoTranslate; + m._updatedAt = new Date(); + }); + }); + const translatedMessage = getMessageTranslation(message, room.autoTranslateLanguage); + if (!translatedMessage) { + const m = { + _id: message.id, + rid: message.subscription.id, + u: message.u, + msg: message.msg + }; + await RocketChat.translateMessage(m, room.autoTranslateLanguage); + } + } catch (e) { + log(e); + } + }; + + const handleReport = async(message) => { + try { + await RocketChat.reportMessage(message.id); + Alert.alert(I18n.t('Message_Reported')); + } catch (e) { + log(e); + } + }; + + const handleDelete = (message) => { + showConfirmationAlert({ + message: I18n.t('You_will_not_be_able_to_recover_this_message'), + callToAction: I18n.t('Delete'), + onPress: async() => { + try { + await RocketChat.deleteMessage(message.id, message.subscription.id); + } catch (e) { + log(e); + } + } + }); + }; + + const getOptions = (message) => { + let options = []; + + // Reply + if (!isReadOnly) { + options = [{ + title: I18n.t('Reply_in_Thread'), + icon: 'threads', + onPress: () => handleReply(message) + }]; + } + + // Quote + if (!isReadOnly) { + options.push({ + title: I18n.t('Quote'), + icon: 'quote', + onPress: () => handleQuote(message) + }); + } + + // Edit + if (allowEdit(message)) { + options.push({ + title: I18n.t('Edit'), + icon: 'edit', + onPress: () => handleEdit(message) + }); + } + + // Permalink + options.push({ + title: I18n.t('Permalink'), + icon: 'link', + onPress: () => handlePermalink(message) + }); + + // Create Discussion + options.push({ + title: I18n.t('Start_a_Discussion'), + icon: 'chat', + onPress: () => handleCreateDiscussion(message) + }); + + // Mark as unread + if (message.u && message.u._id !== user.id) { + options.push({ + title: I18n.t('Mark_unread'), + icon: 'flag', + onPress: () => handleUnread(message) + }); + } + + // Copy + options.push({ + title: I18n.t('Copy'), + icon: 'copy', + onPress: () => handleCopy(message) + }); + + // Share + options.push({ + title: I18n.t('Share'), + icon: 'share', + onPress: () => handleShare(message) + }); + + // Star + if (Message_AllowStarring) { + options.push({ + title: I18n.t(message.starred ? 'Unstar' : 'Star'), + icon: message.starred ? 'star-filled' : 'star', + onPress: () => handleStar(message) + }); + } + + // Pin + if (Message_AllowPinning && permissions?.hasPinPermission) { + options.push({ + title: I18n.t(message.pinned ? 'Unpin' : 'Pin'), + icon: 'pin', + onPress: () => handlePin(message) + }); + } + + // Read Receipts + if (Message_Read_Receipt_Store_Users) { + options.push({ + title: I18n.t('Read_Receipt'), + icon: 'receipt', + onPress: () => handleReadReceipt(message) + }); + } + + // Toggle Auto-translate + if (room.autoTranslate && message.u && message.u._id !== user.id) { + options.push({ + title: I18n.t(message.autoTranslate ? 'View_Original' : 'Translate'), + icon: 'language', + onPress: () => handleToggleTranslation(message) + }); + } + + // Report + options.push({ + title: I18n.t('Report'), + icon: 'warning', + danger: true, + onPress: () => handleReport(message) + }); + + // Delete + if (allowDelete(message)) { + options.push({ + title: I18n.t('Delete'), + icon: 'trash', + danger: true, + onPress: () => handleDelete(message) + }); + } + + return options; + }; + + const showMessageActions = async(message) => { + await getPermissions(); + showActionSheet({ + options: getOptions(message), + headerHeight: HEADER_HEIGHT, + customHeader: (!isReadOnly || room.reactWhenReadOnly ? ( +
+ ) : null) + }); + }; + + useImperativeHandle(ref, () => ({ showMessageActions })); +})); +MessageActions.propTypes = { + room: PropTypes.object, + tmid: PropTypes.string, + user: PropTypes.object, + editInit: PropTypes.func, + reactionInit: PropTypes.func, + onReactionPress: PropTypes.func, + replyInit: PropTypes.func, + isReadOnly: PropTypes.bool, + Message_AllowDeleting: PropTypes.bool, + Message_AllowDeleting_BlockDeleteInMinutes: PropTypes.number, + Message_AllowEditing: PropTypes.bool, + Message_AllowEditing_BlockEditInMinutes: PropTypes.number, + Message_AllowPinning: PropTypes.bool, + Message_AllowStarring: PropTypes.bool, + Message_Read_Receipt_Store_Users: PropTypes.bool, + server: PropTypes.string +}; + +const mapStateToProps = state => ({ + server: state.server.server, + Message_AllowDeleting: state.settings.Message_AllowDeleting, + Message_AllowDeleting_BlockDeleteInMinutes: state.settings.Message_AllowDeleting_BlockDeleteInMinutes, + Message_AllowEditing: state.settings.Message_AllowEditing, + Message_AllowEditing_BlockEditInMinutes: state.settings.Message_AllowEditing_BlockEditInMinutes, + Message_AllowPinning: state.settings.Message_AllowPinning, + Message_AllowStarring: state.settings.Message_AllowStarring, + Message_Read_Receipt_Store_Users: state.settings.Message_Read_Receipt_Store_Users +}); + +export default connect(mapStateToProps, null, null, { forwardRef: true })(MessageActions); diff --git a/app/containers/MessageBox/ReplyPreview.js b/app/containers/MessageBox/ReplyPreview.js index 9a6c125ae..2bb81d989 100644 --- a/app/containers/MessageBox/ReplyPreview.js +++ b/app/containers/MessageBox/ReplyPreview.js @@ -3,6 +3,7 @@ import { View, Text, StyleSheet } from 'react-native'; import PropTypes from 'prop-types'; import moment from 'moment'; import { connect } from 'react-redux'; +import isEqual from 'lodash/isEqual'; import Markdown from '../markdown'; import { CustomIcon } from '../../lib/Icons'; @@ -58,7 +59,7 @@ const ReplyPreview = React.memo(({ > - {message.u.username} + {message.u?.username} {time} ); -}, (prevProps, nextProps) => prevProps.replying === nextProps.replying && prevProps.theme === nextProps.theme); +}, (prevProps, nextProps) => prevProps.replying === nextProps.replying && prevProps.theme === nextProps.theme && isEqual(prevProps.message, nextProps.message)); ReplyPreview.propTypes = { replying: PropTypes.bool, diff --git a/app/containers/MessageBox/index.js b/app/containers/MessageBox/index.js index 3620adfce..6f8d56814 100644 --- a/app/containers/MessageBox/index.js +++ b/app/containers/MessageBox/index.js @@ -6,7 +6,6 @@ import { KeyboardAccessoryView } from 'react-native-keyboard-input'; import ImagePicker from 'react-native-image-crop-picker'; import equal from 'deep-equal'; import DocumentPicker from 'react-native-document-picker'; -import ActionSheet from 'react-native-action-sheet'; import { Q } from '@nozbe/watermelondb'; import { generateTriggerId } from '../../lib/methods/actions'; @@ -46,6 +45,7 @@ import CommandsPreview from './CommandsPreview'; import { Review } from '../../utils/review'; import { getUserSelector } from '../../selectors/login'; import Navigation from '../../lib/Navigation'; +import { withActionSheet } from '../ActionSheet'; const imagePickerConfig = { cropping: true, @@ -61,13 +61,6 @@ const videoPickerConfig = { mediaType: 'video' }; -const FILE_CANCEL_INDEX = 0; -const FILE_PHOTO_INDEX = 1; -const FILE_VIDEO_INDEX = 2; -const FILE_LIBRARY_INDEX = 3; -const FILE_DOCUMENT_INDEX = 4; -const CREATE_DISCUSSION_INDEX = 5; - class MessageBox extends Component { static propTypes = { rid: PropTypes.string.isRequired, @@ -96,7 +89,8 @@ class MessageBox extends Component { theme: PropTypes.string, replyCancel: PropTypes.func, isMasterDetail: PropTypes.bool, - navigation: PropTypes.object + navigation: PropTypes.object, + showActionSheet: PropTypes.func } constructor(props) { @@ -116,14 +110,36 @@ class MessageBox extends Component { }; this.text = ''; this.focused = false; - this.messageBoxActions = [ - I18n.t('Cancel'), - I18n.t('Take_a_photo'), - I18n.t('Take_a_video'), - I18n.t('Choose_from_library'), - I18n.t('Choose_file'), - I18n.t('Create_Discussion') + + // MessageBox Actions + this.options = [ + { + title: I18n.t('Take_a_photo'), + icon: 'image', + onPress: this.takePhoto + }, + { + title: I18n.t('Take_a_video'), + icon: 'video-1', + onPress: this.takeVideo + }, + { + title: I18n.t('Choose_from_library'), + icon: 'share', + onPress: this.chooseFromLibrary + }, + { + title: I18n.t('Choose_file'), + icon: 'folder', + onPress: this.chooseFile + }, + { + title: I18n.t('Create_Discussion'), + icon: 'chat', + onPress: this.createDiscussion + } ]; + const libPickerLabels = { cropperChooseText: I18n.t('Choose'), cropperCancelText: I18n.t('Cancel'), @@ -204,6 +220,7 @@ class MessageBox extends Component { if (this.text) { this.setShowSend(true); } + this.focus(); } else if (replying !== nextProps.replying && nextProps.replying) { this.focus(); } else if (!nextProps.message) { @@ -217,7 +234,7 @@ class MessageBox extends Component { } = this.state; const { - roomType, replying, editing, isFocused, theme + roomType, replying, editing, isFocused, message, theme } = this.props; if (nextProps.theme !== theme) { return true; @@ -252,6 +269,9 @@ class MessageBox extends Component { if (!equal(nextState.file, file)) { return true; } + if (!equal(nextProps.message, message)) { + return true; + } return false; } @@ -613,34 +633,8 @@ class MessageBox extends Component { } showMessageBoxActions = () => { - ActionSheet.showActionSheetWithOptions({ - options: this.messageBoxActions, - cancelButtonIndex: FILE_CANCEL_INDEX - }, (actionIndex) => { - this.handleMessageBoxActions(actionIndex); - }); - } - - handleMessageBoxActions = (actionIndex) => { - switch (actionIndex) { - case FILE_PHOTO_INDEX: - this.takePhoto(); - break; - case FILE_VIDEO_INDEX: - this.takeVideo(); - break; - case FILE_LIBRARY_INDEX: - this.chooseFromLibrary(); - break; - case FILE_DOCUMENT_INDEX: - this.chooseFile(); - break; - case CREATE_DISCUSSION_INDEX: - this.createDiscussion(); - break; - default: - break; - } + const { showActionSheet } = this.props; + showActionSheet({ options: this.options }); } editCancel = () => { @@ -939,4 +933,4 @@ const dispatchToProps = ({ typing: (rid, status) => userTypingAction(rid, status) }); -export default connect(mapStateToProps, dispatchToProps, null, { forwardRef: true })(MessageBox); +export default connect(mapStateToProps, dispatchToProps, null, { forwardRef: true })(withActionSheet(MessageBox)); diff --git a/app/containers/MessageErrorActions.js b/app/containers/MessageErrorActions.js index d106da635..bc7b6bc05 100644 --- a/app/containers/MessageErrorActions.js +++ b/app/containers/MessageErrorActions.js @@ -1,41 +1,22 @@ -import React from 'react'; +import { useImperativeHandle, forwardRef } from 'react'; import PropTypes from 'prop-types'; -import ActionSheet from 'react-native-action-sheet'; import RocketChat from '../lib/rocketchat'; import database from '../lib/database'; import protectedFunction from '../lib/methods/helpers/protectedFunction'; +import { useActionSheet } from './ActionSheet'; import I18n from '../i18n'; import log from '../utils/log'; -class MessageErrorActions extends React.Component { - static propTypes = { - actionsHide: PropTypes.func.isRequired, - message: PropTypes.object, - tmid: PropTypes.string - }; +const MessageErrorActions = forwardRef(({ tmid }, ref) => { + const { showActionSheet } = useActionSheet(); - // eslint-disable-next-line react/sort-comp - constructor(props) { - super(props); - this.handleActionPress = this.handleActionPress.bind(this); - this.options = [I18n.t('Cancel'), I18n.t('Delete'), I18n.t('Resend')]; - this.CANCEL_INDEX = 0; - this.DELETE_INDEX = 1; - this.RESEND_INDEX = 2; - setTimeout(() => { - this.showActionSheet(); - }); - } - - handleResend = protectedFunction(async() => { - const { message, tmid } = this.props; + const handleResend = protectedFunction(async(message) => { await RocketChat.resendMessage(message, tmid); }); - handleDelete = async() => { + const handleDelete = async(message) => { try { - const { message, tmid } = this.props; const db = database.active; const deleteBatch = []; const msgCollection = db.collections.get('messages'); @@ -49,7 +30,7 @@ class MessageErrorActions extends React.Component { try { const msg = await msgCollection.find(message.id); deleteBatch.push(msg.prepareDestroyPermanently()); - } catch (error) { + } catch { // Do nothing: message not found } @@ -68,7 +49,7 @@ class MessageErrorActions extends React.Component { // If the whole thread was removed, delete the thread const thread = await threadCollection.find(tmid); deleteBatch.push(thread.prepareDestroyPermanently()); - } catch (error) { + } catch { // Do nothing: thread not found } } else { @@ -78,7 +59,7 @@ class MessageErrorActions extends React.Component { }) ); } - } catch (error) { + } catch { // Do nothing: message not found } } @@ -88,39 +69,34 @@ class MessageErrorActions extends React.Component { } catch (e) { log(e); } - } + }; - showActionSheet = () => { - ActionSheet.showActionSheetWithOptions({ - options: this.options, - cancelButtonIndex: this.CANCEL_INDEX, - destructiveButtonIndex: this.DELETE_INDEX, - title: I18n.t('Message_actions') - }, (actionIndex) => { - this.handleActionPress(actionIndex); + const showMessageErrorActions = (message) => { + showActionSheet({ + options: [ + { + title: I18n.t('Resend'), + icon: 'send', + onPress: () => handleResend(message) + }, + { + title: I18n.t('Delete'), + icon: 'trash', + danger: true, + onPress: () => handleDelete(message) + } + ], + hasCancel: true }); - } + }; - handleActionPress = (actionIndex) => { - const { actionsHide } = this.props; - switch (actionIndex) { - case this.RESEND_INDEX: - this.handleResend(); - break; - case this.DELETE_INDEX: - this.handleDelete(); - break; - default: - break; - } - actionsHide(); - } - - render() { - return ( - null - ); - } -} + useImperativeHandle(ref, () => ({ + showMessageErrorActions + })); +}); +MessageErrorActions.propTypes = { + message: PropTypes.object, + tmid: PropTypes.string +}; export default MessageErrorActions; diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index 4d1a88a2c..2a79b19d8 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -405,6 +405,7 @@ export default { Review_app_later: 'Maybe later', Review_app_unable_store: 'Unable to open {{store}}', Review_this_app: 'Review this app', + Remove: 'Remove', Roles: 'Roles', Room_actions: 'Room actions', Room_changed_announcement: 'Room announcement changed to: {{announcement}} by {{userBy}}', @@ -470,6 +471,7 @@ export default { starred: 'starred', Starred: 'Starred', Start_of_conversation: 'Start of conversation', + Start_a_Discussion: 'Start a Discussion', Started_discussion: 'Started a discussion:', Started_call: 'Call started by {{userBy}}', Submit: 'Submit', @@ -482,6 +484,8 @@ export default { Terms_of_Service: ' Terms of Service ', Theme: 'Theme', The_URL_is_invalid: 'Invalid URL or unable to establish a secure connection.\n{{contact}}', + The_user_wont_be_able_to_type_in_roomName: 'The user won\'t be able to type in {{roomName}}', + The_user_will_be_able_to_type_in_roomName: 'The user will be able to type in {{roomName}}', There_was_an_error_while_action: 'There was an error while {{action}}!', This_room_is_blocked: 'This room is blocked', This_room_is_read_only: 'This room is read only', @@ -566,6 +570,7 @@ export default { Your_workspace: 'Your workspace', Version_no: 'Version: {{version}}', You_will_not_be_able_to_recover_this_message: 'You will not be able to recover this message!', + You_will_unset_a_certificate_for_this_server: 'You will unset a certificate for this server', Change_Language: 'Change Language', Crash_report_disclaimer: 'We never track the content of your chats. The crash report only contains relevant information for us in order to identify problems and fix it.', Type_message: 'Type message', @@ -578,6 +583,7 @@ export default { Search_messages: 'Search messages', Scroll_messages: 'Scroll messages', Reply_latest: 'Reply to latest', + Reply_in_Thread: 'Reply in Thread', Server_selection: 'Server selection', Server_selection_numbers: 'Server selection 1...9', Add_server: 'Add server', diff --git a/app/i18n/locales/pt-BR.js b/app/i18n/locales/pt-BR.js index 6c7e06b50..b0766bf2b 100644 --- a/app/i18n/locales/pt-BR.js +++ b/app/i18n/locales/pt-BR.js @@ -365,6 +365,7 @@ export default { Review_app_later: 'Talvez depois', Review_app_unable_store: 'Não foi possível abrir {{store}}', Review_this_app: 'Avaliar esse app', + Remove: 'Remover', Roles: 'Papéis', Room_actions: 'Ações', Room_changed_announcement: 'O anúncio da sala foi alterado para: {{announcement}} por {{userBy}}', @@ -426,6 +427,8 @@ export default { Take_a_video: 'Gravar um vídeo', Terms_of_Service: ' Termos de Serviço ', Theme: 'Tema', + The_user_wont_be_able_to_type_in_roomName: 'O usuário não poderá digitar em {{roomName}}', + The_user_will_be_able_to_type_in_roomName: 'O usuário poderá digitar em {{roomName}}', The_URL_is_invalid: 'A URL fornecida é inválida ou incapaz de estabelecer uma conexão segura.\n{{contact}}', There_was_an_error_while_action: 'Aconteceu um erro {{action}}!', This_room_is_blocked: 'Este quarto está bloqueado', @@ -497,6 +500,7 @@ export default { Your_invite_link_will_never_expire: 'Seu link de convite nunca irá vencer.', Your_workspace: 'Sua workspace', You_will_not_be_able_to_recover_this_message: 'Você não será capaz de recuperar essa mensagem!', + You_will_unset_a_certificate_for_this_server: 'Você cancelará a configuração de um certificado para este servidor', Would_you_like_to_return_the_inquiry: 'Deseja retornar a consulta?', Write_External_Permission_Message: 'Rocket Chat precisa de acesso à sua galeria para salvar imagens', Write_External_Permission: 'Acesso à Galeria', diff --git a/app/theme.js b/app/theme.js index dac1b625d..c34f56de0 100644 --- a/app/theme.js +++ b/app/theme.js @@ -12,3 +12,5 @@ export function withTheme(Component) { hoistNonReactStatics(ThemedComponent, Component); return ThemedComponent; } + +export const useTheme = () => React.useContext(ThemeContext); diff --git a/app/views/MessagesView/index.js b/app/views/MessagesView/index.js index 9e637434c..c01b63c25 100644 --- a/app/views/MessagesView/index.js +++ b/app/views/MessagesView/index.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import { FlatList, View, Text } from 'react-native'; import { connect } from 'react-redux'; import equal from 'deep-equal'; -import ActionSheet from 'react-native-action-sheet'; import styles from './styles'; import Message from '../../containers/message'; @@ -15,11 +14,9 @@ import getFileUrlFromMessage from '../../lib/methods/helpers/getFileUrlFromMessa import { themes } from '../../constants/colors'; import { withTheme } from '../../theme'; import { getUserSelector } from '../../selectors/login'; +import { withActionSheet } from '../../containers/ActionSheet'; import SafeAreaView from '../../containers/SafeAreaView'; -const ACTION_INDEX = 0; -const CANCEL_INDEX = 1; - class MessagesView extends React.Component { static navigationOptions = ({ route }) => ({ title: I18n.t(route.params?.name) @@ -31,7 +28,8 @@ class MessagesView extends React.Component { navigation: PropTypes.object, route: PropTypes.object, customEmojis: PropTypes.object, - theme: PropTypes.string + theme: PropTypes.string, + showActionSheet: PropTypes.func } constructor(props) { @@ -162,7 +160,7 @@ class MessagesView extends React.Component { theme={theme} /> ), - actionTitle: I18n.t('Unstar'), + action: message => ({ title: I18n.t('Unstar'), icon: message.starred ? 'star-filled' : 'star', onPress: this.handleActionPress }), handleActionPress: message => RocketChat.toggleStarMessage(message._id, message.starred) }, // Pinned Messages Screen @@ -179,7 +177,7 @@ class MessagesView extends React.Component { theme={theme} /> ), - actionTitle: I18n.t('Unpin'), + action: () => ({ title: I18n.t('Unpin'), icon: 'pin', onPress: this.handleActionPress }), handleActionPress: message => RocketChat.togglePinMessage(message._id, message.pinned) } }[name]); @@ -225,35 +223,28 @@ class MessagesView extends React.Component { } onLongPress = (message) => { - this.setState({ message }); - this.showActionSheet(); + this.setState({ message }, this.showActionSheet); } showActionSheet = () => { - ActionSheet.showActionSheetWithOptions({ - options: [this.content.actionTitle, I18n.t('Cancel')], - cancelButtonIndex: CANCEL_INDEX, - title: I18n.t('Actions') - }, (actionIndex) => { - this.handleActionPress(actionIndex); - }); + const { message } = this.state; + const { showActionSheet } = this.props; + showActionSheet({ options: [this.content.action(message)], hasCancel: true }); } - handleActionPress = async(actionIndex) => { - if (actionIndex === ACTION_INDEX) { - const { message } = this.state; + handleActionPress = async() => { + const { message } = this.state; - try { - const result = await this.content.handleActionPress(message); - if (result.success) { - this.setState(prevState => ({ - messages: prevState.messages.filter(item => item._id !== message._id), - total: prevState.total - 1 - })); - } - } catch (error) { - console.warn('MessagesView -> handleActionPress -> catch -> error', error); + try { + const result = await this.content.handleActionPress(message); + if (result.success) { + this.setState(prevState => ({ + messages: prevState.messages.filter(item => item._id !== message._id), + total: prevState.total - 1 + })); } + } catch { + // Do nothing } } @@ -312,4 +303,4 @@ const mapStateToProps = state => ({ customEmojis: state.customEmojis }); -export default connect(mapStateToProps)(withTheme(MessagesView)); +export default connect(mapStateToProps)(withTheme(withActionSheet(MessagesView))); diff --git a/app/views/NewServerView.js b/app/views/NewServerView.js index 80c436436..b9429c05a 100644 --- a/app/views/NewServerView.js +++ b/app/views/NewServerView.js @@ -6,7 +6,6 @@ import { import { connect } from 'react-redux'; import * as FileSystem from 'expo-file-system'; import DocumentPicker from 'react-native-document-picker'; -import ActionSheet from 'react-native-action-sheet'; import RNUserDefaults from 'rn-user-defaults'; import { encode } from 'base-64'; import parse from 'url-parse'; @@ -26,6 +25,7 @@ import { animateNextTransition } from '../utils/layoutAnimation'; import { withTheme } from '../theme'; import { setBasicAuth, BASIC_AUTH_KEY } from '../utils/fetch'; import { CloseModalButton } from '../containers/HeaderButton'; +import { showConfirmationAlert } from '../utils/info'; const styles = StyleSheet.create({ title: { @@ -83,14 +83,6 @@ class NewServerView extends React.Component { }); } - // Cancel - this.options = [I18n.t('Cancel')]; - this.CANCEL_INDEX = 0; - - // Delete - this.options.push(I18n.t('Delete')); - this.DELETE_INDEX = 1; - this.state = { text: '', connectingOpen: false, @@ -233,15 +225,11 @@ class NewServerView extends React.Component { this.setState({ certificate }); } - handleDelete = () => this.setState({ certificate: null }); // We not need delete file from DocumentPicker because it is a temp file - - showActionSheet = () => { - ActionSheet.showActionSheetWithOptions({ - options: this.options, - cancelButtonIndex: this.CANCEL_INDEX, - destructiveButtonIndex: this.DELETE_INDEX - }, (actionIndex) => { - if (actionIndex === this.DELETE_INDEX) { this.handleDelete(); } + handleRemove = () => { + showConfirmationAlert({ + message: I18n.t('You_will_unset_a_certificate_for_this_server'), + callToAction: I18n.t('Remove'), + onPress: this.setState({ certificate: null }) // We not need delete file from DocumentPicker because it is a temp file }); } @@ -259,7 +247,7 @@ class NewServerView extends React.Component { {certificate ? I18n.t('Your_certificate') : I18n.t('Do_you_have_a_certificate')} m === user.username); user.muted = userIsMuted; - if (userIsMuted) { - this.actionSheetOptions.push(I18n.t('Unmute')); - } else { - this.actionSheetOptions.push(I18n.t('Mute')); - } - this.setState({ userLongPressed: user }); - Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - this.showActionSheet(); + + showActionSheet({ + options: [{ + icon: userIsMuted ? 'volume' : 'volume-off', + title: I18n.t(userIsMuted ? 'Unmute' : 'Mute'), + onPress: () => { + showConfirmationAlert({ + message: I18n.t(`The_user_${ userIsMuted ? 'will' : 'wont' }_be_able_to_type_in_roomName`, { + roomName: RocketChat.getRoomTitle(room) + }), + callToAction: I18n.t(userIsMuted ? 'Unmute' : 'Mute'), + onPress: () => this.handleMute(user) + }); + } + }], + hasCancel: true + }); } toggleStatus = () => { @@ -166,16 +173,6 @@ class RoomMembersView extends React.Component { } } - showActionSheet = () => { - ActionSheet.showActionSheetWithOptions({ - options: this.actionSheetOptions, - cancelButtonIndex: this.CANCEL_INDEX, - title: I18n.t('Actions') - }, (actionIndex) => { - this.handleActionPress(actionIndex); - }); - } - // eslint-disable-next-line react/sort-comp fetchMembers = async() => { const { @@ -211,26 +208,16 @@ class RoomMembersView extends React.Component { goRoom({ item, isMasterDetail }); } - handleMute = async() => { - const { rid, userLongPressed } = this.state; + handleMute = async(user) => { + const { rid } = this.state; try { - await RocketChat.toggleMuteUserInRoom(rid, userLongPressed.username, !userLongPressed.muted); - EventEmitter.emit(LISTENER, { message: I18n.t('User_has_been_key', { key: userLongPressed.muted ? I18n.t('unmuted') : I18n.t('muted') }) }); + await RocketChat.toggleMuteUserInRoom(rid, user?.username, !user?.muted); + EventEmitter.emit(LISTENER, { message: I18n.t('User_has_been_key', { key: user?.muted ? I18n.t('unmuted') : I18n.t('muted') }) }); } catch (e) { log(e); } } - handleActionPress = (actionIndex) => { - switch (actionIndex) { - case this.MUTE_INDEX: - this.handleMute(); - break; - default: - break; - } - } - renderSearchBar = () => ( this.onSearchChangeText(text)} testID='room-members-view-search' /> ) @@ -295,4 +282,4 @@ const mapStateToProps = state => ({ isMasterDetail: state.app.isMasterDetail }); -export default connect(mapStateToProps)(withTheme(RoomMembersView)); +export default connect(mapStateToProps)(withActionSheet(withTheme(RoomMembersView))); diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index e4cd7e278..941ad5e5d 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -58,8 +58,7 @@ const stateAttrsUpdate = [ 'lastOpen', 'reactionsModalVisible', 'canAutoTranslate', - 'showActions', - 'showErrorActions', + 'selectedMessage', 'loading', 'editing', 'replying', @@ -117,8 +116,6 @@ class RoomView extends React.Component { selectedMessage: selectedMessage || {}, canAutoTranslate: false, loading: true, - showActions: false, - showErrorActions: false, editing: false, replying: !!selectedMessage, replyWithMention: false, @@ -501,23 +498,11 @@ class RoomView extends React.Component { } errorActionsShow = (message) => { - this.setState({ selectedMessage: message, showErrorActions: true }); - } - - onActionsHide = () => { - const { editing, replying, reacting } = this.state; - if (editing || replying || reacting) { - return; - } - this.setState({ selectedMessage: {}, showActions: false }); - } - - onErrorActionsHide = () => { - this.setState({ selectedMessage: {}, showErrorActions: false }); + this.messageErrorActions?.showMessageErrorActions(message); } onEditInit = (message) => { - this.setState({ selectedMessage: message, editing: true, showActions: false }); + this.setState({ selectedMessage: message, editing: true }); } onEditCancel = () => { @@ -535,7 +520,7 @@ class RoomView extends React.Component { onReplyInit = (message, mention) => { this.setState({ - selectedMessage: message, replying: true, showActions: false, replyWithMention: mention + selectedMessage: message, replying: true, replyWithMention: mention }); } @@ -544,7 +529,7 @@ class RoomView extends React.Component { } onReactionInit = (message) => { - this.setState({ selectedMessage: message, reacting: true, showActions: false }); + this.setState({ selectedMessage: message, reacting: true }); } onReactionClose = () => { @@ -552,7 +537,7 @@ class RoomView extends React.Component { } onMessageLongPress = (message) => { - this.setState({ selectedMessage: message, showActions: true }); + this.messageActions?.showMessageActions(message); } showAttachment = (attachment) => { @@ -942,9 +927,7 @@ class RoomView extends React.Component { }; renderActions = () => { - const { - room, selectedMessage, showActions, showErrorActions, joined, readOnly - } = this.state; + const { room, readOnly } = this.state; const { user, navigation } = this.props; @@ -953,29 +936,21 @@ class RoomView extends React.Component { } return ( <> - {joined && showActions - ? ( - - ) - : null - } - {showErrorActions ? ( - - ) : null} + this.messageActions = ref} + tmid={this.tmid} + room={room} + user={user} + editInit={this.onEditInit} + replyInit={this.onReplyInit} + reactionInit={this.onReactionInit} + onReactionPress={this.onReactionPress} + isReadOnly={readOnly} + /> + this.messageErrorActions = ref} + tmid={this.tmid} + /> ); } diff --git a/e2e/tests/room/02-room.spec.js b/e2e/tests/room/02-room.spec.js index 105a1122a..f307e4369 100644 --- a/e2e/tests/room/02-room.spec.js +++ b/e2e/tests/room/02-room.spec.js @@ -151,48 +151,54 @@ describe('Room screen', () => { describe('Message', async() => { it('should copy permalink', async() => { - await sleep(1000); - await element(by.label(`${ data.random }message`)).atIndex(0).tap(); await element(by.label(`${ data.random }message`)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Permalink')).tap(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Permalink')).tap(); await sleep(1000); - + // TODO: test clipboard }); - + it('should copy message', async() => { await element(by.label(`${ data.random }message`)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Copy')).tap(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Copy')).tap(); await sleep(1000); + // TODO: test clipboard }); - + it('should star message', async() => { await element(by.label(`${ data.random }message`)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Star')).tap(); - await sleep(2000); - await waitFor(element(by.text('Message actions'))).toBeNotVisible().withTimeout(5000); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Star')).tap(); + await sleep(1000); + await waitFor(element(by.id('action-sheet'))).toNotExist().withTimeout(5000); + await element(by.label(`${ data.random }message`)).atIndex(0).longPress(); - await waitFor(element(by.text('Unstar'))).toExist().withTimeout(2000); - await expect(element(by.text('Unstar'))).toExist(); - await element(by.text('Cancel')).tap(); - await waitFor(element(by.text('Cancel'))).toBeNotVisible().withTimeout(2000); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await waitFor(element(by.label('Unstar'))).toBeVisible().withTimeout(2000); + await expect(element(by.label('Unstar'))).toBeVisible(); + await element(by.id('action-sheet-backdrop')).tap(); await sleep(1000); }); - + it('should react to message', async() => { await element(by.label(`${ data.random }message`)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Add Reaction')).tap(); - await waitFor(element(by.id('reaction-picker'))).toExist().withTimeout(2000); - await expect(element(by.id('reaction-picker'))).toExist(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.id('add-reaction')).tap(); + await waitFor(element(by.id('reaction-picker'))).toBeVisible().withTimeout(2000); + await expect(element(by.id('reaction-picker'))).toBeVisible(); await element(by.id('reaction-picker-😃')).tap(); await waitFor(element(by.id('reaction-picker-grinning'))).toExist().withTimeout(2000); await expect(element(by.id('reaction-picker-grinning'))).toExist(); @@ -202,6 +208,19 @@ describe('Room screen', () => { await sleep(1000); }); + it('should react to message with frequently used emoji', async() => { + await element(by.label(`${ data.random }message`)).atIndex(0).longPress(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await waitFor(element(by.id('message-actions-emoji-+1'))).toBeVisible().withTimeout(2000); + await expect(element(by.id('message-actions-emoji-+1'))).toBeVisible(); + await element(by.id('message-actions-emoji-+1')).tap(); + await waitFor(element(by.id('message-reaction-:+1:'))).toBeVisible().withTimeout(60000); + await expect(element(by.id('message-reaction-:+1:'))).toBeVisible(); + await sleep(1000); + }); + it('should show reaction picker on add reaction button pressed and have frequently used emoji', async() => { await element(by.id('message-add-reaction')).tap(); await waitFor(element(by.id('reaction-picker'))).toExist().withTimeout(2000); @@ -214,54 +233,76 @@ describe('Room screen', () => { await waitFor(element(by.id('message-reaction-:grimacing:'))).toExist().withTimeout(60000); await sleep(1000); }); - + it('should remove reaction', async() => { await element(by.id('message-reaction-:grinning:')).tap(); await waitFor(element(by.id('message-reaction-:grinning:'))).toBeNotVisible().withTimeout(60000); await expect(element(by.id('message-reaction-:grinning:'))).toBeNotVisible(); }); - + it('should edit message', async() => { await mockMessage('edit'); await element(by.label(`${ data.random }edit`)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Edit')).tap(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Edit')).tap(); await element(by.id('messagebox-input')).typeText('ed'); await element(by.id('messagebox-send-message')).tap(); await waitFor(element(by.label(`${ data.random }edited (edited)`)).atIndex(0)).toExist().withTimeout(60000); await expect(element(by.label(`${ data.random }edited (edited)`)).atIndex(0)).toExist(); }); - + it('should quote message', async() => { await mockMessage('quote'); await element(by.label(`${ data.random }quote`)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Quote')).tap(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Quote')).tap(); await element(by.id('messagebox-input')).typeText(`${ data.random }quoted`); await element(by.id('messagebox-send-message')).tap(); + await sleep(1000); + // TODO: test if quote was sent - await sleep(2000); }); - + it('should pin message', async() => { await waitFor(element(by.label(`${ data.random }edited (edited)`)).atIndex(0)).toExist(); await element(by.label(`${ data.random }edited (edited)`)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Pin')).tap(); - await waitFor(element(by.text('Message actions'))).toBeNotVisible().withTimeout(5000); - await waitFor(element(by.label('Message pinned')).atIndex(0)).toExist().withTimeout(5000); - await waitFor(element(by.label(`${ data.random }edited (edited)`)).atIndex(0)).toExist().withTimeout(60000); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Pin')).tap(); + await waitFor(element(by.id('action-sheet'))).toNotExist().withTimeout(5000); + await sleep(1500); + + await waitFor(element(by.label(`${ data.random }edited (edited)`)).atIndex(0)).toBeVisible(); await element(by.label(`${ data.random }edited (edited)`)).atIndex(0).longPress(); - await waitFor(element(by.text('Unpin'))).toExist().withTimeout(2000); - await expect(element(by.text('Unpin'))).toExist(); - await element(by.text('Cancel')).tap(); - await waitFor(element(by.text('Cancel'))).toBeNotVisible().withTimeout(2000); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await waitFor(element(by.label('Unpin'))).toBeVisible().withTimeout(2000); + await expect(element(by.label('Unpin'))).toBeVisible(); + await element(by.id('action-sheet-backdrop')).tap(); }); - // TODO: delete message - swipe on action sheet missing + it('should delete message', async() => { + await waitFor(element(by.label(`${ data.random }quoted`)).atIndex(0)).toBeVisible(); + await element(by.label(`${ data.random }quoted`)).atIndex(0).longPress(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Delete')).tap(); + + const deleteAlertMessage = 'You will not be able to recover this message!'; + await waitFor(element(by.text(deleteAlertMessage)).atIndex(0)).toExist().withTimeout(10000); + await expect(element(by.text(deleteAlertMessage)).atIndex(0)).toExist(); + await element(by.text('Delete')).tap(); + + await sleep(1000); + await expect(element(by.label(`${ data.random }quoted`)).atIndex(0)).toNotExist(); + }); }); describe('Thread', async() => { @@ -269,9 +310,10 @@ describe('Room screen', () => { it('should create thread', async() => { await mockMessage('thread'); await element(by.label(thread)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Reply')).tap(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Reply in Thread')).tap(); await element(by.id('messagebox-input')).typeText('replied'); await element(by.id('messagebox-send-message')).tap(); await waitFor(element(by.id(`message-thread-button-${ thread }`))).toExist().withTimeout(5000); @@ -305,9 +347,10 @@ describe('Room screen', () => { it('should navigate to thread from thread name', async() => { await mockMessage('dummymessagebetweenthethread'); await element(by.label(thread)).atIndex(0).longPress(); - await waitFor(element(by.text('Message actions'))).toExist().withTimeout(5000); - await expect(element(by.text('Message actions'))).toExist(); - await element(by.text('Reply')).tap(); + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by.label('Reply in Thread')).tap(); await element(by.id('messagebox-input')).typeText('repliedagain'); await element(by.id('messagebox-send-message')).tap(); await waitFor(element(by.id(`message-thread-replied-on-${ thread }`))).toExist().withTimeout(5000); diff --git a/e2e/tests/room/03-roomactions.spec.js b/e2e/tests/room/03-roomactions.spec.js index 6f970c2bc..83f989b0e 100644 --- a/e2e/tests/room/03-roomactions.spec.js +++ b/e2e/tests/room/03-roomactions.spec.js @@ -210,12 +210,14 @@ describe('Room actions screen', () => { await element(by.id('room-actions-starred')).tap(); await waitFor(element(by.id('starred-messages-view'))).toExist().withTimeout(2000); await sleep(1000); - await waitFor(element(by.label(`${ data.random }message`).withAncestor(by.id('starred-messages-view')))).toExist().withTimeout(60000); - await expect(element(by.label(`${ data.random }message`).withAncestor(by.id('starred-messages-view')))).toExist(); + await waitFor(element(by.label(`${ data.random }message`).withAncestor(by.id('starred-messages-view')))).toBeVisible().withTimeout(60000); + await expect(element(by.label(`${ data.random }message`).withAncestor(by.id('starred-messages-view')))).toBeVisible(); await element(by.label(`${ data.random }message`).withAncestor(by.id('starred-messages-view'))).longPress(); - await waitFor(element(by.text('Unstar'))).toExist().withTimeout(2000); - await expect(element(by.text('Unstar'))).toExist(); - await element(by.text('Unstar')).tap(); + + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.label('Unstar')).tap(); + await waitFor(element(by.label(`${ data.random }message`).withAncestor(by.id('starred-messages-view')))).toBeNotVisible().withTimeout(60000); await expect(element(by.label(`${ data.random }message`).withAncestor(by.id('starred-messages-view')))).toBeNotVisible(); await backToActions(); @@ -226,12 +228,14 @@ describe('Room actions screen', () => { await element(by.id('room-actions-pinned')).tap(); await waitFor(element(by.id('pinned-messages-view'))).toExist().withTimeout(2000); await sleep(1000); - await waitFor(element(by.label(`${ data.random }edited (edited)`).withAncestor(by.id('pinned-messages-view')))).toExist().withTimeout(60000); - await expect(element(by.label(`${ data.random }edited (edited)`).withAncestor(by.id('pinned-messages-view')))).toExist(); + await waitFor(element(by.label(`${ data.random }edited (edited)`).withAncestor(by.id('pinned-messages-view')))).toBeVisible().withTimeout(60000); + await expect(element(by.label(`${ data.random }edited (edited)`).withAncestor(by.id('pinned-messages-view')))).toBeVisible(); await element(by.label(`${ data.random }edited (edited)`).withAncestor(by.id('pinned-messages-view'))).longPress(); - await waitFor(element(by.text('Unpin'))).toExist().withTimeout(2000); - await expect(element(by.text('Unpin'))).toExist(); - await element(by.text('Unpin')).tap(); + + await expect(element(by.id('action-sheet'))).toExist(); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.label('Unpin')).tap(); + await waitFor(element(by.label(`${ data.random }edited (edited)`).withAncestor(by.id('pinned-messages-view')))).toBeNotVisible().withTimeout(60000); await expect(element(by.label(`${ data.random }edited (edited)`).withAncestor(by.id('pinned-messages-view')))).toBeNotVisible(); await backToActions(); diff --git a/package.json b/package.json index 2c800cc4f..b95a6010f 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@react-native-community/async-storage": "^1.9.0", "@react-native-community/cameraroll": "1.6.0", "@react-native-community/datetimepicker": "2.3.2", + "@react-native-community/hooks": "^2.5.1", "@react-native-community/masked-view": "^0.1.10", "@react-native-community/slider": "2.0.9", "@react-navigation/drawer": "5.8.1", @@ -57,7 +58,6 @@ "prop-types": "15.7.2", "react": "16.11.0", "react-native": "0.62.2", - "react-native-action-sheet": "^2.2.0", "react-native-animatable": "^1.3.3", "react-native-appearance": "0.3.4", "react-native-audio": "^4.3.0", @@ -93,6 +93,7 @@ "react-native-responsive-ui": "^1.1.1", "react-native-safe-area-context": "^3.0.2", "react-native-screens": "^2.7.0", + "react-native-scroll-bottom-sheet": "0.6.1", "react-native-scrollable-tab-view": "^1.0.0", "react-native-slowlog": "^1.0.2", "react-native-unimodules": "0.9.1", diff --git a/patches/react-native-scroll-bottom-sheet+0.6.1.patch b/patches/react-native-scroll-bottom-sheet+0.6.1.patch new file mode 100644 index 000000000..9a1f4211d --- /dev/null +++ b/patches/react-native-scroll-bottom-sheet+0.6.1.patch @@ -0,0 +1,42 @@ +diff --git a/node_modules/react-native-scroll-bottom-sheet/src/index.tsx b/node_modules/react-native-scroll-bottom-sheet/src/index.tsx +index c571856..d7179c6 100644 +--- a/node_modules/react-native-scroll-bottom-sheet/src/index.tsx ++++ b/node_modules/react-native-scroll-bottom-sheet/src/index.tsx +@@ -518,6 +518,28 @@ export class ScrollBottomSheet extends Component> { + clockRunning(this.animationClock) + ), + [ ++ this.didScrollUpAndPullDown, ++ this.setTranslationY, ++ set(this.tempDestSnapPoint, add(snapPoints[0], this.extraOffset)), ++ cond(not(this.isManuallySetValue), set(this.nextSnapIndex, 0)), ++ set( ++ this.destSnapPoint, ++ cond( ++ this.isManuallySetValue, ++ this.manualYOffset, ++ this.calculateNextSnapPoint() ++ ) ++ ), ++ cond(this.isManuallySetValue, [ ++ set(this.animationFinished, 0) ++ ]), ++ set( ++ this.lastSnap, ++ sub( ++ this.destSnapPoint, ++ cond(eq(this.scrollUpAndPullDown, 1), this.lastStartScrollY, 0) ++ ) ++ ), + runTiming({ + clock: this.animationClock, + from: cond( +@@ -550,7 +572,7 @@ export class ScrollBottomSheet extends Component> { + ); + + this.position = interpolate(this.translateY, { +- inputRange: [openPosition, closedPosition], ++ inputRange: [snapPoints[snapPoints.length - 2], closedPosition], + outputRange: [1, 0], + extrapolate: Extrapolate.CLAMP, + }); diff --git a/yarn.lock b/yarn.lock index 88acc194f..831aa2a9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1749,6 +1749,11 @@ dependencies: invariant "^2.2.4" +"@react-native-community/hooks@^2.5.1": + version "2.5.1" + resolved "https://registry.yarnpkg.com/@react-native-community/hooks/-/hooks-2.5.1.tgz#545c76d1a6203532a8e776578bbaaa64bb754cf6" + integrity sha512-P9gwIUGpa/h8p5ASwY8QFTthXw/e/rt4mzZRfe3Xh5L13mTuOFXsYVwe9f8JAUx512cUKUsdTg6Dsg3/jTlxeg== + "@react-native-community/masked-view@^0.1.10": version "0.1.10" resolved "https://registry.yarnpkg.com/@react-native-community/masked-view/-/masked-view-0.1.10.tgz#5dda643e19e587793bc2034dd9bf7398ad43d401" @@ -11720,11 +11725,6 @@ react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== -react-native-action-sheet@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/react-native-action-sheet/-/react-native-action-sheet-2.2.0.tgz#309a87f53bf4e7b17fdd9d24b10b8dcbaebb7230" - integrity sha512-4lsuxH+Cn3/aUEs1VCwqvLhEFyXNqYTkT67CzgTwlifD9Ij4OPQAIs8D+HUD9zBvWc4NtT6cyG1lhArPVMQeVw== - react-native-animatable@1.3.3, react-native-animatable@^1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/react-native-animatable/-/react-native-animatable-1.3.3.tgz#a13a4af8258e3bb14d0a9d839917e9bb9274ec8a" @@ -11963,6 +11963,13 @@ react-native-screens@^2.7.0: resolved "https://registry.yarnpkg.com/react-native-screens/-/react-native-screens-2.7.0.tgz#2d3cf3c39a665e9ca1c774264fccdb90e7944047" integrity sha512-n/23IBOkrTKCfuUd6tFeRkn3lB2QZ3cmvoubRscR0JU/Zl4/ZyKmwnFmUv1/Fr+2GH/H8UTX59kEKDYYg3dMgA== +react-native-scroll-bottom-sheet@0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/react-native-scroll-bottom-sheet/-/react-native-scroll-bottom-sheet-0.6.1.tgz#7fa6a4f1104417e4e9bf4b10efffc46d501aeeb4" + integrity sha512-Glws8msLrbKDW5a53rCeN0pLNI41Yhvz7K7OWZnaVYLs3GPTP2ySYdJ849rd/d5P1P0xqFyKEF/0p+/KLI0nYA== + dependencies: + utility-types "^3.10.0" + react-native-scrollable-tab-view@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/react-native-scrollable-tab-view/-/react-native-scrollable-tab-view-1.0.0.tgz#87319896067f7bb643ecd7fba2cba4d6d8f9e18b" @@ -14395,6 +14402,11 @@ utila@^0.4.0, utila@~0.4: resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" integrity sha1-ihagXURWV6Oupe7MWxKk+lN5dyw= +utility-types@^3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b" + integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== + utils-merge@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"