From 47676c2286c6e4dd9814cd72b19eddb96d1ddadc Mon Sep 17 00:00:00 2001 From: pranavpandey1998official <44601530+pranavpandey1998official@users.noreply.github.com> Date: Mon, 1 Jul 2019 19:50:38 +0530 Subject: [PATCH] [NEW] Room swipe actions: mark as read/unread, hide, fav (#976) * added unread and fav feature * changed the layout * fix jest * done requested changes * added requested changes --- __mocks__/react-native-gesture-handler.js | 1 + app/i18n/locales/en.js | 4 + app/lib/rocketchat.js | 9 + app/presentation/RoomItem/index.js | 328 ++++++++++++++++++++-- app/presentation/RoomItem/styles.js | 42 ++- app/views/RoomsListView/index.js | 35 +++ 6 files changed, 391 insertions(+), 28 deletions(-) diff --git a/__mocks__/react-native-gesture-handler.js b/__mocks__/react-native-gesture-handler.js index da7b586dd..2f9960f4a 100644 --- a/__mocks__/react-native-gesture-handler.js +++ b/__mocks__/react-native-gesture-handler.js @@ -2,3 +2,4 @@ export const RectButton = () => 'View'; export const State = () => 'View'; export const LongPressGestureHandler = () => 'View'; export const BorderlessButton = () => 'View'; +export const PanGestureHandler = () => 'View'; diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index e00b2fd3b..e390793f9 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -162,6 +162,7 @@ export default { Everyone_can_access_this_channel: 'Everyone can access this channel', erasing_room: 'erasing room', Error_uploading: 'Error uploading', + Favorite: 'Favorite', Favorites: 'Favorites', Files: 'Files', File_description: 'File description', @@ -175,6 +176,7 @@ export default { Forgot_Password: 'Forgot Password', Group_by_favorites: 'Group favorites', Group_by_type: 'Group by type', + Hide: 'Hide', Has_joined_the_channel: 'Has joined the channel', Has_joined_the_conversation: 'Has joined the conversation', Has_left_the_channel: 'Has left the channel', @@ -268,6 +270,7 @@ export default { Reactions_are_disabled: 'Reactions are disabled', Reactions_are_enabled: 'Reactions are enabled', Reactions: 'Reactions', + Read: 'Read', Read_Only_Channel: 'Read Only Channel', Read_Only: 'Read Only', Read_Receipt: 'Read Receipt', @@ -352,6 +355,7 @@ export default { unarchive: 'unarchive', UNARCHIVE: 'UNARCHIVE', Unblock_user: 'Unblock user', + Unfavorite: 'Unfavorite', Unfollowed_thread: 'Unfollowed thread', Unmute: 'Unmute', unmuted: 'unmuted', diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 7efcc717a..364a43816 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -582,6 +582,12 @@ const RocketChat = { // RC 0.64.0 return this.sdk.post('rooms.favorite', { roomId, favorite }); }, + toggleRead(read, roomId) { + if (read) { + return this.sdk.post('subscriptions.unread', { roomId }); + } + return this.sdk.post('subscriptions.read', { rid: roomId }); + }, getRoomMembers(rid, allUsers, skip = 0, limit = 10) { // RC 0.42.0 return this.sdk.methodCall('getUsersOfRoom', rid, allUsers, { skip, limit }); @@ -640,6 +646,9 @@ const RocketChat = { // RC 0.48.0 return this.sdk.post(`${ this.roomTypeToApiType(t) }.unarchive`, { roomId }); }, + hideRoom(roomId, t) { + return this.sdk.post(`${ this.roomTypeToApiType(t) }.close`, { roomId }); + }, saveRoomSettings(rid, params) { // RC 0.55.0 return this.sdk.methodCall('saveRoomSettings', rid, params); diff --git a/app/presentation/RoomItem/index.js b/app/presentation/RoomItem/index.js index 28a296dc7..b93c9f1d5 100644 --- a/app/presentation/RoomItem/index.js +++ b/app/presentation/RoomItem/index.js @@ -1,9 +1,11 @@ import React from 'react'; import moment from 'moment'; import PropTypes from 'prop-types'; -import { View, Text } from 'react-native'; +import { + View, Text, Dimensions, Animated +} from 'react-native'; import { connect } from 'react-redux'; -import { RectButton } from 'react-native-gesture-handler'; +import { RectButton, PanGestureHandler, State } from 'react-native-gesture-handler'; import Avatar from '../../containers/Avatar'; import I18n from '../../i18n'; @@ -11,9 +13,13 @@ import styles, { ROW_HEIGHT } from './styles'; import UnreadBadge from './UnreadBadge'; import TypeIcon from './TypeIcon'; import LastMessage from './LastMessage'; +import { CustomIcon } from '../../lib/Icons'; export { ROW_HEIGHT }; +const OPTION_WIDTH = 80; +const SMALL_SWIPE = 80; +const ACTION_OFFSET = 220; const attrs = ['name', 'unread', 'userMentions', 'showLastMessage', 'alert', 'type']; @connect(state => ({ userId: state.login.user && state.login.user.id, @@ -39,7 +45,13 @@ export default class RoomItem extends React.Component { token: PropTypes.string, avatarSize: PropTypes.number, testID: PropTypes.string, - height: PropTypes.number + height: PropTypes.number, + favorite: PropTypes.bool, + isRead: PropTypes.bool, + rid: PropTypes.string, + toggleFav: PropTypes.func, + toggleRead: PropTypes.func, + hideChannel: PropTypes.func } static defaultProps = { @@ -50,10 +62,28 @@ export default class RoomItem extends React.Component { // eslint-disable-next-line no-useless-constructor constructor(props) { super(props); + const dragX = new Animated.Value(0); + const rowOffSet = new Animated.Value(0); + this.rowTranslation = Animated.add( + rowOffSet, + dragX + ); + this.state = { + dragX, + rowOffSet, + rowState: 0, // 0: closed, 1: right opened, -1: left opened + leftWidth: undefined, + rightOffset: undefined + }; + this._onGestureEvent = Animated.event( + [{ nativeEvent: { translationX: dragX } }] + ); + this._value = 0; + this.rowTranslation.addListener(({ value }) => { this._value = value; }); } shouldComponentUpdate(nextProps) { - const { lastMessage, _updatedAt } = this.props; + const { lastMessage, _updatedAt, isRead } = this.props; const oldlastMessage = lastMessage; const newLastmessage = nextProps.lastMessage; @@ -63,10 +93,236 @@ export default class RoomItem extends React.Component { if (_updatedAt && nextProps._updatedAt && nextProps._updatedAt !== _updatedAt) { return true; } + if (isRead !== nextProps.isRead) { + return true; + } // eslint-disable-next-line react/destructuring-assignment return attrs.some(key => nextProps[key] !== this.props[key]); } + componentWillUnmount() { + this.rowTranslation.removeAllListeners(); + } + + close = () => { + this.swipeableRow.close(); + }; + + _onHandlerStateChange = ({ nativeEvent }) => { + if (nativeEvent.oldState === State.ACTIVE) { + this._handleRelease(nativeEvent); + } + }; + + _currentOffset = () => { + const { leftWidth = 0, rowState } = this.state; + const { rightOffset } = this.state; + if (rowState === 1) { + return leftWidth; + } else if (rowState === -1) { + return rightOffset; + } + return 0; + }; + + _handleRelease = (nativeEvent) => { + const { translationX } = nativeEvent; + const { rowState } = this.state; + let toValue = 0; + if (rowState === 0) { // if no option is opened + if (translationX > 0 && translationX < ACTION_OFFSET) { + toValue = OPTION_WIDTH; // open left option if he swipe right but not enough to trigger action + this.setState({ rowState: -1 }); + } else if (translationX > ACTION_OFFSET) { + toValue = 0; + this.toggleRead(); + } else if (translationX < 0 && translationX > -ACTION_OFFSET) { + toValue = -2 * OPTION_WIDTH; // open right option if he swipe left + this.setState({ rowState: 1 }); + } else if (translationX < -ACTION_OFFSET) { + toValue = 0; + this.hideChannel(); + } else { + toValue = 0; + } + } + + if (rowState === -1) { // if left option is opened + if (this._value < SMALL_SWIPE) { + toValue = 0; + this.setState({ rowState: 0 }); + } else if (this._value > ACTION_OFFSET) { + toValue = 0; + this.setState({ rowState: 0 }); + this.toggleRead(); + } else { + toValue = OPTION_WIDTH; + } + } + + if (rowState === 1) { // if right option is opened + if (this._value > -2 * SMALL_SWIPE) { + toValue = 0; + this.setState({ rowState: 0 }); + } else if (this._value < -ACTION_OFFSET) { + toValue = 0; + this.setState({ rowState: 0 }); + this.hideChannel(); + } else { + toValue = -2 * OPTION_WIDTH; + } + } + this._animateRow(toValue); + } + + _animateRow = (toValue) => { + const { dragX, rowOffSet } = this.state; + rowOffSet.setValue(this._value); + dragX.setValue(0); + Animated.spring(rowOffSet, { + toValue, + bounciness: 5 + }).start(); + } + + handleLeftButtonPress = () => { + this.toggleRead(); + this.close(); + } + + close = () => { + this._animateRow(0); + } + + toggleFav = () => { + const { toggleFav, rid, favorite } = this.props; + if (toggleFav) { + toggleFav(rid, favorite); + } + this.close(); + } + + toggleRead = () => { + const { toggleRead, rid, isRead } = this.props; + if (toggleRead) { + toggleRead(rid, isRead); + } + } + + handleHideButtonPress = () => { + this.hideChannel(); + this.close(); + } + + hideChannel = () => { + const { hideChannel, rid, type } = this.props; + if (hideChannel) { + hideChannel(rid, type); + } + } + + renderLeftActions = () => { + const { isRead } = this.props; + const { width } = Dimensions.get('window'); + const trans = this.rowTranslation.interpolate({ + inputRange: [0, OPTION_WIDTH], + outputRange: [-width, -width + OPTION_WIDTH] + }); + + const iconTrans = this.rowTranslation.interpolate({ + inputRange: [0, OPTION_WIDTH, ACTION_OFFSET - 20, ACTION_OFFSET, width], + outputRange: [0, 0, -(OPTION_WIDTH + 10), 0, 0] + }); + return ( + + + + {isRead ? ( + + + {I18n.t('Unread')} + + ) : ( + + + {I18n.t('Read')} + + )} + + + + ); + }; + + renderRightActions = () => { + const { favorite } = this.props; + const { width } = Dimensions.get('window'); + const trans = this.rowTranslation.interpolate({ + inputRange: [-OPTION_WIDTH, 0], + outputRange: [width - OPTION_WIDTH, width] + }); + const iconHideTrans = this.rowTranslation.interpolate({ + inputRange: [-(ACTION_OFFSET - 20), -2 * OPTION_WIDTH, 0], + outputRange: [0, 0, -OPTION_WIDTH] + }); + const iconFavWidth = this.rowTranslation.interpolate({ + inputRange: [-(ACTION_OFFSET + 1), -ACTION_OFFSET, -(ACTION_OFFSET - 20), -2 * OPTION_WIDTH, 0], + outputRange: [0, 0, OPTION_WIDTH + 20, OPTION_WIDTH, OPTION_WIDTH] + }); + const iconHideWidth = this.rowTranslation.interpolate({ + inputRange: [-(ACTION_OFFSET + 1), -ACTION_OFFSET, -(ACTION_OFFSET - 20), -2 * OPTION_WIDTH, 0], + outputRange: [(ACTION_OFFSET + 1), ACTION_OFFSET, OPTION_WIDTH + 20, OPTION_WIDTH, OPTION_WIDTH] + }); + return ( + + + + {favorite ? ( + + + {I18n.t('Unfavorite')} + + ) : ( + + + {I18n.t('Favorite')} + + )} + + + + + + + {I18n.t('Hide')} + + + + + ); + } + formatDate = date => moment(date).calendar(null, { lastDay: `[${ I18n.t('Yesterday') }]`, sameDay: 'h:mm A', @@ -97,30 +353,48 @@ export default class RoomItem extends React.Component { } return ( - - - - - - - { name } - {_updatedAt ? { date } : null} - - - - - - - - + + {this.renderLeftActions()} + {this.renderRightActions()} + + + + + + + + { name } + {_updatedAt ? { date } : null} + + + + + + + + + + + ); } } diff --git a/app/presentation/RoomItem/styles.js b/app/presentation/RoomItem/styles.js index 87fb92c94..fba962e7e 100644 --- a/app/presentation/RoomItem/styles.js +++ b/app/presentation/RoomItem/styles.js @@ -9,9 +9,10 @@ export const ROW_HEIGHT = 75 * PixelRatio.getFontScale(); export default StyleSheet.create({ container: { + backgroundColor: COLOR_WHITE, flexDirection: 'row', alignItems: 'center', - marginLeft: 14, + paddingLeft: 14, height: ROW_HEIGHT }, centerContainer: { @@ -93,5 +94,44 @@ export default StyleSheet.create({ }, avatar: { marginRight: 10 + }, + actionText: { + color: 'white', + fontSize: 14, + backgroundColor: 'transparent', + justifyContent: 'center' + }, + actionButtonLeft: { + flex: 1, + backgroundColor: '#497AFC', + justifyContent: 'center', + alignItems: 'flex-end' + }, + actionButtonRightFav: { + flex: 1, + justifyContent: 'center', + alignItems: 'flex-start', + backgroundColor: '#F4BD3E' + }, + actionButtonRightHide: { + flex: 1, + justifyContent: 'center', + alignItems: 'flex-start', + backgroundColor: '#55585D' + }, + actionView: { + width: 80, + alignItems: 'center' + }, + leftAction: { + ...StyleSheet.absoluteFill, + flexDirection: 'row-reverse' + }, + rightAction: { + ...StyleSheet.absoluteFill, + flexDirection: 'row' + }, + upperContainer: { + overflow: 'hidden' } }); diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index ee5aa0fcf..f8901afce 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -383,6 +383,30 @@ export default class RoomsListView extends React.Component { }, 100); } + toggleFav = async(rid, favorite) => { + try { + await RocketChat.toggleFavorite(rid, !favorite); + } catch (e) { + log('error_toggle_favorite', e); + } + } + + toggleRead = async(rid, isRead) => { + try { + await RocketChat.toggleRead(isRead, rid); + } catch (e) { + log('error_toggle_read', e); + } + } + + hideChannel = async(rid, type) => { + try { + await RocketChat.hideRoom(rid, type); + } catch (e) { + log('error_hide_channel', e); + } + } + goDirectory = () => { const { navigation } = this.props; navigation.navigate('DirectoryView'); @@ -404,6 +428,12 @@ export default class RoomsListView extends React.Component { ); } + getIsRead = (item) => { + let isUnread = (item.archived !== true && item.open === true); // item is not archived and not opened + isUnread = isUnread && (item.unread > 0 || item.alert === true); // either its unread count > 0 or its alert + return !isUnread; + } + renderItem = ({ item }) => { const { userId, baseUrl, StoreLastMessage @@ -416,12 +446,14 @@ export default class RoomsListView extends React.Component { alert={item.alert} unread={item.unread} userMentions={item.userMentions} + isRead={this.getIsRead(item)} favorite={item.f} lastMessage={item.lastMessage ? JSON.parse(JSON.stringify(item.lastMessage)) : null} name={this.getRoomTitle(item)} _updatedAt={item.roomUpdatedAt} key={item._id} id={id} + rid={item.rid} type={item.t} baseUrl={baseUrl} prid={item.prid} @@ -429,6 +461,9 @@ export default class RoomsListView extends React.Component { onPress={() => this._onPressItem(item)} testID={`rooms-list-view-item-${ item.name }`} height={ROW_HEIGHT} + toggleFav={this.toggleFav} + toggleRead={this.toggleRead} + hideChannel={this.hideChannel} /> ); }