[NEW] Action Sheet (#2114)

* [WIP] New Action Sheet

* [NEW] Header Indicator

* [IMPROVEMENT] Header Logic

* [NEW] Use EventEmitter to show ActionSheet for while

* [FIX] Animation

* [IMPROVEMENT] Use provider

* [FIX] Add callback

* [FIX] Message Actions

* [FIX] Add MessageActions icons

* [NEW] MessageErrorActions

* [IMPROVEMENT] OnClose

* [FIX] Adjust height

* [FIX] Close/Reopen

* [CHORE] Remove react-native-action-sheet

* [CHORE] Move ActionSheet

* [FIX] Reply Message

* [IMPROVEMENT] Hide ActionSheet logic

* [WIP] Custom MessageActions Header

* [IMPROVEMENT] MessageActions Header

* [IMPROVEMENT] Enable Scroll

* [FIX] Scroll on Android

* Move to react-native-scroll-bottom-sheet

* Stash

* Refactor actions

* Revert some changes

* Trying react-native-modalize

* Back to HOC

* ActionSheet style

* HOC Header

* Reaction actionSheet

* Fix messageBox actions

* Fix add reaction

* Change to flatListProps

* fix modalize android scroll

* Use react-native-scroll-bottom-sheet

* [NEW] BottomSheet dismissable & [FIX] Android not opening

* [NEW] Show emojis based on screen width

* [WIP] Adjust to content height

* [IMPROVEMENT] Responsible

* [IMPROVEMENT] Use alert instead actionSheet at NewServerView

* [FIX] Handle tablet cases

* [IMPROVEMENT] Remove actionSheet of RoomMembersView

* [IMPROVEMENT] Min snap distance when its portrait

* [CHORE] Remove unused Components

* [IMPROVEMENT] Remove duplicated add-reaction

* [IMPROVEMENT] Refactor Icon Package

* [IMPROVEMENT] Use new icons

* [FIX] Select message at MessageActions before getOptions

* [FIX] Custom header height

* [CHORE] Remove patch & [FIX] Tablet bottom sheet

* [FIX] Use ListItem height to BottomSheet Height

* Some fixes

* [FIX] Custom MessageActions header

* [FIX] Android height adjust

* [IMPROVEMENT] Item touchable & [FIX] Respect pin permission

* [IMPROVEMENT] More than one snap point

* some size fixes

* improve code

* hide horizontal scroll indicator

* [FIX] Focus MessageBox on edit message

* [FIX] Ripple color

* [IMPROVEMENT] Backdrop must keep same opacity after 50% of the screen

* [TEST] Change animation config

* [IMPROVEMENT] BackHandler should close the ActionSheet

* [CHORE] Add react-native-safe-area-context

* [FIX] Provide a bottom padding at notch devices

* [IMPROVEMENT] Improve backdrop input/output range

* [FIX] Weird Android Snap behavior

* [PATCH] React-native-scroll-bottom-sheet

* [CI] Re-run build

* [FIX] Landscape android

* [IMPROVEMENT] Cover 50% of the screen at the landscape mode

* [FIX] Adjust emoji content to width size

* [IMPROVEMENT] Use hooks library

* [IMPROVEMENT] Close the actionSheet when orientation change

* deactivate safe-area-context for while

* [REVERT] Re-add react-native-safe-area-context (3.0.2)

* [IMPROVEMENT] Use focused background

* [TESTS] E2E Tests updated to new BottomSheet

* [NEW] Add cancel button

* [FIX] Cancel button at android

* [IMPROVEMENT] Use cancelable bottom sheet at room members view

* [IMPROVEMENT] Use better function names

* [IMPROVEMENT] Use getItemLayout

* [FIX][TEMP] Animation

* Review

* Build

* Header keyExtractor

* Rename function

* Tweak animation

* Refactoring

* useTheme

* Refactoring

* TestIDs

* Refactor

* Remove old lib

Co-authored-by: Diego Mello <diegolmello@gmail.com>
This commit is contained in:
Djorkaeff Alexandre 2020-06-15 16:35:45 -03:00 committed by GitHub
parent 98ed84ba5c
commit 893acdcd3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1336 additions and 827 deletions

View File

@ -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 (
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
<NavigationContainer
theme={navTheme}
ref={Navigation.navigationRef}
onStateChange={(state) => {
const previousRouteName = Navigation.routeNameRef.current;
const currentRouteName = getActiveRouteName(state);
if (previousRouteName !== currentRouteName) {
setCurrentScreen(currentRouteName);
}
Navigation.routeNameRef.current = currentRouteName;
}}
>
<Stack.Navigator screenOptions={{ headerShown: false, animationEnabled: false }}>
<>
{root === ROOT_LOADING || root === ROOT_BACKGROUND ? (
<Stack.Screen
name='AuthLoading'
component={AuthLoadingView}
/>
) : null}
{root === ROOT_OUTSIDE || root === ROOT_NEW_SERVER ? (
<Stack.Screen
name='OutsideStack'
component={OutsideStack}
/>
) : null}
{root === ROOT_INSIDE && isMasterDetail ? (
<Stack.Screen
name='MasterDetailStack'
component={MasterDetailStack}
/>
) : null}
{root === ROOT_INSIDE && !isMasterDetail ? (
<Stack.Screen
name='InsideStack'
component={InsideStack}
/>
) : null}
{root === ROOT_SET_USERNAME ? (
<Stack.Screen
name='SetUsernameStack'
component={SetUsernameStack}
/>
) : null}
</>
</Stack.Navigator>
</NavigationContainer>
<ActionSheetProvider>
<NavigationContainer
theme={navTheme}
ref={Navigation.navigationRef}
onStateChange={(state) => {
const previousRouteName = Navigation.routeNameRef.current;
const currentRouteName = getActiveRouteName(state);
if (previousRouteName !== currentRouteName) {
setCurrentScreen(currentRouteName);
}
Navigation.routeNameRef.current = currentRouteName;
}}
>
<Stack.Navigator screenOptions={{ headerShown: false, animationEnabled: false }}>
<>
{root === ROOT_LOADING || root === ROOT_BACKGROUND ? (
<Stack.Screen
name='AuthLoading'
component={AuthLoadingView}
/>
) : null}
{root === ROOT_OUTSIDE || root === ROOT_NEW_SERVER ? (
<Stack.Screen
name='OutsideStack'
component={OutsideStack}
/>
) : null}
{root === ROOT_INSIDE && isMasterDetail ? (
<Stack.Screen
name='MasterDetailStack'
component={MasterDetailStack}
/>
) : null}
{root === ROOT_INSIDE && !isMasterDetail ? (
<Stack.Screen
name='InsideStack'
component={InsideStack}
/>
) : null}
{root === ROOT_SET_USERNAME ? (
<Stack.Screen
name='SetUsernameStack'
component={SetUsernameStack}
/>
) : null}
</>
</Stack.Navigator>
</NavigationContainer>
</ActionSheetProvider>
</SafeAreaProvider>
);
});

