From d68eb01b82b1ec6ab57aee14c57a929a95a2adff Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 10 Jun 2019 15:36:31 -0300 Subject: [PATCH] [NEW] Read receipt (#975) * switching to ubountu * added read Recipt functionality to the app fix: #542 * placed the check icon on the end of timestamp * removed linting errors * updating snapshots * done requested changes * removed width scrollView * done required changes * fixed linting errors * added migrations * resolved conflicts and done requested changes * undone uneesasary changes * adding migrations * done requested changes * Add stories and fix some issues --- .../__snapshots__/Storyshots.test.js.snap | 606 ++++++++++++++++++ app/constants/settings.js | 6 + app/containers/MessageActions.js | 23 +- app/containers/message/Message.js | 9 +- app/containers/message/ReadReceipt.js | 21 + app/containers/message/index.js | 10 +- app/containers/message/styles.js | 3 + app/i18n/locales/en.js | 2 + app/i18n/locales/pt-BR.js | 1 + app/index.js | 2 + app/lib/methods/helpers/normalizeMessage.js | 1 + app/lib/realm.js | 5 +- app/lib/rocketchat.js | 6 + app/views/ReadReceiptView/index.js | 146 +++++ app/views/ReadReceiptView/styles.js | 50 ++ app/views/RoomView/index.js | 7 +- storybook/stories/Message.js | 24 + 17 files changed, 912 insertions(+), 10 deletions(-) create mode 100644 app/containers/message/ReadReceipt.js create mode 100644 app/views/ReadReceiptView/index.js create mode 100644 app/views/ReadReceiptView/styles.js diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index 3777a6ea4..335bfb8b3 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -9216,6 +9216,612 @@ exports[`Storyshots Message list 1`] = ` + + Message with read receipt + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + + I’m fine! + + + + + + + + + + + + + + + + + + + I’m fine! + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + + I’m fine! + + + + + + +  + + + + + + + + + + + + + + + I’m fine! + + + + + + +  + + + + + ({ @@ -26,7 +27,8 @@ import log from '../utils/log'; 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_AllowStarring: state.settings.Message_AllowStarring, + Message_Read_Receipt_Store_Users: state.settings.Message_Read_Receipt_Store_Users }), dispatch => ({ actionsHide: () => dispatch(actionsHideAction()), @@ -56,7 +58,8 @@ export default class MessageActions extends React.Component { Message_AllowEditing: PropTypes.bool, Message_AllowEditing_BlockEditInMinutes: PropTypes.number, Message_AllowPinning: PropTypes.bool, - Message_AllowStarring: PropTypes.bool + Message_AllowStarring: PropTypes.bool, + Message_Read_Receipt_Store_Users: PropTypes.bool }; constructor(props) { @@ -64,7 +67,7 @@ export default class MessageActions extends React.Component { this.handleActionPress = this.handleActionPress.bind(this); this.setPermissions(); - const { Message_AllowStarring, Message_AllowPinning } = this.props; + const { Message_AllowStarring, Message_AllowPinning, Message_Read_Receipt_Store_Users } = this.props; // Cancel this.options = [I18n.t('Cancel')]; @@ -118,6 +121,12 @@ export default class MessageActions extends React.Component { 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; + } + // Report this.options.push(I18n.t('Report')); this.REPORT_INDEX = this.options.length - 1; @@ -302,6 +311,11 @@ export default class MessageActions extends React.Component { toggleReactionPicker(actionMessage); } + handleReadReceipt = () => { + const { actionMessage } = this.props; + Navigation.navigate('ReadReceiptsView', { messageId: actionMessage._id }); + } + handleReport = async() => { const { actionMessage } = this.props; try { @@ -348,6 +362,9 @@ export default class MessageActions extends React.Component { case this.DELETE_INDEX: this.handleDelete(); break; + case this.READ_RECEIPT_INDEX: + this.handleReadReceipt(); + break; default: break; } diff --git a/app/containers/message/Message.js b/app/containers/message/Message.js index 1ba56ee40..d10a5262f 100644 --- a/app/containers/message/Message.js +++ b/app/containers/message/Message.js @@ -16,6 +16,7 @@ import Reactions from './Reactions'; import Broadcast from './Broadcast'; import Discussion from './Discussion'; import Content from './Content'; +import ReadReceipt from './ReadReceipt'; const MessageInner = React.memo((props) => { if (props.type === 'discussion-created') { @@ -72,6 +73,10 @@ const Message = React.memo((props) => { > + ); @@ -119,7 +124,9 @@ Message.propTypes = { hasError: PropTypes.bool, style: PropTypes.any, onLongPress: PropTypes.func, - onPress: PropTypes.func + onPress: PropTypes.func, + isReadReceiptEnabled: PropTypes.bool, + unread: PropTypes.bool }; MessageInner.propTypes = { diff --git a/app/containers/message/ReadReceipt.js b/app/containers/message/ReadReceipt.js new file mode 100644 index 000000000..c407e021d --- /dev/null +++ b/app/containers/message/ReadReceipt.js @@ -0,0 +1,21 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { COLOR_PRIMARY } from '../../constants/colors'; +import { CustomIcon } from '../../lib/Icons'; +import styles from './styles'; + +const ReadReceipt = React.memo(({ isReadReceiptEnabled, unread }) => { + if (isReadReceiptEnabled && !unread && unread !== null) { + return ; + } + return null; +}); +ReadReceipt.displayName = 'MessageReadReceipt'; + +ReadReceipt.propTypes = { + isReadReceiptEnabled: PropTypes.bool, + unread: PropTypes.bool +}; + +export default ReadReceipt; diff --git a/app/containers/message/index.js b/app/containers/message/index.js index 1f76d5aeb..478055ad0 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -24,6 +24,7 @@ export default class MessageContainer extends React.Component { _updatedAt: PropTypes.instanceOf(Date), baseUrl: PropTypes.string, Message_GroupingPeriod: PropTypes.number, + isReadReceiptEnabled: PropTypes.bool, useRealName: PropTypes.bool, useMarkdown: PropTypes.bool, status: PropTypes.number, @@ -57,6 +58,9 @@ export default class MessageContainer extends React.Component { if (item.tmsg !== nextProps.item.tmsg) { return true; } + if (item.unread !== nextProps.item.unread) { + return true; + } return _updatedAt.toISOString() !== nextProps._updatedAt.toISOString(); } @@ -187,10 +191,10 @@ export default class MessageContainer extends React.Component { render() { const { - item, user, style, archived, baseUrl, useRealName, broadcast, fetchThreadName, customThreadTimeFormat, onOpenFileModal, timeFormat, useMarkdown + item, user, style, archived, baseUrl, useRealName, broadcast, fetchThreadName, customThreadTimeFormat, onOpenFileModal, timeFormat, useMarkdown, isReadReceiptEnabled } = this.props; const { - _id, msg, ts, attachments, urls, reactions, t, avatar, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg, mentions, channels + _id, msg, ts, attachments, urls, reactions, t, avatar, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg, mentions, channels, unread } = item; return ( @@ -213,6 +217,8 @@ export default class MessageContainer extends React.Component { broadcast={broadcast} baseUrl={baseUrl} useRealName={useRealName} + isReadReceiptEnabled={isReadReceiptEnabled} + unread={unread} role={role} drid={drid} dcount={dcount} diff --git a/app/containers/message/styles.js b/app/containers/message/styles.js index 4066b779a..c08467ef0 100644 --- a/app/containers/message/styles.js +++ b/app/containers/message/styles.js @@ -234,5 +234,8 @@ export default StyleSheet.create({ flex: 1, color: COLOR_PRIMARY, ...sharedStyles.textRegular + }, + readReceipt: { + lineHeight: 20 } }); diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index a426f0ddb..b11c47cc5 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -233,6 +233,7 @@ export default { No_Message: 'No Message', No_messages_yet: 'No messages yet', No_Reactions: 'No Reactions', + No_Read_Receipts: 'No Read Receipts', Not_logged: 'Not logged', Nothing_to_save: 'Nothing to save!', Notify_active_in_this_room: 'Notify active users in this room', @@ -265,6 +266,7 @@ export default { Reactions: 'Reactions', Read_Only_Channel: 'Read Only Channel', Read_Only: 'Read Only', + Read_Receipt: 'Read Receipt', Register: 'Register', Repeat_Password: 'Repeat Password', Replied_on: 'Replied on:', diff --git a/app/i18n/locales/pt-BR.js b/app/i18n/locales/pt-BR.js index ef13d05cf..b22686c10 100644 --- a/app/i18n/locales/pt-BR.js +++ b/app/i18n/locales/pt-BR.js @@ -266,6 +266,7 @@ export default { Read_Only_Channel: 'Canal Somente Leitura', Read_Only: 'Somente Leitura', Register: 'Registrar', + Read_Receipt: 'Lida por', Repeat_Password: 'Repetir Senha', Replied_on: 'Respondido em:', replies: 'respostas', diff --git a/app/index.js b/app/index.js index 7a08db17d..65b001b29 100644 --- a/app/index.js +++ b/app/index.js @@ -29,6 +29,7 @@ import RoomInfoView from './views/RoomInfoView'; import RoomInfoEditView from './views/RoomInfoEditView'; import RoomMembersView from './views/RoomMembersView'; import SearchMessagesView from './views/SearchMessagesView'; +import ReadReceiptsView from './views/ReadReceiptView'; import ThreadMessagesView from './views/ThreadMessagesView'; import MessagesView from './views/MessagesView'; import SelectedUsersView from './views/SelectedUsersView'; @@ -114,6 +115,7 @@ const ChatsStack = createStackNavigator({ SelectedUsersView, ThreadMessagesView, MessagesView, + ReadReceiptsView, DirectoryView }, { defaultNavigationOptions: defaultHeader diff --git a/app/lib/methods/helpers/normalizeMessage.js b/app/lib/methods/helpers/normalizeMessage.js index ee0824889..39fa9dae0 100644 --- a/app/lib/methods/helpers/normalizeMessage.js +++ b/app/lib/methods/helpers/normalizeMessage.js @@ -26,6 +26,7 @@ export default (msg) => { msg = normalizeAttachments(msg); msg.reactions = msg.reactions || []; + msg.unread = msg.unread || false; // TODO: api problems // if (Array.isArray(msg.reactions)) { // msg.reactions = msg.reactions.map((value, key) => ({ emoji: key, usernames: value.usernames.map(username => ({ value: username })) })); diff --git a/app/lib/realm.js b/app/lib/realm.js index fd14cda03..d750d7ab7 100644 --- a/app/lib/realm.js +++ b/app/lib/realm.js @@ -197,7 +197,8 @@ const messagesSchema = { tlm: { type: 'date', optional: true }, replies: 'string[]', mentions: { type: 'list', objectType: 'users' }, - channels: { type: 'list', objectType: 'rooms' } + channels: { type: 'list', objectType: 'rooms' }, + unread: { type: 'bool', optional: true } } }; @@ -415,7 +416,7 @@ class DB { return this.databases.activeDB = new Realm({ path: `${ path }.realm`, schema, - schemaVersion: 11, + schemaVersion: 12, migration: (oldRealm, newRealm) => { if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 11) { const newSubs = newRealm.objects('subscriptions'); diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 737e58b8f..4bf66959f 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -771,6 +771,12 @@ const RocketChat = { sort: { ts: -1 } }); }, + + getReadReceipts(messageId) { + return this.sdk.get('chat.getMessageReadReceipts', { + messageId + }); + }, searchMessages(roomId, searchText) { // RC 0.60.0 return this.sdk.get('chat.search', { diff --git a/app/views/ReadReceiptView/index.js b/app/views/ReadReceiptView/index.js new file mode 100644 index 000000000..9c90e8b52 --- /dev/null +++ b/app/views/ReadReceiptView/index.js @@ -0,0 +1,146 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FlatList, View, Text } from 'react-native'; +import { SafeAreaView } from 'react-navigation'; +import equal from 'deep-equal'; +import moment from 'moment'; +import { connect } from 'react-redux'; + +import Avatar from '../../containers/Avatar'; +import styles from './styles'; +import RCActivityIndicator from '../../containers/ActivityIndicator'; +import I18n from '../../i18n'; +import RocketChat from '../../lib/rocketchat'; +import StatusBar from '../../containers/StatusBar'; + +@connect(state => ({ + Message_TimeFormat: state.settings.Message_TimeFormat, + baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', + userId: state.login.user && state.login.user.id, + token: state.login.user && state.login.user.token +})) +export default class ReadReceiptsView extends React.Component { + static navigationOptions = { + title: I18n.t('Read_Receipt') + } + + static propTypes = { + navigation: PropTypes.object, + Message_TimeFormat: PropTypes.string, + baseUrl: PropTypes.string, + userId: PropTypes.string, + token: PropTypes.string + } + + constructor(props) { + super(props); + this.messageId = props.navigation.getParam('messageId'); + this.state = { + loading: false, + receipts: [] + }; + } + + componentDidMount() { + this.load(); + } + + shouldComponentUpdate(nextProps, nextState) { + const { loading, receipts } = this.state; + if (nextState.loading !== loading) { + return true; + } + if (!equal(nextState.receipts, receipts)) { + return true; + } + return false; + } + + load = async() => { + const { loading } = this.state; + if (loading) { + return; + } + + this.setState({ loading: true }); + + try { + const result = await RocketChat.getReadReceipts(this.messageId); + if (result.success) { + this.setState({ + receipts: result.receipts, + loading: false + }); + } + } catch (error) { + this.setState({ loading: false }); + console.log('err_fetch_read_receipts', error); + } + } + + renderEmpty = () => ( + + {I18n.t('No_Read_Receipts')} + + ) + + renderItem = ({ item }) => { + const { + Message_TimeFormat, userId, baseUrl, token + } = this.props; + const time = moment(item.ts).format(Message_TimeFormat); + return ( + + + + + + {item.user.name} + + + {time} + + + + {`@${ item.user.username }`} + + + + ); + } + + renderSeparator = () => ; + + render() { + const { receipts, loading } = this.state; + + if (!loading && receipts.length === 0) { + return this.renderEmpty(); + } + + return ( + + + + {loading + ? + : ( + item._id} + /> + )} + + + ); + } +} diff --git a/app/views/ReadReceiptView/styles.js b/app/views/ReadReceiptView/styles.js new file mode 100644 index 000000000..731fe8f1d --- /dev/null +++ b/app/views/ReadReceiptView/styles.js @@ -0,0 +1,50 @@ +import { StyleSheet } from 'react-native'; +import { COLOR_SEPARATOR, COLOR_WHITE, COLOR_BACKGROUND_CONTAINER } from '../../constants/colors'; +import sharedStyles from '../Styles'; + +export default StyleSheet.create({ + listEmptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: COLOR_BACKGROUND_CONTAINER + }, + item: { + flex: 1, + flexDirection: 'row', + justifyContent: 'space-between' + }, + separator: { + height: StyleSheet.hairlineWidth, + backgroundColor: COLOR_SEPARATOR + }, + name: { + ...sharedStyles.textRegular, + ...sharedStyles.textColorTitle, + fontSize: 17 + }, + username: { + flex: 1, + ...sharedStyles.textRegular, + ...sharedStyles.textColorDescription, + fontSize: 14 + }, + infoContainer: { + flex: 1, + marginLeft: 10 + }, + itemContainer: { + flex: 1, + flexDirection: 'row', + padding: 10, + backgroundColor: COLOR_WHITE + }, + container: { + flex: 1, + backgroundColor: COLOR_BACKGROUND_CONTAINER + }, + list: { + ...sharedStyles.separatorVertical, + marginVertical: 10 + } +}); diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index 4bcecf409..3ed9a8e33 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -60,7 +60,8 @@ import { Toast } from '../../utils/info'; Message_GroupingPeriod: state.settings.Message_GroupingPeriod, Message_TimeFormat: state.settings.Message_TimeFormat, useMarkdown: state.markdown.useMarkdown, - baseUrl: state.settings.baseUrl || state.server ? state.server.server : '' + baseUrl: state.settings.baseUrl || state.server ? state.server.server : '', + Message_Read_Receipt_Enabled: state.settings.Message_Read_Receipt_Enabled }), dispatch => ({ editCancel: () => dispatch(editCancelAction()), replyCancel: () => dispatch(replyCancelAction()), @@ -116,6 +117,7 @@ export default class RoomView extends React.Component { isAuthenticated: PropTypes.bool, Message_GroupingPeriod: PropTypes.number, Message_TimeFormat: PropTypes.string, + Message_Read_Receipt_Enabled: PropTypes.bool, editing: PropTypes.bool, replying: PropTypes.bool, baseUrl: PropTypes.string, @@ -499,7 +501,7 @@ export default class RoomView extends React.Component { renderItem = (item, previousItem) => { const { room, lastOpen } = this.state; const { - user, Message_GroupingPeriod, Message_TimeFormat, useRealName, baseUrl, useMarkdown + user, Message_GroupingPeriod, Message_TimeFormat, useRealName, baseUrl, useMarkdown, Message_Read_Receipt_Enabled } = this.props; let dateSeparator = null; let showUnreadSeparator = false; @@ -541,6 +543,7 @@ export default class RoomView extends React.Component { timeFormat={Message_TimeFormat} useRealName={useRealName} useMarkdown={useMarkdown} + isReadReceiptEnabled={Message_Read_Receipt_Enabled} /> ); diff --git a/storybook/stories/Message.js b/storybook/stories/Message.js index d79cbe336..87c821384 100644 --- a/storybook/stories/Message.js +++ b/storybook/stories/Message.js @@ -311,6 +311,30 @@ export default ( }]} /> + + + + + +