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"