View File

@ -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(() => (
<>
<Handle theme={theme} />
{isValidElement(data?.customHeader) ? data.customHeader : null}
</>
));
const renderFooter = useCallback(() => (data?.hasCancel ? (
<Button
onPress={hide}
style={[styles.button, { backgroundColor: themes[theme].auxiliaryBackground }]}
theme={theme}
>
<Text style={[styles.text, { color: themes[theme].bodyText }]}>
{I18n.t('Cancel')}
</Text>
</Button>
) : null));
const renderSeparator = useCallback(() => <Separator theme={theme} style={styles.separator} />);
const renderItem = useCallback(({ item }) => <Item item={item} hide={hide} theme={theme} />);
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 && (
<>
<TapGestureHandler onHandlerStateChange={onBackdropPressed}>
<Animated.View
testID='action-sheet-backdrop'
style={[
styles.backdrop,
{
backgroundColor: themes[theme].backdropColor,
opacity
}
]}
/>
</TapGestureHandler>
<ScrollBottomSheet
testID='action-sheet'
ref={bottomSheetRef}
componentType='FlatList'
snapPoints={snaps}
initialSnapIndex={closedSnapIndex}
renderHandle={renderHandle}
onSettle={index => (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;

View File

@ -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;

View File

@ -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 }) => (
<View style={[styles.handle, { backgroundColor: themes[theme].focusedBackground }]} testID='action-sheet-handle'>
<View style={[styles.handleIndicator, { backgroundColor: themes[theme].auxiliaryText }]} />
</View>
));
Handle.propTypes = {
theme: PropTypes.string
};

View File

@ -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 (
<Button
onPress={onPress}
style={[styles.item, { backgroundColor: themes[theme].focusedBackground }]}
theme={theme}
>
<CustomIcon name={item.icon} size={20} color={item.danger ? themes[theme].dangerColor : themes[theme].bodyText} />
<Text
numberOfLines={1}
style={[styles.title, { color: item.danger ? themes[theme].dangerColor : themes[theme].bodyText }]}
>
{item.title}
</Text>
</Button>
);
});
Item.propTypes = {
item: PropTypes.shape({
title: PropTypes.string,
icon: PropTypes.string,
danger: PropTypes.bool,
onPress: PropTypes.func
}),
hide: PropTypes.func,
theme: PropTypes.string
};

View File

@ -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 => (
<Consumer>
{contexts => <Component {...props} {...contexts} />}
</Consumer>
);
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 (
<Provider value={getContext()}>
<ActionSheet ref={ref} theme={theme}>
{children}
</ActionSheet>
</Provider>
);
});
ActionSheetProvider.propTypes = {
children: PropTypes.node
};

View File

@ -0,0 +1,2 @@
export * from './Provider';
export * from './Button';

View File

@ -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
}
});

View File

@ -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);

View File

@ -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
}) => (
<Button
testID={`message-actions-emoji-${ item.content || item }`}
onPress={() => onReaction({ emoji: `:${ item.content || item }:` })}
style={[styles.headerItem, { backgroundColor: themes[theme].auxiliaryBackground }]}
theme={theme}
>
{item?.isCustom ? (
<CustomEmoji style={styles.customEmoji} emoji={item} baseUrl={server} />
) : (
<Text style={styles.headerIcon}>
{shortnameToUnicode(`:${ item.content || item }:`)}
</Text>
)}
</Button>
));
HeaderItem.propTypes = {
item: PropTypes.string,
onReaction: PropTypes.func,
server: PropTypes.string,
theme: PropTypes.string
};
const HeaderFooter = React.memo(({ onReaction, theme }) => (
<Button
testID='add-reaction'
onPress={onReaction}
style={[styles.headerItem, { backgroundColor: themes[theme].auxiliaryBackground }]}
theme={theme}
>
<CustomIcon name='add-reaction' size={24} color={themes[theme].bodyText} />
</Button>
));
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 }) => <HeaderItem item={item} onReaction={onReaction} server={server} theme={theme} />);
const renderFooter = useCallback(() => <HeaderFooter onReaction={onReaction} theme={theme} />);
return (
<View style={[styles.container, { backgroundColor: themes[theme].focusedBackground }]}>
<FlatList
data={items}
renderItem={renderItem}
ListFooterComponent={renderFooter}
style={{ backgroundColor: themes[theme].focusedBackground }}
keyExtractor={keyExtractor}
showsHorizontalScrollIndicator={false}
scrollEnabled={false}
horizontal
/>
</View>
);
});
Header.propTypes = {
handleReaction: PropTypes.func,
server: PropTypes.string,
message: PropTypes.object,
theme: PropTypes.string
};
export default withTheme(Header);

View File

@ -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 ? (
<Header
server={server}
handleReaction={handleReaction}
message={message}
/>
) : 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);

View File

@ -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(({
>
<View style={[styles.messageContainer, { backgroundColor: themes[theme].chatComponentBackground }]}>
<View style={styles.header}>
<Text style={[styles.username, { color: themes[theme].tintColor }]}>{message.u.username}</Text>
<Text style={[styles.username, { color: themes[theme].tintColor }]}>{message.u?.username}</Text>
<Text style={[styles.time, { color: themes[theme].auxiliaryText }]}>{time}</Text>
</View>
<Markdown
@ -74,7 +75,7 @@ const ReplyPreview = React.memo(({
<CustomIcon name='Cross' color={themes[theme].auxiliaryText} size={20} style={styles.close} onPress={close} />
</View>
);
}, (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,

View File

@ -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));

View File

@ -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;

View File

@ -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',

View File

@ -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',

View File

@ -12,3 +12,5 @@ export function withTheme(Component) {
hoistNonReactStatics(ThemedComponent, Component);
return ThemedComponent;
}
export const useTheme = () => React.useContext(ThemeContext);

View File

@ -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)));

View File

@ -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')}
</Text>
<TouchableOpacity
onPress={certificate ? this.showActionSheet : this.chooseCertificate}
onPress={certificate ? this.handleRemove : this.chooseCertificate}
testID='new-server-choose-certificate'
>
<Text

View File

@ -1,9 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FlatList, View } from 'react-native';
import ActionSheet from 'react-native-action-sheet';
import { connect } from 'react-redux';
import * as Haptics from 'expo-haptics';
import { Q } from '@nozbe/watermelondb';
import styles from './styles';
@ -23,6 +21,8 @@ import ActivityIndicator from '../../containers/ActivityIndicator';
import { withTheme } from '../../theme';
import { themes } from '../../constants/colors';
import { getUserSelector } from '../../selectors/login';
import { withActionSheet } from '../../containers/ActionSheet';
import { showConfirmationAlert } from '../../utils/info';
import SafeAreaView from '../../containers/SafeAreaView';
import { goRoom } from '../../utils/goRoom';
@ -40,6 +40,7 @@ class RoomMembersView extends React.Component {
id: PropTypes.string,
token: PropTypes.string
}),
showActionSheet: PropTypes.func,
theme: PropTypes.string,
isMasterDetail: PropTypes.bool
}
@ -47,9 +48,7 @@ class RoomMembersView extends React.Component {
constructor(props) {
super(props);
this.mounted = false;
this.CANCEL_INDEX = 0;
this.MUTE_INDEX = 1;
this.actionSheetOptions = [''];
this.MUTE_INDEX = 0;
const rid = props.route.params?.rid;
const room = props.route.params?.room;
this.state = {
@ -59,7 +58,6 @@ class RoomMembersView extends React.Component {
rid,
members: [],
membersFiltered: [],
userLongPressed: {},
room: room || {},
end: false
};
@ -140,19 +138,28 @@ class RoomMembersView extends React.Component {
return;
}
const { room } = this.state;
const { showActionSheet } = this.props;
const { muted } = room;
this.actionSheetOptions = [I18n.t('Cancel')];
const userIsMuted = !!(muted || []).find(m => 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 = () => (
<SearchBox onChangeText={text => 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)));

View File

@ -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
? (
<MessageActions
tmid={this.tmid}
room={room}
user={user}
message={selectedMessage}
actionsHide={this.onActionsHide}
editInit={this.onEditInit}
replyInit={this.onReplyInit}
reactionInit={this.onReactionInit}
isReadOnly={readOnly}
/>
)
: null
}
{showErrorActions ? (
<MessageErrorActions
tmid={this.tmid}
message={selectedMessage}
actionsHide={this.onErrorActionsHide}
/>
) : null}
<MessageActions
ref={ref => this.messageActions = ref}
tmid={this.tmid}
room={room}
user={user}
editInit={this.onEditInit}
replyInit={this.onReplyInit}
reactionInit={this.onReactionInit}
onReactionPress={this.onReactionPress}
isReadOnly={readOnly}
/>
<MessageErrorActions
ref={ref => this.messageErrorActions = ref}
tmid={this.tmid}
/>
</>
);
}

View File

@ -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);

View File

@ -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();

View File

@ -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",

View File

@ -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<T extends any> extends Component<Props<T>> {
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<T extends any> extends Component<Props<T>> {
);
this.position = interpolate(this.translateY, {
- inputRange: [openPosition, closedPosition],
+ inputRange: [snapPoints[snapPoints.length - 2], closedPosition],
outputRange: [1, 0],
extrapolate: Extrapolate.CLAMP,
});

View File

@ -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"