From 9cf81bbab997f63ec08be6606c9048d28e94cdc5 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 17 Apr 2019 14:01:03 -0300 Subject: [PATCH] [NEW] Threads (#798) --- .../__snapshots__/Storyshots.test.js.snap | 4229 ++++++++++++----- app/actions/messages.js | 20 - app/actions/server.js | 11 +- app/constants/settings.js | 3 + app/containers/MessageBox/index.js | 41 +- app/containers/message/Message.js | 107 +- app/containers/message/index.js | 50 +- app/containers/message/styles.js | 20 +- app/i18n/locales/en.js | 11 +- app/i18n/locales/pt-BR.js | 11 +- app/index.js | 4 +- app/lib/methods/loadMessagesForRoom.js | 7 +- app/lib/methods/loadMissedMessages.js | 6 +- app/lib/methods/loadThreadMessages.js | 48 + app/lib/methods/sendMessage.js | 25 +- app/lib/methods/subscriptions/room.js | 35 +- app/lib/realm.js | 84 +- app/lib/rocketchat.js | 39 +- app/reducers/messages.js | 19 - app/reducers/server.js | 3 + app/sagas/deepLinking.js | 2 +- app/sagas/init.js | 4 +- app/sagas/messages.js | 16 - app/sagas/selectServer.js | 40 +- app/views/RoomMembersView/index.js | 56 +- app/views/RoomView/Header/Header.js | 25 +- app/views/RoomView/Header/Icon.js | 2 + app/views/RoomView/Header/RightButtons.js | 114 + app/views/RoomView/Header/index.js | 11 +- app/views/RoomView/List.js | 128 +- app/views/RoomView/ScrollBottomButton.js | 60 - app/views/RoomView/index.js | 224 +- app/views/RoomView/styles.js | 14 +- app/views/RoomsListView/index.js | 7 +- app/views/ThreadMessagesView/index.js | 180 + app/views/ThreadMessagesView/styles.js | 32 + e2e/08-room.spec.js | 76 +- e2e/14-joinpublicroom.spec.js | 4 - package.json | 2 +- storybook/stories/Message.js | 187 +- storybook/stories/RoomViewHeader.js | 4 + yarn.lock | 260 +- 42 files changed, 4605 insertions(+), 1616 deletions(-) create mode 100644 app/lib/methods/loadThreadMessages.js create mode 100644 app/views/RoomView/Header/RightButtons.js delete mode 100644 app/views/RoomView/ScrollBottomButton.js create mode 100644 app/views/ThreadMessagesView/index.js create mode 100644 app/views/ThreadMessagesView/styles.js diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index 72c61a7b6..8975a82c9 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -612,7 +612,7 @@ exports[`Storyshots Message list 1`] = ` } > - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. @@ -902,7 +902,7 @@ exports[`Storyshots Message list 1`] = ` source={ Object { "priority": "high", - "uri": "https://open.rocket.chat/avatar/Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum?format=png&width=50&height=50&rc_token=79q6lH40W4ZRGLOshDiDiVlQaCc4f_lU9HNdHLAzuHz&rc_uid=y8bd77ptZswPj3EW8", + "uri": "https://open.rocket.chat/avatar/Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.?format=png&width=50&height=50&rc_token=79q6lH40W4ZRGLOshDiDiVlQaCc4f_lU9HNdHLAzuHz&rc_uid=y8bd77ptZswPj3EW8", } } style={ @@ -964,7 +964,7 @@ exports[`Storyshots Message list 1`] = ` } } > - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. @ - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. @@ -9839,6 +9839,3099 @@ exports[`Storyshots Message list 1`] = ` + + Message with thread + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + + How are you? + + + + + + + +  + + + 1 reply + + + + Nov 10 + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Replied on: + + + How are you? + + + + + + + I’m fine! + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Replied on: + + + Thread with emoji :) :joy: + + + + + + + I’m fine! + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Replied on: + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + + + + I’m fine! + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Replied on: + + + How are you? + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Replied on: + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + + How are you? + + + + + + + +  + + + + + Nov 10 + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + + How are you? + + + + + + + +  + + + +999 replies + + + + Nov 10 + + + + + + + + Discussion + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Started a discussion: + + + This is a discussion + + + + +  + + + No messages yet + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Started a discussion: + + + This is a discussion + + + + +  + + + 1 message + + + + Nov 10 + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Started a discussion: + + + Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. + + + + +  + + + 10 messages + + + + Nov 10 + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + Started a discussion: + + + This is a discussion + + + + +  + + + +999 messages + + + + Nov 10 + + + + + + - - Discussion - - - - - - - - - - - - - - diego.mello - - - - 10:00 AM - - - - Started a discussion: - - - This is a discussion - - - - -  - - - No messages yet - - - - - - - - - - - - - - - - - - - - - diego.mello - - - - 10:00 AM - - - - Started a discussion: - - - This is a discussion - - - - -  - - - 1 message - - - - Nov 10 - - - - - - - - - - - - - - - - - - - diego.mello - - - - 10:00 AM - - - - Started a discussion: - - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. - - - - -  - - - 10 messages - - - - Nov 10 - - - - - - - - - - - - - - - - - - - diego.mello - - - - 10:00 AM - - - - Started a discussion: - - - This is a discussion - - - - -  - - - +999 messages - - - - Nov 10 - - - - - - ({ replying: state.messages.replyMessage && !!state.messages.replyMessage.msg, editing: state.messages.editing, baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', + threadsEnabled: state.settings.Threads_enabled, user: { id: state.login.user && state.login.user.id, username: state.login.user && state.login.user.username, diff --git a/app/containers/message/Message.js b/app/containers/message/Message.js index 985f76c59..944fd99bb 100644 --- a/app/containers/message/Message.js +++ b/app/containers/message/Message.js @@ -85,8 +85,6 @@ const getInfoMessage = ({ return I18n.t('Room_changed_privacy', { type: msg, userBy: username }); } else if (type === 'message_snippeted') { return I18n.t('Created_snippet'); - } else if (type === 'thread-created') { - return I18n.t('Thread_created', { name: msg }); } return ''; }; @@ -99,6 +97,7 @@ export default class Message extends PureComponent { baseUrl: PropTypes.string.isRequired, customEmojis: PropTypes.object.isRequired, timeFormat: PropTypes.string.isRequired, + customThreadTimeFormat: PropTypes.string, msg: PropTypes.string, user: PropTypes.shape({ id: PropTypes.string.isRequired, @@ -137,6 +136,10 @@ export default class Message extends PureComponent { useRealName: PropTypes.bool, dcount: PropTypes.number, dlm: PropTypes.instanceOf(Date), + tmid: PropTypes.string, + tcount: PropTypes.number, + tlm: PropTypes.instanceOf(Date), + tmsg: PropTypes.string, // methods closeReactions: PropTypes.func, onErrorPress: PropTypes.func, @@ -144,8 +147,10 @@ export default class Message extends PureComponent { onReactionLongPress: PropTypes.func, onReactionPress: PropTypes.func, onDiscussionPress: PropTypes.func, + onThreadPress: PropTypes.func, replyBroadcast: PropTypes.func, - toggleReactionPicker: PropTypes.func + toggleReactionPicker: PropTypes.func, + fetchThreadName: PropTypes.func } static defaultProps = { @@ -169,6 +174,32 @@ export default class Message extends PureComponent { onLongPress(); } + formatLastMessage = (lm) => { + const { customThreadTimeFormat } = this.props; + if (customThreadTimeFormat) { + return moment(lm).format(customThreadTimeFormat); + } + return lm ? moment(lm).calendar(null, { + lastDay: `[${ I18n.t('Yesterday') }]`, + sameDay: 'h:mm A', + lastWeek: 'dddd', + sameElse: 'MMM D' + }) : null; + } + + formatMessageCount = (count, type) => { + const discussion = type === 'discussion'; + let text = discussion ? I18n.t('No_messages_yet') : null; + if (count === 1) { + text = `${ count } ${ discussion ? I18n.t('message') : I18n.t('reply') }`; + } else if (count > 1 && count < 1000) { + text = `${ count } ${ discussion ? I18n.t('messages') : I18n.t('replies') }`; + } else if (count > 999) { + text = `+999 ${ discussion ? I18n.t('messages') : I18n.t('replies') }`; + } + return text; + } + isInfoMessage = () => { const { type } = this.props; return SYSTEM_MESSAGES.includes(type); @@ -369,23 +400,11 @@ export default class Message extends PureComponent { const { msg, dcount, dlm, onDiscussionPress } = this.props; - const time = dlm ? moment(dlm).calendar(null, { - lastDay: `[${ I18n.t('Yesterday') }]`, - sameDay: 'h:mm A', - lastWeek: 'dddd', - sameElse: 'MMM D' - }) : null; - let buttonText = 'No messages yet'; - if (dcount === 1) { - buttonText = `${ dcount } message`; - } else if (dcount > 1 && dcount < 1000) { - buttonText = `${ dcount } messages`; - } else if (dcount > 999) { - buttonText = '+999 messages'; - } + const time = this.formatLastMessage(dlm); + const buttonText = this.formatMessageCount(dcount, 'discussion'); return ( - {I18n.t('Started_discussion')} + {I18n.t('Started_discussion')} {msg} { + const { + tcount, tlm, onThreadPress, msg + } = this.props; + + if (!tlm) { + return null; + } + + const time = this.formatLastMessage(tlm); + const buttonText = this.formatMessageCount(tcount, 'thread'); + return ( + + + + + {buttonText} + + + {time} + + ); + } + + renderRepliedThread = () => { + const { + tmid, tmsg, header, onThreadPress, fetchThreadName + } = this.props; + if (!tmid || !header || this.isTemp()) { + return null; + } + + if (!tmsg) { + fetchThreadName(tmid); + return null; + } + + return ( + + {I18n.t('Replied_on')} {tmsg} + + ); + } + renderInner = () => { const { type } = this.props; if (type === 'discussion-created') { @@ -418,9 +487,11 @@ export default class Message extends PureComponent { return ( {this.renderUsername()} + {this.renderRepliedThread()} {this.renderContent()} {this.renderAttachment()} {this.renderUrl()} + {this.renderThread()} {this.renderReactions()} {this.renderBroadcastReply()} diff --git a/app/containers/message/index.js b/app/containers/message/index.js index 9537a515a..e5e0e909a 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -11,6 +11,7 @@ import { replyBroadcast as replyBroadcastAction } from '../../actions/messages'; import { vibrate } from '../../utils/vibration'; +import debounce from '../../utils/debounce'; @connect(state => ({ baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', @@ -27,15 +28,14 @@ import { vibrate } from '../../utils/vibration'; export default class MessageContainer extends React.Component { static propTypes = { item: PropTypes.object.isRequired, - reactions: PropTypes.any.isRequired, user: PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, token: PropTypes.string.isRequired }), customTimeFormat: PropTypes.string, + customThreadTimeFormat: PropTypes.string, style: ViewPropTypes.style, - status: PropTypes.number, archived: PropTypes.bool, broadcast: PropTypes.bool, previousItem: PropTypes.object, @@ -47,6 +47,8 @@ export default class MessageContainer extends React.Component { Message_TimeFormat: PropTypes.string, editingMessage: PropTypes.object, useRealName: PropTypes.bool, + status: PropTypes.number, + navigation: PropTypes.object, // methods - props onLongPress: PropTypes.func, onReactionPress: PropTypes.func, @@ -54,7 +56,8 @@ export default class MessageContainer extends React.Component { // methods - redux errorActionsShow: PropTypes.func, replyBroadcast: PropTypes.func, - toggleReactionPicker: PropTypes.func + toggleReactionPicker: PropTypes.func, + fetchThreadName: PropTypes.func } static defaultProps = { @@ -73,7 +76,7 @@ export default class MessageContainer extends React.Component { shouldComponentUpdate(nextProps, nextState) { const { reactionsModal } = this.state; const { - status, reactions, broadcast, _updatedAt, editingMessage, item + status, editingMessage, item, _updatedAt } = this.props; if (reactionsModal !== nextState.reactionsModal) { @@ -82,16 +85,10 @@ export default class MessageContainer extends React.Component { if (status !== nextProps.status) { return true; } - // eslint-disable-next-line - if (!!_updatedAt ^ !!nextProps._updatedAt) { - return true; - } - if (!equal(reactions, nextProps.reactions)) { - return true; - } - if (broadcast !== nextProps.broadcast) { + if (item.tmsg !== nextProps.item.tmsg) { return true; } + if (!equal(editingMessage, nextProps.editingMessage)) { if (nextProps.editingMessage && nextProps.editingMessage._id === item._id) { return true; @@ -99,7 +96,7 @@ export default class MessageContainer extends React.Component { return true; } } - return _updatedAt.toGMTString() !== nextProps._updatedAt.toGMTString(); + return _updatedAt.toISOString() !== nextProps._updatedAt.toISOString(); } onLongPress = () => { @@ -127,6 +124,20 @@ export default class MessageContainer extends React.Component { onDiscussionPress(item); } + onThreadPress = debounce(() => { + const { navigation, item } = this.props; + if (item.tmid) { + navigation.push('RoomView', { + rid: item.rid, tmid: item.tmid, name: item.tmsg, t: 'thread' + }); + } else if (item.tlm) { + const title = item.msg || (item.attachments && item.attachments.length && item.attachments[0].title); + navigation.push('RoomView', { + rid: item.rid, tmid: item._id, name: title, t: 'thread' + }); + } + }, 1000, true) + get timeFormat() { const { customTimeFormat, Message_TimeFormat } = this.props; return customTimeFormat || Message_TimeFormat; @@ -145,6 +156,7 @@ export default class MessageContainer extends React.Component { && (previousItem.u.username === item.u.username) && !(previousItem.groupable === false || item.groupable === false || broadcast === true) && (item.ts - previousItem.ts < Message_GroupingPeriod * 1000) + && (previousItem.tmid === item.tmid) )) { return false; } @@ -169,14 +181,15 @@ export default class MessageContainer extends React.Component { render() { const { reactionsModal } = this.state; const { - item, editingMessage, user, style, archived, baseUrl, customEmojis, useRealName, broadcast + item, editingMessage, user, style, archived, baseUrl, customEmojis, useRealName, broadcast, fetchThreadName, customThreadTimeFormat } = this.props; const { - msg, ts, attachments, urls, reactions, t, status, avatar, u, alias, editedBy, role, drid, dcount, dlm + _id, msg, ts, attachments, urls, reactions, t, status, avatar, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg } = item; const isEditing = editingMessage._id === item._id; return ( ); } diff --git a/app/containers/message/styles.js b/app/containers/message/styles.js index d5c27b9bf..678bec4af 100644 --- a/app/containers/message/styles.js +++ b/app/containers/message/styles.js @@ -121,7 +121,7 @@ export default StyleSheet.create({ alignItems: 'center', justifyContent: 'center', backgroundColor: COLOR_PRIMARY, - borderRadius: 4 + borderRadius: 2 }, smallButton: { height: 30 @@ -200,6 +200,13 @@ export default StyleSheet.create({ color: COLOR_PRIMARY, ...sharedStyles.textRegular }, + startedDiscussion: { + fontStyle: 'italic', + fontSize: 16, + marginBottom: 6, + ...sharedStyles.textColorDescription, + ...sharedStyles.textRegular + }, time: { fontSize: 12, paddingLeft: 10, @@ -207,5 +214,16 @@ export default StyleSheet.create({ ...sharedStyles.textColorDescription, ...sharedStyles.textRegular, fontWeight: '300' + }, + repliedThread: { + fontSize: 16, + marginBottom: 6, + ...sharedStyles.textColorDescription, + ...sharedStyles.textRegular + }, + repliedThreadName: { + fontSize: 16, + color: COLOR_PRIMARY, + ...sharedStyles.textSemibold } }); diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index 65dee6b33..f855e3906 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -124,6 +124,7 @@ export default { Connect: 'Connect', Connect_to_a_server: 'Connect to a server', Connected: 'Connected', + connecting_server: 'connecting to server', Connecting: 'Connecting...', Continue_with: 'Continue with', Copied_to_clipboard: 'Copied to clipboard!', @@ -198,6 +199,8 @@ export default { Message_actions: 'Message actions', Message_pinned: 'Message pinned', Message_removed: 'Message removed', + message: 'message', + messages: 'messages', Messages: 'Messages', Microphone_Permission_Message: 'Rocket Chat needs access to your microphone so you can send audio message.', Microphone_Permission: 'Microphone Permission', @@ -217,10 +220,12 @@ export default { No_pinned_messages: 'No pinned messages', No_results_found: 'No results found', No_starred_messages: 'No starred messages', + No_thread_messages: 'No thread messages', No_announcement_provided: 'No announcement provided.', No_description_provided: 'No description provided.', No_topic_provided: 'No topic provided.', No_Message: 'No Message', + No_messages_yet: 'No messages yet', No_Reactions: 'No Reactions', Not_logged: 'Not logged', Nothing_to_save: 'Nothing to save!', @@ -256,6 +261,9 @@ export default { Read_Only: 'Read Only', Register: 'Register', Repeat_Password: 'Repeat Password', + Replied_on: 'Replied on:', + replies: 'replies', + reply: 'reply', Reply: 'Reply', Resend: 'Resend', Reset_password: 'Reset password', @@ -311,7 +319,8 @@ export default { 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', - Thread_created: 'Started a new thread: "{{name}}"', + Thread: 'Thread', + Threads: 'Threads', Timezone: 'Timezone', Toggle_Drawer: 'Toggle_Drawer', topic: 'topic', diff --git a/app/i18n/locales/pt-BR.js b/app/i18n/locales/pt-BR.js index 78d0813e0..228e268fb 100644 --- a/app/i18n/locales/pt-BR.js +++ b/app/i18n/locales/pt-BR.js @@ -131,6 +131,7 @@ export default { Connect: 'Conectar', Connect_to_a_server: 'Conectar a um servidor', Connected: 'Conectado', + connecting_server: 'conectando no servidor', Connecting: 'Conectando...', Continue_with: 'Entrar com', Copied_to_clipboard: 'Copiado para a área de transferência!', @@ -202,6 +203,8 @@ export default { Message_actions: 'Ações', Message_pinned: 'Fixou uma mensagem', Message_removed: 'Mensagem removida', + message: 'mensagem', + messages: 'mensagens', Messages: 'Mensagens', Microphone_Permission_Message: 'Rocket Chat precisa de acesso ao seu microfone para enviar mensagens de áudio.', Microphone_Permission: 'Acesso ao Microfone', @@ -220,10 +223,12 @@ export default { No_pinned_messages: 'Não há mensagens fixadas', No_results_found: 'Nenhum resultado encontrado', No_starred_messages: 'Não há mensagens favoritas', + No_thread_messages: 'Não há tópicos', No_announcement_provided: 'Sem anúncio.', No_description_provided: 'Sem descrição.', No_topic_provided: 'Sem tópico.', No_Message: 'Não há mensagens', + No_messages_yet: 'Não há mensagens ainda', No_Reactions: 'Sem reações', Nothing_to_save: 'Nada para salvar!', Notify_active_in_this_room: 'Notificar usuários ativos nesta sala', @@ -258,6 +263,9 @@ export default { Read_Only: 'Somente Leitura', Register: 'Registrar', Repeat_Password: 'Repetir Senha', + Replied_on: 'Respondido em:', + replies: 'respostas', + reply: 'resposta', Reply: 'Responder', Resend: 'Reenviar', Reset_password: 'Resetar senha', @@ -310,7 +318,8 @@ export default { There_was_an_error_while_action: 'Aconteceu um erro {{action}}!', This_room_is_blocked: 'Este quarto está bloqueado', This_room_is_read_only: 'Este quarto é apenas de leitura', - Thread_created: 'Iniciou uma thread: "{{name}}"', + Thread: 'Tópico', + Threads: 'Tópicos', Timezone: 'Fuso horário', topic: 'tópico', Topic: 'Tópico', diff --git a/app/index.js b/app/index.js index 2ce6b541b..e86460e09 100644 --- a/app/index.js +++ b/app/index.js @@ -29,6 +29,7 @@ import MentionedMessagesView from './views/MentionedMessagesView'; import StarredMessagesView from './views/StarredMessagesView'; import SearchMessagesView from './views/SearchMessagesView'; import PinnedMessagesView from './views/PinnedMessagesView'; +import ThreadMessagesView from './views/ThreadMessagesView'; import SelectedUsersView from './views/SelectedUsersView'; import CreateChannelView from './views/CreateChannelView'; import LegalView from './views/LegalView'; @@ -122,7 +123,8 @@ const ChatsStack = createStackNavigator({ StarredMessagesView, SearchMessagesView, PinnedMessagesView, - SelectedUsersView + SelectedUsersView, + ThreadMessagesView }, { defaultNavigationOptions: defaultHeader }); diff --git a/app/lib/methods/loadMessagesForRoom.js b/app/lib/methods/loadMessagesForRoom.js index f49ff8f64..bb2f26c90 100644 --- a/app/lib/methods/loadMessagesForRoom.js +++ b/app/lib/methods/loadMessagesForRoom.js @@ -39,8 +39,13 @@ export default function loadMessagesForRoom(...args) { if (data && data.length) { InteractionManager.runAfterInteractions(() => { database.write(() => data.forEach((message) => { + message = buildMessage(message); try { - database.create('messages', buildMessage(message), true); + database.create('messages', message, true); + // if it's a thread "header" + if (message.tlm) { + database.create('threads', message, true); + } } catch (e) { log('loadMessagesForRoom -> create messages', e); } diff --git a/app/lib/methods/loadMissedMessages.js b/app/lib/methods/loadMissedMessages.js index 4092d436a..202411a06 100644 --- a/app/lib/methods/loadMissedMessages.js +++ b/app/lib/methods/loadMissedMessages.js @@ -31,11 +31,15 @@ export default function loadMissedMessages(...args) { if (data) { if (data.updated && data.updated.length) { const { updated } = data; - updated.forEach(buildMessage); InteractionManager.runAfterInteractions(() => { database.write(() => updated.forEach((message) => { try { + message = buildMessage(message); database.create('messages', message, true); + // if it's a thread "header" + if (message.tlm) { + database.create('threads', message, true); + } } catch (e) { log('loadMissedMessages -> create messages', e); } diff --git a/app/lib/methods/loadThreadMessages.js b/app/lib/methods/loadThreadMessages.js new file mode 100644 index 000000000..077ca4427 --- /dev/null +++ b/app/lib/methods/loadThreadMessages.js @@ -0,0 +1,48 @@ +import { InteractionManager } from 'react-native'; +import EJSON from 'ejson'; + +import buildMessage from './helpers/buildMessage'; +import database from '../realm'; +import log from '../../utils/log'; + +async function load({ tmid, skip }) { + try { + // RC 1.0 + const data = await this.sdk.methodCall('getThreadMessages', { tmid, limit: 50, skip }); + if (!data || data.status === 'error') { + return []; + } + return data; + } catch (error) { + console.log(error); + return []; + } +} + +export default function loadThreadMessages({ tmid, skip }) { + return new Promise(async(resolve, reject) => { + try { + const data = await load.call(this, { tmid, skip }); + + if (data && data.length) { + InteractionManager.runAfterInteractions(() => { + database.write(() => data.forEach((m) => { + try { + const message = buildMessage(EJSON.fromJSONValue(m)); + message.rid = tmid; + database.create('threadMessages', message, true); + } catch (e) { + log('loadThreadMessages -> create messages', e); + } + })); + return resolve(data); + }); + } else { + return resolve([]); + } + } catch (e) { + log('loadThreadMessages', e); + reject(e); + } + }); +} diff --git a/app/lib/methods/sendMessage.js b/app/lib/methods/sendMessage.js index b136fbdad..7545349ed 100644 --- a/app/lib/methods/sendMessage.js +++ b/app/lib/methods/sendMessage.js @@ -5,12 +5,13 @@ import reduxStore from '../createStore'; import log from '../../utils/log'; import random from '../../utils/random'; -export const getMessage = (rid, msg = {}) => { +export const getMessage = (rid, msg = '', tmid) => { const _id = random(17); const message = { _id, rid, msg, + tmid, ts: new Date(), _updatedAt: new Date(), status: messagesStatus.TEMP, @@ -30,20 +31,28 @@ export const getMessage = (rid, msg = {}) => { }; export async function sendMessageCall(message) { - const { _id, rid, msg } = message; + const { + _id, rid, msg, tmid + } = message; // RC 0.60.0 - const data = await this.sdk.post('chat.sendMessage', { message: { _id, rid, msg } }); + const data = await this.sdk.post('chat.sendMessage', { + message: { + _id, rid, msg, tmid + } + }); return data; } -export default async function(rid, msg) { +export default async function(rid, msg, tmid) { try { - const message = getMessage(rid, msg); + const message = getMessage(rid, msg, tmid); const [room] = database.objects('subscriptions').filtered('rid == $0', rid); - database.write(() => { - room.draftMessage = null; - }); + if (room) { + database.write(() => { + room.draftMessage = null; + }); + } try { const ret = await sendMessageCall.call(this, message); diff --git a/app/lib/methods/subscriptions/room.js b/app/lib/methods/subscriptions/room.js index 8873741ff..db3c16b57 100644 --- a/app/lib/methods/subscriptions/room.js +++ b/app/lib/methods/subscriptions/room.js @@ -4,6 +4,7 @@ import log from '../../../utils/log'; import protectedFunction from '../helpers/protectedFunction'; import buildMessage from '../helpers/buildMessage'; import database from '../../realm'; +import debounce from '../../../utils/debounce'; const unsubscribe = subscriptions => subscriptions.forEach(sub => sub.unsubscribe().catch(() => console.log('unsubscribeRoom'))); const removeListener = listener => listener.stop(); @@ -107,27 +108,47 @@ export default function subscribeRoom({ rid }) { const { _id } = ddpMessage.fields.args[0]; const message = database.objects('messages').filtered('_id = $0', _id); database.delete(message); + const thread = database.objects('threads').filtered('_id = $0', _id); + database.delete(thread); + const threadMessage = database.objects('threadMessages').filtered('_id = $0', _id); + database.delete(threadMessage); + const cleanTmids = database.objects('messages').filtered('tmid = $0', _id).snapshot(); + if (cleanTmids && cleanTmids.length) { + cleanTmids.forEach((m) => { + m.tmid = null; + }); + } } }); } }); + const read = debounce(() => { + const [room] = database.objects('subscriptions').filtered('rid = $0', rid); + if (room._id) { + this.readMessages(rid); + } + }, 300); + const handleMessageReceived = protectedFunction((ddpMessage) => { - const message = buildMessage(ddpMessage.fields.args[0]); + const message = buildMessage(EJSON.fromJSONValue(ddpMessage.fields.args[0])); if (rid !== message.rid) { return; } requestAnimationFrame(() => { try { database.write(() => { - database.create('messages', EJSON.fromJSONValue(message), true); + database.create('messages', message, true); + // if it's a thread "header" + if (message.tlm) { + database.create('threads', message, true); + } else if (message.tmid) { + message.rid = message.tmid; + database.create('threadMessages', message, true); + } }); - const [room] = database.objects('subscriptions').filtered('rid = $0', rid); - - if (room._id) { - this.readMessages(rid); - } + read(); } catch (e) { console.warn('handleMessageReceived', e); } diff --git a/app/lib/realm.js b/app/lib/realm.js index a36771e3e..4b197e891 100644 --- a/app/lib/realm.js +++ b/app/lib/realm.js @@ -11,7 +11,8 @@ const serversSchema = { id: 'string', name: { type: 'string', optional: true }, iconURL: { type: 'string', optional: true }, - roomsUpdatedAt: { type: 'date', optional: true } + roomsUpdatedAt: { type: 'date', optional: true }, + version: 'string?' } }; @@ -206,8 +207,6 @@ const messagesSchema = { rid: { type: 'string', indexed: true }, ts: 'date', u: 'users', - // mentions: [], - // channels: [], alias: { type: 'string', optional: true }, parseUrls: { type: 'bool', optional: true }, groupable: { type: 'bool', optional: true }, @@ -223,7 +222,70 @@ const messagesSchema = { role: { type: 'string', optional: true }, drid: { type: 'string', optional: true }, dcount: { type: 'int', optional: true }, - dlm: { type: 'date', optional: true } + dlm: { type: 'date', optional: true }, + tmid: { type: 'string', optional: true }, + tcount: { type: 'int', optional: true }, + tlm: { type: 'date', optional: true }, + replies: 'string[]' + } +}; + +const threadsSchema = { + name: 'threads', + primaryKey: '_id', + properties: { + _id: 'string', + msg: { type: 'string', optional: true }, + t: { type: 'string', optional: true }, + rid: { type: 'string', indexed: true }, + ts: 'date', + u: 'users', + alias: { type: 'string', optional: true }, + parseUrls: { type: 'bool', optional: true }, + groupable: { type: 'bool', optional: true }, + avatar: { type: 'string', optional: true }, + attachments: { type: 'list', objectType: 'attachment' }, + urls: { type: 'list', objectType: 'url', default: [] }, + _updatedAt: { type: 'date', optional: true }, + status: { type: 'int', optional: true }, + pinned: { type: 'bool', optional: true }, + starred: { type: 'bool', optional: true }, + editedBy: 'messagesEditedBy', + reactions: { type: 'list', objectType: 'messagesReactions' }, + role: { type: 'string', optional: true }, + drid: { type: 'string', optional: true }, + dcount: { type: 'int', optional: true }, + dlm: { type: 'date', optional: true }, + tmid: { type: 'string', optional: true }, + tcount: { type: 'int', optional: true }, + tlm: { type: 'date', optional: true }, + replies: 'string[]' + } +}; + +const threadMessagesSchema = { + name: 'threadMessages', + primaryKey: '_id', + properties: { + _id: 'string', + msg: { type: 'string', optional: true }, + t: { type: 'string', optional: true }, + rid: { type: 'string', indexed: true }, + ts: 'date', + u: 'users', + alias: { type: 'string', optional: true }, + parseUrls: { type: 'bool', optional: true }, + groupable: { type: 'bool', optional: true }, + avatar: { type: 'string', optional: true }, + attachments: { type: 'list', objectType: 'attachment' }, + urls: { type: 'list', objectType: 'url', default: [] }, + _updatedAt: { type: 'date', optional: true }, + status: { type: 'int', optional: true }, + pinned: { type: 'bool', optional: true }, + starred: { type: 'bool', optional: true }, + editedBy: 'messagesEditedBy', + reactions: { type: 'list', objectType: 'messagesReactions' }, + role: { type: 'string', optional: true } } }; @@ -296,6 +358,8 @@ const schema = [ subscriptionSchema, subscriptionRolesSchema, messagesSchema, + threadsSchema, + threadMessagesSchema, usersSchema, roomsSchema, attachment, @@ -323,9 +387,9 @@ class DB { schema: [ serversSchema ], - schemaVersion: 2, + schemaVersion: 4, migration: (oldRealm, newRealm) => { - if (oldRealm.schemaVersion === 1 && newRealm.schemaVersion === 2) { + if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 3) { const newServers = newRealm.objects('servers'); // eslint-disable-next-line no-plusplus @@ -363,6 +427,10 @@ class DB { return this.database.objects(...args); } + objectForPrimaryKey(...args) { + return this.database.objectForPrimaryKey(...args); + } + get database() { return this.databases.activeDB; } @@ -376,9 +444,9 @@ class DB { return this.databases.activeDB = new Realm({ path: `${ path }.realm`, schema, - schemaVersion: 4, + schemaVersion: 6, migration: (oldRealm, newRealm) => { - if (oldRealm.schemaVersion === 3 && newRealm.schemaVersion === 4) { + if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 6) { const newSubs = newRealm.objects('subscriptions'); // eslint-disable-next-line no-plusplus diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index d20404241..edc93d409 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -9,6 +9,7 @@ import messagesStatus from '../constants/messagesStatus'; import database, { safeAddListener } from './realm'; import log from '../utils/log'; import { isIOS, getBundleId } from '../utils/deviceInfo'; +import EventEmitter from '../utils/events'; import { setUser, setLoginServices, loginRequest, loginFailure, logout @@ -31,6 +32,7 @@ import canOpenRoom from './methods/canOpenRoom'; import loadMessagesForRoom from './methods/loadMessagesForRoom'; import loadMissedMessages from './methods/loadMissedMessages'; +import loadThreadMessages from './methods/loadThreadMessages'; import sendMessage, { getMessage, sendMessageCall } from './methods/sendMessage'; import { sendFileMessage, cancelUpload, isUploadActive } from './methods/sendFileMessage'; @@ -78,26 +80,24 @@ const RocketChat = { console.warn(`AsyncStorage error: ${ error.message }`); } }, - async testServer(server) { + async getServerInfo(server) { try { - const result = await fetch(`${ server }/api/v1/info`).then(response => response.json()); - if (result.success && result.info) { - if (semver.lt(result.info.version, MIN_ROCKETCHAT_VERSION)) { + const result = await fetch(`${ server }/api/info`).then(response => response.json()); + if (result.success) { + if (semver.lt(result.version, MIN_ROCKETCHAT_VERSION)) { return { success: false, message: 'Invalid_server_version', messageOptions: { - currentVersion: result.info.version, + currentVersion: result.version, minVersion: MIN_ROCKETCHAT_VERSION } }; } - return { - success: true - }; + return result; } } catch (e) { - log('testServer', e); + log('getServerInfo', e); } return { success: false, @@ -135,6 +135,7 @@ const RocketChat = { } }, async loginSuccess({ user }) { + EventEmitter.emit('connected'); reduxStore.dispatch(setUser(user)); reduxStore.dispatch(roomsRequest()); @@ -370,6 +371,7 @@ const RocketChat = { }, loadMissedMessages, loadMessagesForRoom, + loadThreadMessages, getMessage, sendMessage, getRooms, @@ -568,9 +570,9 @@ const RocketChat = { // RC 0.64.0 return this.sdk.post('rooms.favorite', { roomId, favorite }); }, - getRoomMembers(rid, allUsers) { + getRoomMembers(rid, allUsers, skip = 0, limit = 10) { // RC 0.42.0 - return this.sdk.methodCall('getUsersOfRoom', rid, allUsers); + return this.sdk.methodCall('getUsersOfRoom', rid, allUsers, { skip, limit }); }, getUserRoles() { // RC 0.27.0 @@ -649,6 +651,10 @@ const RocketChat = { // RC 0.51.0 return this.sdk.methodCall('addUsersToRoom', { rid, users }); }, + getSingleMessage(msgId) { + // RC 0.57.0 + return this.sdk.methodCall('getSingleMessage', msgId); + }, hasPermission(permissions, rid) { let roles = []; try { @@ -768,6 +774,17 @@ const RocketChat = { roomId, searchText }); + }, + toggleFollowMessage(mid, follow) { + // RC 1.0 + if (follow) { + return this.sdk.methodCall('followMessage', { mid }); + } + return this.sdk.methodCall('unfollowMessage', { mid }); + }, + getThreadsList({ rid, limit, skip }) { + // RC 1.0 + return this.sdk.methodCall('getThreadsList', { rid, limit, skip }); } }; diff --git a/app/reducers/messages.js b/app/reducers/messages.js index 787efb549..85f7e57dd 100644 --- a/app/reducers/messages.js +++ b/app/reducers/messages.js @@ -1,8 +1,6 @@ import * as types from '../actions/actionsTypes'; const initialState = { - isFetching: false, - failure: false, message: {}, actionMessage: {}, replyMessage: {}, @@ -14,23 +12,6 @@ const initialState = { export default function messages(state = initialState, action) { switch (action.type) { - case types.MESSAGES.REQUEST: - return { - ...state, - isFetching: true - }; - case types.MESSAGES.SUCCESS: - return { - ...state, - isFetching: false - }; - case types.LOGIN.FAILURE: - return { - ...state, - isFetching: false, - failure: true, - errorMessage: action.err - }; case types.MESSAGES.ACTIONS_SHOW: return { ...state, diff --git a/app/reducers/server.js b/app/reducers/server.js index a423033e2..a2a04bd94 100644 --- a/app/reducers/server.js +++ b/app/reducers/server.js @@ -5,6 +5,7 @@ const initialState = { connected: false, failure: false, server: '', + version: null, loading: true, adding: false }; @@ -29,6 +30,7 @@ export default function server(state = initialState, action) { return { ...state, server: action.server, + version: action.version, connecting: true, connected: false, loading: true @@ -37,6 +39,7 @@ export default function server(state = initialState, action) { return { ...state, server: action.server, + version: action.version, connecting: false, connected: true, loading: false diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 0e0e17bed..de7bed594 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -69,7 +69,7 @@ const handleOpen = function* handleOpen({ params }) { yield navigate({ params }); } else { // if deep link is from a different server - const result = yield RocketChat.testServer(server); + const result = yield RocketChat.getServerInfo(server); if (!result.success) { return; } diff --git a/app/sagas/init.js b/app/sagas/init.js index 41421c8eb..1b458e6e5 100644 --- a/app/sagas/init.js +++ b/app/sagas/init.js @@ -9,6 +9,7 @@ import { APP } from '../actions/actionsTypes'; import RocketChat from '../lib/rocketchat'; import log from '../utils/log'; import Navigation from '../lib/Navigation'; +import database from '../lib/realm'; const restore = function* restore() { try { @@ -27,7 +28,8 @@ const restore = function* restore() { ]); yield put(actions.appStart('outside')); } else if (server) { - yield put(selectServerRequest(server)); + const serverObj = database.databases.serversDB.objectForPrimaryKey('servers', server); + yield put(selectServerRequest(server, serverObj && serverObj.version)); } yield put(actions.appReady({})); diff --git a/app/sagas/messages.js b/app/sagas/messages.js index 38bda9c26..0fb79a32a 100644 --- a/app/sagas/messages.js +++ b/app/sagas/messages.js @@ -4,8 +4,6 @@ import { takeLatest, put, call } from 'redux-saga/effects'; import Navigation from '../lib/Navigation'; import { MESSAGES } from '../actions/actionsTypes'; import { - messagesSuccess, - messagesFailure, deleteSuccess, deleteFailure, editSuccess, @@ -25,19 +23,6 @@ const editMessage = message => RocketChat.editMessage(message); const toggleStarMessage = message => RocketChat.toggleStarMessage(message); const togglePinMessage = message => RocketChat.togglePinMessage(message); -const get = function* get({ room }) { - try { - if (room.lastOpen) { - yield RocketChat.loadMissedMessages(room); - } else { - yield RocketChat.loadMessagesForRoom(room); - } - yield put(messagesSuccess()); - } catch (err) { - yield put(messagesFailure(err)); - } -}; - const handleDeleteRequest = function* handleDeleteRequest({ message }) { try { yield call(deleteMessage, message); @@ -97,7 +82,6 @@ const handleReplyBroadcast = function* handleReplyBroadcast({ message }) { }; const root = function* root() { - yield takeLatest(MESSAGES.REQUEST, get); yield takeLatest(MESSAGES.DELETE_REQUEST, handleDeleteRequest); yield takeLatest(MESSAGES.EDIT_REQUEST, handleEditRequest); yield takeLatest(MESSAGES.TOGGLE_STAR_REQUEST, handleToggleStarRequest); diff --git a/app/sagas/selectServer.js b/app/sagas/selectServer.js index 389490ef3..9e72462a2 100644 --- a/app/sagas/selectServer.js +++ b/app/sagas/selectServer.js @@ -12,8 +12,31 @@ import database from '../lib/realm'; import log from '../utils/log'; import I18n from '../i18n'; -const handleSelectServer = function* handleSelectServer({ server }) { +const getServerInfo = function* getServerInfo({ server }) { try { + const serverInfo = yield RocketChat.getServerInfo(server); + if (!serverInfo.success) { + Alert.alert(I18n.t('Oops'), I18n.t(serverInfo.message, serverInfo.messageOptions)); + yield put(serverFailure()); + return; + } + + database.databases.serversDB.write(() => { + database.databases.serversDB.create('servers', { id: server, version: serverInfo.version }, true); + }); + + return serverInfo; + } catch (e) { + log('getServerInfo', e); + } +}; + +const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }) { + try { + let serverInfo; + if (fetchVersion) { + serverInfo = yield getServerInfo({ server }); + } yield AsyncStorage.setItem('currentServer', server); const userStringified = yield AsyncStorage.getItem(`${ RocketChat.TOKEN_KEY }-${ server }`); @@ -37,7 +60,7 @@ const handleSelectServer = function* handleSelectServer({ server }) { return result; }, {}))); - yield put(selectServerSuccess(server)); + yield put(selectServerSuccess(server, fetchVersion ? serverInfo && serverInfo.version : version)); } catch (e) { log('handleSelectServer', e); } @@ -45,13 +68,9 @@ const handleSelectServer = function* handleSelectServer({ server }) { const handleServerRequest = function* handleServerRequest({ server }) { try { - const result = yield RocketChat.testServer(server); - if (!result.success) { - Alert.alert(I18n.t('Oops'), I18n.t(result.message, result.messageOptions)); - yield put(serverFailure()); - return; - } + const serverInfo = yield getServerInfo({ server }); + // TODO: cai aqui O.o const loginServicesLength = yield RocketChat.getLoginServices(server); if (loginServicesLength === 0) { Navigation.navigate('LoginView'); @@ -59,10 +78,7 @@ const handleServerRequest = function* handleServerRequest({ server }) { Navigation.navigate('LoginSignupView'); } - database.databases.serversDB.write(() => { - database.databases.serversDB.create('servers', { id: server }, true); - }); - yield put(selectServerRequest(server)); + yield put(selectServerRequest(server, serverInfo.version, false)); } catch (e) { yield put(serverFailure()); log('handleServerRequest', e); diff --git a/app/views/RoomMembersView/index.js b/app/views/RoomMembersView/index.js index 464d6a227..cf26f641b 100644 --- a/app/views/RoomMembersView/index.js +++ b/app/views/RoomMembersView/index.js @@ -21,6 +21,8 @@ import protectedFunction from '../../lib/methods/helpers/protectedFunction'; import { CustomHeaderButtons, Item } from '../../containers/HeaderButton'; import StatusBar from '../../containers/StatusBar'; +const PAGE_SIZE = 25; + @connect(state => ({ baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', user: { @@ -62,19 +64,20 @@ export default class RoomMembersView extends LoggedView { this.CANCEL_INDEX = 0; this.MUTE_INDEX = 1; this.actionSheetOptions = ['']; - const { rid, members } = props.navigation.state.params; + const { rid } = props.navigation.state.params; this.rooms = database.objects('subscriptions').filtered('rid = $0', rid); this.permissions = RocketChat.hasPermission(['mute-user'], rid); this.state = { - isLoading: true, + isLoading: false, allUsers: false, filtering: false, rid, - members, + members: [], membersFiltered: [], userLongPressed: {}, room: this.rooms[0] || {}, - options: [] + options: [], + end: false }; } @@ -170,7 +173,9 @@ export default class RoomMembersView extends LoggedView { toggleStatus = () => { try { const { allUsers } = this.state; - this.fetchMembers(!allUsers); + this.setState({ members: [], allUsers: !allUsers, end: false }, () => { + this.fetchMembers(); + }); } catch (e) { log('RoomMembers.toggleStatus', e); } @@ -186,15 +191,26 @@ export default class RoomMembersView extends LoggedView { }); } - fetchMembers = async(status) => { - this.setState({ isLoading: true }); - const { rid } = this.state; + // eslint-disable-next-line react/sort-comp + fetchMembers = async() => { + const { + rid, members, isLoading, allUsers, end + } = this.state; const { navigation } = this.props; + if (isLoading || end) { + return; + } + + this.setState({ isLoading: true }); try { - const membersResult = await RocketChat.getRoomMembers(rid, status); - const members = membersResult.records; - this.setState({ allUsers: status, members, isLoading: false }); - navigation.setParams({ allUsers: status }); + const membersResult = await RocketChat.getRoomMembers(rid, allUsers, members.length, PAGE_SIZE); + const newMembers = membersResult.records; + this.setState({ + members: members.concat(newMembers || []), + isLoading: false, + end: newMembers.length < PAGE_SIZE + }); + navigation.setParams({ allUsers }); } catch (error) { console.log('TCL: fetchMembers -> error', error); this.setState({ isLoading: false }); @@ -260,9 +276,9 @@ export default class RoomMembersView extends LoggedView { const { filtering, members, membersFiltered, isLoading } = this.state; - if (isLoading) { - return ; - } + // if (isLoading) { + // return ; + // } return ( @@ -273,6 +289,16 @@ export default class RoomMembersView extends LoggedView { keyExtractor={item => item._id} ItemSeparatorComponent={this.renderSeparator} ListHeaderComponent={this.renderSearchBar} + ListFooterComponent={() => { + if (isLoading) { + return ; + } + return null; + }} + onEndReachedThreshold={0.1} + onEndReached={this.fetchMembers} + maxToRenderPerBatch={5} + windowSize={10} {...scrollPersistTaps} /> diff --git a/app/views/RoomView/Header/Header.js b/app/views/RoomView/Header/Header.js index dd2ebfd73..daa9ea4db 100644 --- a/app/views/RoomView/Header/Header.js +++ b/app/views/RoomView/Header/Header.js @@ -3,23 +3,26 @@ import PropTypes from 'prop-types'; import { View, Text, StyleSheet, ScrollView } from 'react-native'; +import { emojify } from 'react-emojione'; import I18n from '../../../i18n'; import sharedStyles from '../../Styles'; -import { isIOS } from '../../../utils/deviceInfo'; +import { isIOS, isAndroid } from '../../../utils/deviceInfo'; import Icon from './Icon'; import { COLOR_TEXT_DESCRIPTION, HEADER_TITLE, COLOR_WHITE } from '../../../constants/colors'; const TITLE_SIZE = 16; const styles = StyleSheet.create({ container: { - flex: 1, height: '100%' }, titleContainer: { flex: 6, flexDirection: 'row' }, + threadContainer: { + marginRight: isAndroid ? 20 : undefined + }, title: { ...sharedStyles.textSemibold, color: HEADER_TITLE, @@ -62,7 +65,7 @@ Typing.propTypes = { }; const Header = React.memo(({ - prid, title, type, status, usersTyping, width, height + title, type, status, usersTyping, width, height, prid, tmid, widthOffset }) => { const portrait = height > width; let scale = 1; @@ -72,9 +75,13 @@ const Header = React.memo(({ scale = 0.8; } } + if (title) { + title = emojify(title, { output: 'unicode' }); + } + return ( - - + + - {title} + {title} - + {type === 'thread' ? null : } ); }); @@ -96,8 +103,10 @@ Header.propTypes = { width: PropTypes.number.isRequired, height: PropTypes.number.isRequired, prid: PropTypes.string, + tmid: PropTypes.string, status: PropTypes.string, - usersTyping: PropTypes.array + usersTyping: PropTypes.array, + widthOffset: PropTypes.number }; Header.defaultProps = { diff --git a/app/views/RoomView/Header/Icon.js b/app/views/RoomView/Header/Icon.js index 9bd897550..945f6a246 100644 --- a/app/views/RoomView/Header/Icon.js +++ b/app/views/RoomView/Header/Icon.js @@ -30,6 +30,8 @@ const Icon = React.memo(({ type, status }) => { let icon; if (type === 'discussion') { icon = 'chat'; + } else if (type === 'thread') { + icon = 'thread'; } else if (type === 'c') { icon = 'hashtag'; } else { diff --git a/app/views/RoomView/Header/RightButtons.js b/app/views/RoomView/Header/RightButtons.js new file mode 100644 index 000000000..b251990a8 --- /dev/null +++ b/app/views/RoomView/Header/RightButtons.js @@ -0,0 +1,114 @@ +import React from 'react'; +import { StyleSheet } from 'react-native'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { CustomHeaderButtons, Item } from '../../../containers/HeaderButton'; +import database, { safeAddListener } from '../../../lib/realm'; +import RocketChat from '../../../lib/rocketchat'; +import log from '../../../utils/log'; + +const styles = StyleSheet.create({ + more: { + marginHorizontal: 0, marginLeft: 0, marginRight: 5 + }, + thread: { + marginHorizontal: 0, marginLeft: 0, marginRight: 10 + } +}); + +@connect(state => ({ + userId: state.login.user && state.login.user.id, + threadsEnabled: state.settings.Threads_enabled +})) +class RightButtonsContainer extends React.PureComponent { + static propTypes = { + userId: PropTypes.string, + threadsEnabled: PropTypes.bool, + rid: PropTypes.string, + t: PropTypes.string, + tmid: PropTypes.string, + navigation: PropTypes.object + }; + + constructor(props) { + super(props); + if (props.tmid) { + this.thread = database.objectForPrimaryKey('messages', props.tmid); + safeAddListener(this.thread, this.updateThread); + } + this.state = { + isFollowingThread: true + }; + } + + updateThread = () => { + const { userId } = this.props; + this.setState({ + isFollowingThread: this.thread.replies && !!this.thread.replies.find(t => t === userId) + }); + } + + goThreadsView = () => { + const { rid, t, navigation } = this.props; + navigation.navigate('ThreadMessagesView', { rid, t }); + } + + goRoomActionsView = () => { + const { rid, t, navigation } = this.props; + navigation.navigate('RoomActionsView', { rid, t }); + } + + toggleFollowThread = async() => { + const { isFollowingThread } = this.state; + const { tmid } = this.props; + try { + await RocketChat.toggleFollowMessage(tmid, !isFollowingThread); + } catch (e) { + console.log('TCL: RightButtonsContainer -> toggleFollowThread -> e', e); + log('toggleFollowThread', e); + } + } + + render() { + const { isFollowingThread } = this.state; + const { t, tmid, threadsEnabled } = this.props; + if (t === 'l') { + return null; + } + if (tmid) { + return ( + + + + ); + } + return ( + + {threadsEnabled ? ( + + ) : null} + + + ); + } +} + +export default RightButtonsContainer; diff --git a/app/views/RoomView/Header/index.js b/app/views/RoomView/Header/index.js index 62220a52c..6b680d841 100644 --- a/app/views/RoomView/Header/index.js +++ b/app/views/RoomView/Header/index.js @@ -6,6 +6,7 @@ import equal from 'deep-equal'; import database from '../../../lib/realm'; import Header from './Header'; +import RightButtons from './RightButtons'; @responsive @connect((state, ownProps) => { @@ -33,9 +34,11 @@ export default class RoomHeaderView extends Component { title: PropTypes.string, type: PropTypes.string, prid: PropTypes.string, + tmid: PropTypes.string, rid: PropTypes.string, window: PropTypes.object, - status: PropTypes.string + status: PropTypes.string, + widthOffset: PropTypes.number }; constructor(props) { @@ -89,19 +92,23 @@ export default class RoomHeaderView extends Component { render() { const { usersTyping } = this.state; const { - window, title, type, status, prid + window, title, type, status, prid, tmid, widthOffset } = this.props; return (
); } } + +export { RightButtons }; diff --git a/app/views/RoomView/List.js b/app/views/RoomView/List.js index 05af0c231..e5c16646e 100644 --- a/app/views/RoomView/List.js +++ b/app/views/RoomView/List.js @@ -1,125 +1,137 @@ import React from 'react'; import { ActivityIndicator, FlatList, InteractionManager } from 'react-native'; import PropTypes from 'prop-types'; +import { emojify } from 'react-emojione'; +import debounce from 'lodash/debounce'; import styles from './styles'; import database, { safeAddListener } from '../../lib/realm'; import scrollPersistTaps from '../../utils/scrollPersistTaps'; -import debounce from '../../utils/debounce'; import RocketChat from '../../lib/rocketchat'; import log from '../../utils/log'; import EmptyRoom from './EmptyRoom'; -// import ScrollBottomButton from './ScrollBottomButton'; -export class List extends React.Component { +export class List extends React.PureComponent { static propTypes = { onEndReached: PropTypes.func, renderFooter: PropTypes.func, renderRow: PropTypes.func, rid: PropTypes.string, t: PropTypes.string, - window: PropTypes.object + tmid: PropTypes.string }; constructor(props) { super(props); console.time(`${ this.constructor.name } init`); console.time(`${ this.constructor.name } mount`); - this.data = database - .objects('messages') - .filtered('rid = $0', props.rid) - .sorted('ts', true); + if (props.tmid) { + this.data = database + .objects('threadMessages') + .filtered('rid = $0', props.tmid) + .sorted('ts', true); + this.threads = []; + } else { + this.data = database + .objects('messages') + .filtered('rid = $0', props.rid) + .sorted('ts', true); + this.threads = database.objects('threads').filtered('rid = $0', props.rid); + } + this.state = { loading: true, - loadingMore: false, end: false, - messages: this.data.slice() - // showScollToBottomButton: false + messages: this.data.slice(), + threads: this.threads.slice() }; + safeAddListener(this.data, this.updateState); console.timeEnd(`${ this.constructor.name } init`); } - // shouldComponentUpdate(nextProps, nextState) { - // const { - // loadingMore, loading, end, showScollToBottomButton, messages - // } = this.state; - // const { window } = this.props; - // return end !== nextState.end - // || loadingMore !== nextState.loadingMore - // || loading !== nextState.loading - // || showScollToBottomButton !== nextState.showScollToBottomButton - // // || messages.length !== nextState.messages.length - // || !equal(messages, nextState.messages) - // || window.width !== nextProps.window.width; - // } - componentDidMount() { console.timeEnd(`${ this.constructor.name } mount`); } componentWillUnmount() { this.data.removeAllListeners(); + this.threads.removeAllListeners(); if (this.updateState && this.updateState.stop) { this.updateState.stop(); } - if (this.interactionManager && this.interactionManager.cancel) { - this.interactionManager.cancel(); + if (this.updateThreads && this.updateThreads.stop) { + this.updateThreads.stop(); + } + if (this.interactionManagerState && this.interactionManagerState.cancel) { + this.interactionManagerState.cancel(); + } + if (this.interactionManagerThreads && this.interactionManagerThreads.cancel) { + this.interactionManagerThreads.cancel(); } console.countReset(`${ this.constructor.name }.render calls`); } // eslint-disable-next-line react/sort-comp updateState = debounce(() => { - this.interactionManager = InteractionManager.runAfterInteractions(() => { - this.setState({ messages: this.data.slice(), loading: false, loadingMore: false }); + this.interactionManagerState = InteractionManager.runAfterInteractions(() => { + this.setState({ + messages: this.data.slice(), + threads: this.threads.slice(), + loading: false + }); }); - }, 300); + }, 300, { leading: true }); onEndReached = async() => { const { - loadingMore, loading, end, messages + loading, end, messages } = this.state; - if (loadingMore || loading || end || messages.length < 50) { + if (loading || end || messages.length < 50) { return; } - this.setState({ loadingMore: true }); - const { rid, t } = this.props; + this.setState({ loading: true }); + const { rid, t, tmid } = this.props; try { - const result = await RocketChat.loadMessagesForRoom({ rid, t, latest: this.data[this.data.length - 1].ts }); + let result; + if (tmid) { + result = await RocketChat.loadThreadMessages({ tmid, skip: messages.length }); + } else { + result = await RocketChat.loadMessagesForRoom({ rid, t, latest: messages[messages.length - 1].ts }); + } + this.setState({ end: result.length < 50 }); } catch (e) { - this.setState({ loadingMore: false }); + this.setState({ loading: false }); log('ListView.onEndReached', e); } } - // scrollToBottom = () => { - // requestAnimationFrame(() => { - // this.list.scrollToOffset({ offset: isNotch ? -90 : -60 }); - // }); - // } - - // handleScroll = (event) => { - // if (event.nativeEvent.contentOffset.y > 0) { - // this.setState({ showScollToBottomButton: true }); - // } else { - // this.setState({ showScollToBottomButton: false }); - // } - // } - renderFooter = () => { - const { loadingMore, loading } = this.state; - if (loadingMore || loading) { - return ; + const { loading } = this.state; + if (loading) { + return ; } return null; } + renderItem = ({ item, index }) => { + const { messages, threads } = this.state; + const { renderRow } = this.props; + if (item.tmid) { + const thread = threads.find(t => t._id === item.tmid); + if (thread) { + let tmsg = thread.msg || (thread.attachments && thread.attachments.length && thread.attachments[0].title); + tmsg = emojify(tmsg, { output: 'unicode' }); + item = { ...item, tmsg }; + } + } + return renderRow(item, messages[index + 1]); + } + render() { console.count(`${ this.constructor.name }.render calls`); - const { renderRow } = this.props; const { messages } = this.state; return ( @@ -130,10 +142,9 @@ export class List extends React.Component { keyExtractor={item => item._id} data={messages} extraData={this.state} - renderItem={({ item, index }) => renderRow(item, messages[index + 1])} + renderItem={this.renderItem} contentContainerStyle={styles.contentContainer} style={styles.list} - // onScroll={this.handleScroll} inverted removeClippedSubviews initialNumToRender={1} @@ -144,11 +155,6 @@ export class List extends React.Component { ListFooterComponent={this.renderFooter} {...scrollPersistTaps} /> - {/* window.height} - /> */} ); } diff --git a/app/views/RoomView/ScrollBottomButton.js b/app/views/RoomView/ScrollBottomButton.js deleted file mode 100644 index ad9429d0d..000000000 --- a/app/views/RoomView/ScrollBottomButton.js +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import { TouchableOpacity, StyleSheet } from 'react-native'; -import PropTypes from 'prop-types'; - -import { isNotch } from '../../utils/deviceInfo'; -import { CustomIcon } from '../../lib/Icons'; -import { COLOR_BUTTON_PRIMARY } from '../../constants/colors'; - -const styles = StyleSheet.create({ - button: { - position: 'absolute', - width: 42, - height: 42, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: '#EAF2FE', - borderRadius: 21, - shadowColor: '#000', - shadowOffset: { - width: 0, - height: 1 - }, - shadowOpacity: 0.20, - shadowRadius: 1.41, - elevation: 2 - } -}); - -let right; -let bottom = 80; -if (isNotch) { - bottom = 120; -} - -const ScrollBottomButton = React.memo(({ show, onPress, landscape }) => { - if (show) { - if (landscape) { - right = 45; - } else { - right = 30; - } - return ( - - - - ); - } - return null; -}); - -ScrollBottomButton.propTypes = { - show: PropTypes.bool.isRequired, - onPress: PropTypes.func.isRequired, - landscape: PropTypes.bool -}; -export default ScrollBottomButton; diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index b6fbec468..3afb583a7 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -9,11 +9,11 @@ import { SafeAreaView } from 'react-navigation'; import equal from 'deep-equal'; import moment from 'moment'; import 'react-native-console-time-polyfill'; +import EJSON from 'ejson'; import { toggleReactionPicker as toggleReactionPickerAction, actionsShow as actionsShowAction, - messagesRequest as messagesRequestAction, editCancel as editCancelAction, replyCancel as replyCancelAction } from '../../actions/messages'; @@ -30,14 +30,15 @@ import UploadProgress from './UploadProgress'; import styles from './styles'; import log from '../../utils/log'; import { isIOS } from '../../utils/deviceInfo'; +import EventEmitter from '../../utils/events'; import I18n from '../../i18n'; import ConnectionBadge from '../../containers/ConnectionBadge'; -import { CustomHeaderButtons, Item } from '../../containers/HeaderButton'; -import RoomHeaderView from './Header'; +import RoomHeaderView, { RightButtons } from './Header'; import StatusBar from '../../containers/StatusBar'; import Separator from './Separator'; import { COLOR_WHITE } from '../../constants/colors'; import debounce from '../../utils/debounce'; +import buildMessage from '../../lib/methods/helpers/buildMessage'; @connect(state => ({ user: { @@ -49,13 +50,13 @@ import debounce from '../../utils/debounce'; showActions: state.messages.showActions, showErrorActions: state.messages.showErrorActions, appState: state.app.ready && state.app.foreground ? 'foreground' : 'background', - useRealName: state.settings.UI_Use_Real_Name + useRealName: state.settings.UI_Use_Real_Name, + isAuthenticated: state.login.isAuthenticated }), dispatch => ({ editCancel: () => dispatch(editCancelAction()), replyCancel: () => dispatch(replyCancelAction()), toggleReactionPicker: message => dispatch(toggleReactionPickerAction(message)), - actionsShow: actionMessage => dispatch(actionsShowAction(actionMessage)), - messagesRequest: room => dispatch(messagesRequestAction(room)) + actionsShow: actionMessage => dispatch(actionsShowAction(actionMessage)) })) /** @extends React.Component */ export default class RoomView extends LoggedView { @@ -64,15 +65,13 @@ export default class RoomView extends LoggedView { const prid = navigation.getParam('prid'); const title = navigation.getParam('name'); const t = navigation.getParam('t'); + const tmid = navigation.getParam('tmid'); return { - headerTitle: , - headerRight: t === 'l' - ? null - : ( - - navigation.navigate('RoomActionsView', { rid, t })} testID='room-view-header-actions' /> - - ) + headerTitleContainerStyle: styles.headerTitleContainerStyle, + headerTitle: ( + + ), + headerRight: }; } @@ -88,9 +87,9 @@ export default class RoomView extends LoggedView { actionMessage: PropTypes.object, appState: PropTypes.string, useRealName: PropTypes.bool, + isAuthenticated: PropTypes.bool, toggleReactionPicker: PropTypes.func.isRequired, actionsShow: PropTypes.func, - messagesRequest: PropTypes.func, editCancel: PropTypes.func, replyCancel: PropTypes.func }; @@ -101,6 +100,7 @@ export default class RoomView extends LoggedView { console.time(`${ this.constructor.name } mount`); this.rid = props.navigation.getParam('rid'); this.t = props.navigation.getParam('t'); + this.tmid = props.navigation.getParam('tmid'); this.rooms = database.objects('subscriptions').filtered('rid = $0', this.rid); this.state = { joined: this.rooms.length > 0, @@ -110,38 +110,37 @@ export default class RoomView extends LoggedView { this.beginAnimating = false; this.beginAnimatingTimeout = setTimeout(() => this.beginAnimating = true, 300); this.messagebox = React.createRef(); + safeAddListener(this.rooms, this.updateRoom); console.timeEnd(`${ this.constructor.name } init`); } componentDidMount() { - this.didMountInteraction = InteractionManager.runAfterInteractions(async() => { + this.didMountInteraction = InteractionManager.runAfterInteractions(() => { const { room } = this.state; - const { messagesRequest, navigation } = this.props; - messagesRequest(room); + const { navigation, isAuthenticated } = this.props; - // if room is joined - if (room._id) { + if (room._id && !this.tmid) { navigation.setParams({ name: this.getRoomTitle(room), t: room.t }); - this.sub = await RocketChat.subscribeRoom(room); - RocketChat.readMessages(room.rid); - if (room.alert || room.unread || room.userMentions) { - this.setLastOpen(room.ls); - } else { - this.setLastOpen(null); - } } - safeAddListener(this.rooms, this.updateRoom); + + if (isAuthenticated) { + this.init(); + } else { + EventEmitter.addEventListener('connected', this.handleConnected); + } }); console.timeEnd(`${ this.constructor.name } mount`); } shouldComponentUpdate(nextProps, nextState) { const { - room, joined + room, joined, lastOpen } = this.state; const { showActions, showErrorActions, appState } = this.props; - if (room.ro !== nextState.room.ro) { + if (lastOpen !== nextState.lastOpen) { + return true; + } else if (room.ro !== nextState.room.ro) { return true; } else if (room.f !== nextState.room.f) { return true; @@ -180,10 +179,12 @@ export default class RoomView extends LoggedView { componentWillUnmount() { if (this.messagebox && this.messagebox.current && this.messagebox.current.text) { const { text } = this.messagebox.current; - database.write(() => { - const [room] = this.rooms; - room.draftMessage = text; - }); + const [room] = this.rooms; + if (room) { + database.write(() => { + room.draftMessage = text; + }); + } } this.rooms.removeAllListeners(); if (this.sub && this.sub.stop) { @@ -204,9 +205,41 @@ export default class RoomView extends LoggedView { if (this.updateStateInteraction && this.updateStateInteraction.cancel) { this.updateStateInteraction.cancel(); } + if (this.initInteraction && this.initInteraction.cancel) { + this.initInteraction.cancel(); + } + EventEmitter.removeListener('connected', this.handleConnected); console.countReset(`${ this.constructor.name }.render calls`); } + // eslint-disable-next-line react/sort-comp + init = () => { + try { + this.initInteraction = InteractionManager.runAfterInteractions(async() => { + const { room } = this.state; + if (this.tmid) { + RocketChat.loadThreadMessages({ tmid: this.tmid, t: this.t }); + } else { + await this.getMessages(room); + + // if room is joined + if (room._id) { + if (room.alert || room.unread || room.userMentions) { + this.setLastOpen(room.ls); + } else { + this.setLastOpen(null); + } + RocketChat.readMessages(room.rid).catch(e => console.log(e)); + this.sub = await RocketChat.subscribeRoom(room); + } + } + }); + } catch (e) { + console.log('TCL: init -> e', e); + log('RoomView.init', e); + } + } + onMessageLongPress = (message) => { const { actionsShow } = this.props; actionsShow(message); @@ -232,6 +265,11 @@ export default class RoomView extends LoggedView { }); }, 1000, true) + handleConnected = () => { + this.init(); + EventEmitter.removeListener('connected', this.handleConnected); + } + internalSetState = (...args) => { if (isIOS && this.beginAnimating) { LayoutAnimation.easeInEaseOut(); @@ -241,14 +279,16 @@ export default class RoomView extends LoggedView { updateRoom = () => { this.updateStateInteraction = InteractionManager.runAfterInteractions(() => { - const room = JSON.parse(JSON.stringify(this.rooms[0] || {})); - this.internalSetState({ room }); + if (this.rooms[0]) { + const room = JSON.parse(JSON.stringify(this.rooms[0] || {})); + this.internalSetState({ room }); + } }); } - sendMessage = (message) => { + sendMessage = (message, tmid) => { LayoutAnimation.easeInEaseOut(); - RocketChat.sendMessage(this.rid, message).then(() => { + RocketChat.sendMessage(this.rid, message, this.tmid || tmid).then(() => { this.setLastOpen(null); }); }; @@ -258,6 +298,20 @@ export default class RoomView extends LoggedView { return ((room.prid || useRealName) && room.fname) || room.name; } + getMessages = () => { + const { room } = this.state; + try { + if (room.lastOpen) { + return RocketChat.loadMissedMessages(room); + } else { + return RocketChat.loadMessagesForRoom(room); + } + } catch (e) { + console.log('TCL: getMessages -> e', e); + log('getMessages', e); + } + } + setLastOpen = lastOpen => this.setState({ lastOpen }); joinRoom = async() => { @@ -301,9 +355,22 @@ export default class RoomView extends LoggedView { return false; } + // eslint-disable-next-line react/sort-comp + fetchThreadName = async(tmid) => { + try { + // TODO: we should build a tmid queue here in order to search for a single tmid only once + const thread = await RocketChat.getSingleMessage(tmid); + database.write(() => { + database.create('threads', buildMessage(EJSON.fromJSONValue(thread)), true); + }); + } catch (error) { + console.log('TCL: fetchThreadName -> error', error); + } + } + renderItem = (item, previousItem) => { const { room, lastOpen } = this.state; - const { user } = this.props; + const { user, navigation } = this.props; let dateSeparator = null; let showUnreadSeparator = false; @@ -319,23 +386,28 @@ export default class RoomView extends LoggedView { } } + const message = ( + + ); + if (showUnreadSeparator || dateSeparator) { return ( - + {message} - ); + return message; } renderFooter = () => { const { joined, room } = this.state; - if (!joined) { + if (!joined && !this.tmid) { return ( {I18n.t('You_are_in_preview_mode')} @@ -397,13 +454,21 @@ export default class RoomView extends LoggedView { return ; }; - renderList = () => { + renderActions = () => { const { room } = this.state; - const { rid, t } = room; + const { + user, showActions, showErrorActions, navigation + } = this.props; + if (!navigation.isFocused()) { + return null; + } return ( - - {this.renderFooter()} + {room._id && showActions + ? + : null + } + {showErrorActions ? : null} ); } @@ -411,17 +476,14 @@ export default class RoomView extends LoggedView { render() { console.count(`${ this.constructor.name }.render calls`); const { room } = this.state; - const { user, showActions, showErrorActions } = this.props; + const { rid, t } = room; return ( - {this.renderList()} - {room._id && showActions - ? - : null - } - {showErrorActions ? : null} + + {this.renderFooter()} + {this.renderActions()} diff --git a/app/views/RoomView/styles.js b/app/views/RoomView/styles.js index 471888ce8..b4cb4ed0e 100644 --- a/app/views/RoomView/styles.js +++ b/app/views/RoomView/styles.js @@ -1,8 +1,9 @@ import { StyleSheet } from 'react-native'; + import { COLOR_SEPARATOR, COLOR_PRIMARY, COLOR_WHITE, COLOR_TEXT_DESCRIPTION } from '../../constants/colors'; - +import { isIOS } from '../../utils/deviceInfo'; import sharedStyles from '../Styles'; export default StyleSheet.create({ @@ -23,8 +24,8 @@ export default StyleSheet.create({ height: 1, backgroundColor: COLOR_SEPARATOR }, - loadingMore: { - textAlign: 'center', + loading: { + flex: 1, padding: 15, color: COLOR_TEXT_DESCRIPTION }, @@ -40,9 +41,6 @@ export default StyleSheet.create({ borderRadius: 4, flexDirection: 'column' }, - loading: { - flex: 1 - }, joinRoomContainer: { justifyContent: 'flex-end', alignItems: 'center', @@ -67,5 +65,9 @@ export default StyleSheet.create({ fontSize: 16, ...sharedStyles.textMedium, ...sharedStyles.textColorNormal + }, + headerTitleContainerStyle: { + justifyContent: 'flex-start', + left: isIOS ? 40 : 50 } }); diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index c6368b9f2..20785a7df 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -264,10 +264,11 @@ export default class RoomsListView extends LoggedView { } = this.props; if (server && this.hasActiveDB()) { + this.data = database.objects('subscriptions').filtered('archived != true && open == true && t != $0', 'l'); if (sortBy === 'alphabetical') { - this.data = database.objects('subscriptions').filtered('archived != true && open == true').sorted('name', false); + this.data = this.data.sorted('name', false); } else { - this.data = database.objects('subscriptions').filtered('archived != true && open == true').sorted('roomUpdatedAt', true); + this.data = this.data.sorted('roomUpdatedAt', true); } let chats = []; @@ -281,7 +282,7 @@ export default class RoomsListView extends LoggedView { // unread if (showUnread) { - this.unread = this.data.filtered('archived != true && open == true').filtered('(unread > 0 || alert == true)'); + this.unread = this.data.filtered('(unread > 0 || alert == true)'); unread = this.removeRealmInstance(this.unread); safeAddListener(this.unread, debounce(() => this.internalSetState({ unread: this.removeRealmInstance(this.unread) }), 300)); } else { diff --git a/app/views/ThreadMessagesView/index.js b/app/views/ThreadMessagesView/index.js new file mode 100644 index 000000000..f24bb62c7 --- /dev/null +++ b/app/views/ThreadMessagesView/index.js @@ -0,0 +1,180 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { + FlatList, View, Text, InteractionManager +} from 'react-native'; +import { connect } from 'react-redux'; +import { SafeAreaView } from 'react-navigation'; +import equal from 'deep-equal'; +import EJSON from 'ejson'; +import moment from 'moment'; + +import LoggedView from '../View'; +import styles from './styles'; +import Message from '../../containers/message'; +import RCActivityIndicator from '../../containers/ActivityIndicator'; +import I18n from '../../i18n'; +import RocketChat from '../../lib/rocketchat'; +import database, { safeAddListener } from '../../lib/realm'; +import StatusBar from '../../containers/StatusBar'; +import buildMessage from '../../lib/methods/helpers/buildMessage'; +import log from '../../utils/log'; +import debounce from '../../utils/debounce'; + +const Separator = React.memo(() => ); + +@connect(state => ({ + baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', + customEmojis: state.customEmojis, + user: { + id: state.login.user && state.login.user.id, + username: state.login.user && state.login.user.username, + token: state.login.user && state.login.user.token + } +})) +/** @extends React.Component */ +export default class ThreadMessagesView extends LoggedView { + static navigationOptions = { + title: I18n.t('Threads') + } + + static propTypes = { + user: PropTypes.object, + navigation: PropTypes.object + } + + constructor(props) { + super('ThreadMessagesView', props); + this.rid = props.navigation.getParam('rid'); + this.t = props.navigation.getParam('t'); + this.messages = database.objects('threads').filtered('rid = $0', this.rid); + safeAddListener(this.messages, this.updateMessages); + this.state = { + loading: false, + messages: this.messages.slice(), + end: false, + total: 0 + }; + } + + componentDidMount() { + this.load(); + } + + shouldComponentUpdate(nextProps, nextState) { + const { loading, messages, end } = this.state; + if (nextState.loading !== loading) { + return true; + } + if (!equal(nextState.messages, messages)) { + return true; + } + if (!equal(nextState.end, end)) { + return true; + } + return false; + } + + updateMessages = () => { + this.setState({ messages: this.messages.slice() }); + } + + // eslint-disable-next-line react/sort-comp + load = debounce(async() => { + const { + loading, end, total + } = this.state; + if (end || loading) { + return; + } + + this.setState({ loading: true }); + + try { + const result = await RocketChat.getThreadsList({ rid: this.rid, limit: 50, skip: total }); + + database.write(() => result.forEach((message) => { + try { + database.create('threads', buildMessage(EJSON.fromJSONValue(message)), true); + } catch (e) { + log('ThreadMessagesView -> load -> create', e); + } + })); + + InteractionManager.runAfterInteractions(() => { + this.setState(prevState => ({ + loading: false, + end: result.length < 50, + total: prevState.total + result.length + })); + }); + } catch (error) { + console.log('ThreadMessagesView -> catch -> error', error); + this.setState({ loading: false, end: true }); + } + }, 300, true) + + formatMessage = lm => ( + lm ? moment(lm).calendar(null, { + lastDay: `[${ I18n.t('Yesterday') }]`, + sameDay: 'h:mm A', + lastWeek: 'dddd', + sameElse: 'MMM D' + }) : null + ) + + renderSeparator = () => + + renderEmpty = () => ( + + {I18n.t('No_thread_messages')} + + ) + + renderItem = ({ item }) => { + const { user, navigation } = this.props; + return ( + + ); + } + + render() { + const { messages, loading } = this.state; + + if (!loading && messages.length === 0) { + return this.renderEmpty(); + } + + return ( + + + item._id} + onEndReached={this.load} + onEndReachedThreshold={0.5} + maxToRenderPerBatch={5} + initialNumToRender={1} + ItemSeparatorComponent={this.renderSeparator} + ListFooterComponent={loading ? : null} + /> + + ); + } +} diff --git a/app/views/ThreadMessagesView/styles.js b/app/views/ThreadMessagesView/styles.js new file mode 100644 index 000000000..e1a77b974 --- /dev/null +++ b/app/views/ThreadMessagesView/styles.js @@ -0,0 +1,32 @@ +import { StyleSheet } from 'react-native'; + +import sharedStyles from '../Styles'; +import { COLOR_WHITE, COLOR_SEPARATOR } from '../../constants/colors'; + +export default StyleSheet.create({ + list: { + flex: 1, + backgroundColor: COLOR_WHITE + }, + listEmptyContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: COLOR_WHITE + }, + noDataFound: { + fontSize: 14, + ...sharedStyles.textRegular, + ...sharedStyles.textColorNormal + }, + contentContainer: { + paddingBottom: 30 + }, + separator: { + height: StyleSheet.hairlineWidth, + width: '100%', + marginLeft: 60, + marginTop: 10, + backgroundColor: COLOR_SEPARATOR + } +}); diff --git a/e2e/08-room.spec.js b/e2e/08-room.spec.js index 7aaaea338..421e1cac9 100644 --- a/e2e/08-room.spec.js +++ b/e2e/08-room.spec.js @@ -21,6 +21,8 @@ async function navigateToRoom() { } describe('Room screen', () => { + const mainRoom = `private${ data.random }`; + before(async() => { await navigateToRoom(); }); @@ -28,6 +30,8 @@ describe('Room screen', () => { describe('Render', async() => { it('should have room screen', async() => { await expect(element(by.id('room-view'))).toBeVisible(); + await waitFor(element(by.id(`room-view-title-${ mainRoom }`))).toBeVisible().withTimeout(5000); + await expect(element(by.id(`room-view-title-${ mainRoom }`))).toBeVisible(); }); it('should have messages list', async() => { @@ -228,17 +232,6 @@ describe('Room screen', () => { await expect(element(by.id('message-reaction-:grinning:'))).toBeNotVisible(); }); - it('should reply message', async() => { - await mockMessage('reply'); - await element(by.text(`${ data.random }reply`)).longPress(); - await waitFor(element(by.text('Message actions'))).toBeVisible().withTimeout(5000); - await expect(element(by.text('Message actions'))).toBeVisible(); - await element(by.text('Reply')).tap(); - await element(by.id('messagebox-input')).typeText('replied'); - await element(by.id('messagebox-send-message')).tap(); - // TODO: test if reply was sent - }); - it('should edit message', async() => { await mockMessage('edit'); await element(by.text(`${ data.random }edit`)).longPress(); @@ -281,6 +274,67 @@ describe('Room screen', () => { // TODO: delete message - swipe on action sheet missing }); + describe('Thread', async() => { + const thread = `${ data.random }thread`; + it('should create thread', async() => { + await mockMessage('thread'); + await element(by.text(thread)).longPress(); + await waitFor(element(by.text('Message actions'))).toBeVisible().withTimeout(5000); + await expect(element(by.text('Message actions'))).toBeVisible(); + await element(by.text('Reply')).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 }`))).toBeVisible().withTimeout(5000); + await expect(element(by.id(`message-thread-button-${ thread }`))).toBeVisible(); + await waitFor(element(by.id(`message-thread-replied-on-${ thread }`))).toBeVisible().withTimeout(5000); + await expect(element(by.id(`message-thread-replied-on-${ thread }`))).toBeVisible(); + }); + + it('should navigate to thread from button', async() => { + await element(by.id(`message-thread-button-${ thread }`)).tap(); + await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000); + await waitFor(element(by.id(`room-view-title-${ thread }`))).toBeVisible().withTimeout(5000); + await expect(element(by.id(`room-view-title-${ thread }`))).toBeVisible(); + await tapBack(); + }); + + it('should toggle follow thread', async() => { + await element(by.id(`message-thread-button-${ thread }`)).tap(); + await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000); + await waitFor(element(by.id(`room-view-title-${ thread }`))).toBeVisible().withTimeout(5000); + await expect(element(by.id(`room-view-title-${ thread }`))).toBeVisible(); + await element(by.id('room-view-header-unfollow')).tap(); + await waitFor(element(by.id('room-view-header-follow'))).toBeVisible().withTimeout(60000); + await expect(element(by.id('room-view-header-follow'))).toBeVisible(); + await element(by.id('room-view-header-follow')).tap(); + await waitFor(element(by.id('room-view-header-unfollow'))).toBeVisible().withTimeout(60000); + await expect(element(by.id('room-view-header-unfollow'))).toBeVisible(); + await tapBack(); + }); + + it('should navigate to thread from thread name', async() => { + await element(by.id(`message-thread-replied-on-${ thread }`)).tap(); + await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000); + await waitFor(element(by.id(`room-view-title-${ thread }`))).toBeVisible().withTimeout(5000); + await expect(element(by.id(`room-view-title-${ thread }`))).toBeVisible(); + await tapBack(); + }); + + it('should navigate to thread from threads view', async() => { + await element(by.id('room-view-header-threads')).tap(); + await waitFor(element(by.id('thread-messages-view'))).toBeVisible().withTimeout(5000); + await expect(element(by.id('thread-messages-view'))).toBeVisible(); + await element(by.id(`message-thread-button-${ thread }`)).tap(); + await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000); + await waitFor(element(by.id(`room-view-title-${ thread }`))).toBeVisible().withTimeout(5000); + await expect(element(by.id(`room-view-title-${ thread }`))).toBeVisible(); + await tapBack(); + await waitFor(element(by.id('thread-messages-view'))).toBeVisible().withTimeout(5000); + await expect(element(by.id('thread-messages-view'))).toBeVisible(); + await tapBack(); + }); + }); + afterEach(async() => { takeScreenshot(); }); diff --git a/e2e/14-joinpublicroom.spec.js b/e2e/14-joinpublicroom.spec.js index 74f944656..30bac3f09 100644 --- a/e2e/14-joinpublicroom.spec.js +++ b/e2e/14-joinpublicroom.spec.js @@ -44,10 +44,6 @@ describe('Join public room', () => { // Render - Header describe('Header', async() => { - it('should have star button', async() => { - await expect(element(by.id('room-view-header-star'))).toBeVisible(); - }); - it('should have actions button ', async() => { await expect(element(by.id('room-view-header-actions'))).toBeVisible(); }); diff --git a/package.json b/package.json index 4fca8a29c..716a818b4 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,7 @@ "react-navigation-header-buttons": "^2.1.2", "react-redux": "^6.0.0", "reactotron-react-native": "2.2", - "realm": "2.24", + "realm": "2.26.1", "redux": "^4.0.1", "redux-enhancer-react-native-appstate": "^0.3.1", "redux-immutable-state-invariant": "^2.1.0", diff --git a/storybook/stories/Message.js b/storybook/stories/Message.js index ea4fd53fe..0d18d0b67 100644 --- a/storybook/stories/Message.js +++ b/storybook/stories/Message.js @@ -26,6 +26,7 @@ const author = { const baseUrl = 'https://open.rocket.chat'; const customEmojis = { react_rocket: 'png', nyan_rocket: 'png', marioparty: 'gif' }; const date = new Date(2017, 10, 10, 10); +const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; const Message = props => ( - + @@ -58,7 +59,7 @@ export default ( msg='Different user' author={{ ...author, - username: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum' + username: longText }} /> @@ -74,7 +75,7 @@ export default ( msg='Message' author={{ ...author, - username: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum' + username: longText }} alias='Diego Mello' /> @@ -262,6 +263,7 @@ export default ( header={false} /> + {/* Legacy thread */} + + + + + + + + + + {/* + + + */} + + + + + + + {/* + + + */} + + alert('broadcast!')} /> - - - - - - {/* - - - */} - diff --git a/storybook/stories/RoomViewHeader.js b/storybook/stories/RoomViewHeader.js index 894b65f7c..95c364ba9 100644 --- a/storybook/stories/RoomViewHeader.js +++ b/storybook/stories/RoomViewHeader.js @@ -31,6 +31,9 @@ const Header = props => ( height={480} {...props} /> + + + @@ -47,6 +50,7 @@ export default (
+
diff --git a/yarn.lock b/yarn.lock index 28277011e..16eb4eac9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1878,6 +1878,20 @@ arr-union@^3.1.0: resolved "https://registry.yarnpkg.com/arr-union/-/arr-union-3.1.0.tgz#e39b09aea9def866a8f206e288af63919bae39c4" integrity sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ= +array-back@^1.0.3, array-back@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-1.0.4.tgz#644ba7f095f7ffcf7c43b5f0dc39d3c1f03c063b" + integrity sha1-ZEun8JX3/898Q7Xw3DnTwfA8Bjs= + dependencies: + typical "^2.6.0" + +array-back@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/array-back/-/array-back-2.0.0.tgz#6877471d51ecc9c9bfa6136fb6c7d5fe69748022" + integrity sha512-eJv4pLLufP3g5kcZry0j6WXpIbzYw9GUB4mVJZno9wfwiBxbizTnHCw3VJb07cBihbFX48Y7oSrW9y+gt4glyw== + dependencies: + typical "^2.6.1" + array-equal@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/array-equal/-/array-equal-1.0.0.tgz#8c2a5ef2472fd9ea742b04c77a75093ba2757c93" @@ -3080,7 +3094,7 @@ binstring@^0.2.1: resolved "https://registry.yarnpkg.com/binstring/-/binstring-0.2.1.tgz#8a174d301f6d54efda550dd98bb4cb524eacd75d" integrity sha1-ihdNMB9tVO/aVQ3Zi7TLUk6s110= -bl@^1.2.1: +bl@^1.0.0, bl@^1.2.1: version "1.2.2" resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" integrity sha512-e8tQYnZodmebYDWGH7KMRvtzKXaJHx3BbilrgZCfvyLUYdKpK1t5PSPmpkny/SgiTSCnjfLW7v5rlONXVFkQEA== @@ -3306,11 +3320,29 @@ bser@^2.0.0: dependencies: node-int64 "^0.4.0" -buffer-crc32@^0.2.13: +buffer-alloc-unsafe@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" + integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== + +buffer-alloc@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" + integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== + dependencies: + buffer-alloc-unsafe "^1.1.0" + buffer-fill "^1.0.0" + +buffer-crc32@^0.2.13, buffer-crc32@~0.2.3: version "0.2.13" resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242" integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI= +buffer-fill@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" + integrity sha1-+PeLdniYiO858gXNY39o5wISKyw= + buffer-from@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef" @@ -3330,6 +3362,14 @@ buffer@^4.3.0: ieee754 "^1.1.4" isarray "^1.0.0" +buffer@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.2.1.tgz#dd57fa0f109ac59c602479044dca7b8b3d0b71d6" + integrity sha512-c+Ko0loDaFfuPWiL02ls9Xd3GO3cPVmUobQ6t3rXNUk304u6hGq+8N/kFi+QEIKhzK3uwolVhLzszmfLmMLnqg== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" + builtin-modules@^1.0.0: version "1.1.1" resolved "https://registry.yarnpkg.com/builtin-modules/-/builtin-modules-1.1.1.tgz#270f076c5a72c02f5b65a47df94c5fe3a278892f" @@ -3723,6 +3763,15 @@ combined-stream@^1.0.6, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" +command-line-args@^4.0.6: + version "4.0.7" + resolved "https://registry.yarnpkg.com/command-line-args/-/command-line-args-4.0.7.tgz#f8d1916ecb90e9e121eda6428e41300bfb64cc46" + integrity sha512-aUdPvQRAyBvQd2n7jXcsMDz68ckBJELXNzBybCHOibUWEg0mWTnaYCSRU8h9R+aNRSvDihJtssSRCiDRpLaezA== + dependencies: + array-back "^2.0.0" + find-replace "^1.0.3" + typical "^2.6.1" + commander@2.15.1: version "2.15.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" @@ -3743,6 +3792,13 @@ commander@~2.13.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.13.0.tgz#6964bca67685df7c1f1430c584f07d7597885b9c" integrity sha512-MVuS359B+YzaWqjCL/c+22gfryv+mCBPHAv3zyVI2GN8EY6IRP8VwtasXn8jyyhvvq84R4ImN1OKRtcbIasjYA== +commander@~2.8.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4" + integrity sha1-Br42f+v9oMMwqh4qBy09yXYkJdQ= + dependencies: + graceful-readlink ">= 1.0.0" + commist@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/commist/-/commist-1.0.0.tgz#c0c352501cf6f52e9124e3ef89c9806e2022ebef" @@ -4201,6 +4257,59 @@ decode-uri-component@^0.2.0: resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.0.tgz#eb3913333458775cb84cd1a1fae062106bb87545" integrity sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU= +decompress-tar@^4.0.0, decompress-tar@^4.1.0, decompress-tar@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-tar/-/decompress-tar-4.1.1.tgz#718cbd3fcb16209716e70a26b84e7ba4592e5af1" + integrity sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ== + dependencies: + file-type "^5.2.0" + is-stream "^1.1.0" + tar-stream "^1.5.2" + +decompress-tarbz2@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz#3082a5b880ea4043816349f378b56c516be1a39b" + integrity sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A== + dependencies: + decompress-tar "^4.1.0" + file-type "^6.1.0" + is-stream "^1.1.0" + seek-bzip "^1.0.5" + unbzip2-stream "^1.0.9" + +decompress-targz@^4.0.0: + version "4.1.1" + resolved "https://registry.yarnpkg.com/decompress-targz/-/decompress-targz-4.1.1.tgz#c09bc35c4d11f3de09f2d2da53e9de23e7ce1eee" + integrity sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w== + dependencies: + decompress-tar "^4.1.1" + file-type "^5.2.0" + is-stream "^1.1.0" + +decompress-unzip@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/decompress-unzip/-/decompress-unzip-4.0.1.tgz#deaaccdfd14aeaf85578f733ae8210f9b4848f69" + integrity sha1-3qrM39FK6vhVePczroIQ+bSEj2k= + dependencies: + file-type "^3.8.0" + get-stream "^2.2.0" + pify "^2.3.0" + yauzl "^2.4.2" + +decompress@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/decompress/-/decompress-4.2.0.tgz#7aedd85427e5a92dacfe55674a7c505e96d01f9d" + integrity sha1-eu3YVCflqS2s/lVnSnxQXpbQH50= + dependencies: + decompress-tar "^4.0.0" + decompress-tarbz2 "^4.0.0" + decompress-targz "^4.0.0" + decompress-unzip "^4.0.1" + graceful-fs "^4.1.10" + make-dir "^1.0.0" + pify "^2.3.0" + strip-dirs "^2.0.0" + deep-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" @@ -5334,6 +5443,13 @@ fbjs@^1.0.0: setimmediate "^1.0.5" ua-parser-js "^0.7.18" +fd-slicer@~1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" + integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4= + dependencies: + pend "~1.2.0" + figgy-pudding@^3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.1.tgz#862470112901c727a0e495a80744bd5baa1d6790" @@ -5379,6 +5495,21 @@ file-system-cache@^1.0.5: fs-extra "^0.30.0" ramda "^0.21.0" +file-type@^3.8.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-3.9.0.tgz#257a078384d1db8087bc449d107d52a52672b9e9" + integrity sha1-JXoHg4TR24CHvESdEH1SpSZyuek= + +file-type@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-5.2.0.tgz#2ddbea7c73ffe36368dfae49dc338c058c2b8ad6" + integrity sha1-LdvqfHP/42No365J3DOMBYwritY= + +file-type@^6.1.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" + integrity sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg== + file-uri-to-path@1: version "1.0.0" resolved "https://registry.yarnpkg.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz#553a7b8446ff6f684359c445f1e37a05dacc33dd" @@ -5467,6 +5598,14 @@ find-cache-dir@^2.0.0: make-dir "^1.0.0" pkg-dir "^3.0.0" +find-replace@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-1.0.3.tgz#b88e7364d2d9c959559f388c66670d6130441fa0" + integrity sha1-uI5zZNLZyVlVnziMZmcNYTBEH6A= + dependencies: + array-back "^1.0.4" + test-value "^2.1.0" + find-up@3.0.0, find-up@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" @@ -5577,6 +5716,11 @@ from2@^2.1.0: inherits "^2.0.1" readable-stream "^2.0.0" +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs-extra@^0.30.0: version "0.30.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-0.30.0.tgz#f233ffcc08d4da7d432daa449776989db1df93f0" @@ -5597,7 +5741,7 @@ fs-extra@^1.0.0: jsonfile "^2.1.0" klaw "^1.0.0" -fs-extra@^4.0.2: +fs-extra@^4.0.2, fs-extra@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.3.tgz#0d852122e5bc5beb453fb028e9c0c9bf36340c94" integrity sha512-q6rbdDd1o2mAnQreO7YADIxf/Whx4AHBiRf6d+/cVT8h44ss+lHgxf1FemcqDnQt9X3ct4McHr+JMGlYSsK7Cg== @@ -5714,6 +5858,14 @@ get-port@^2.1.0: dependencies: pinkie-promise "^2.0.0" +get-stream@^2.2.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-2.3.1.tgz#5f38f93f346009666ee0150a054167f91bdd95de" + integrity sha1-Xzj5PzRgCWZu4BUKBUFn+Rvdld4= + dependencies: + object-assign "^4.0.1" + pinkie-promise "^2.0.0" + get-stream@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" @@ -5928,11 +6080,16 @@ got@^6.7.1: unzip-response "^2.0.1" url-parse-lax "^1.0.0" -graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.1.9: +graceful-fs@^4.1.10, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.3, graceful-fs@^4.1.6, graceful-fs@^4.1.9: version "4.1.15" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== +"graceful-readlink@>= 1.0.0": + version "1.0.1" + resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" + integrity sha1-TK+tdrxi8C+gObL5Tpo906ORpyU= + graphlib@^2.1.1, graphlib@^2.1.5: version "2.1.7" resolved "https://registry.yarnpkg.com/graphlib/-/graphlib-2.1.7.tgz#b6a69f9f44bd9de3963ce6804a2fc9e73d86aecc" @@ -6423,7 +6580,7 @@ inherits@2.0.1: resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.1.tgz#b17d08d326b4423e568eff719f91b0b1cbdf69f1" integrity sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE= -ini@^1.3.0, ini@^1.3.4, ini@~1.3.0: +ini@^1.3.0, ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: version "1.3.5" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== @@ -6720,6 +6877,11 @@ is-installed-globally@^0.1.0: global-dirs "^0.1.0" is-path-inside "^1.0.0" +is-natural-number@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/is-natural-number/-/is-natural-number-4.0.1.tgz#ab9d76e1db4ced51e35de0c72ebecf09f734cde8" + integrity sha1-q5124dtM7VHjXeDHLr7PCfc0zeg= + is-negated-glob@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-negated-glob/-/is-negated-glob-1.0.0.tgz#6910bca5da8c95e784b5751b976cf5a10fee36d2" @@ -9425,12 +9587,17 @@ pbkdf2@^3.0.3: safe-buffer "^5.0.1" sha.js "^2.4.8" +pend@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" + integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= + performance-now@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -pify@^2.0.0: +pify@^2.0.0, pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= @@ -9680,7 +9847,7 @@ process@~0.5.1: resolved "https://registry.yarnpkg.com/process/-/process-0.5.2.tgz#1638d8a8e34c2f440a91db95ab9aeb677fc185cf" integrity sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8= -progress@^2.0.0: +progress@^2.0.0, progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== @@ -10646,7 +10813,7 @@ read-pkg@^3.0.0: normalize-package-data "^2.3.2" path-type "^3.0.0" -"readable-stream@1 || 2", readable-stream@2, "readable-stream@> 1.0.0 < 3.0.0", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@2, "readable-stream@> 1.0.0 < 3.0.0", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== @@ -10709,16 +10876,22 @@ readdirp@^2.0.0: micromatch "^3.1.10" readable-stream "^2.0.2" -realm@2.24: - version "2.24.0" - resolved "https://registry.yarnpkg.com/realm/-/realm-2.24.0.tgz#4c804bed23360b7d4f23964e708d142608f7a335" - integrity sha512-pIeZNoUfqrfo9WRdP3PtVVjh2aEde9l6T5tReN4as9MLn9sC/17tppatrl5S3gfakxvNQH3uJ9FdYI7lS6EspQ== +realm@2.26.1: + version "2.26.1" + resolved "https://registry.yarnpkg.com/realm/-/realm-2.26.1.tgz#9d890c85c4d0946bef0a3ece736551c6a8a5dc49" + integrity sha512-kkDOMV5vgaPOYgTELHFPws9suEF0LI/kSb8SIZ615STKHLHLiRxioxgBcu5beO5HVkjxe5jYx7duSB3NASr+AA== dependencies: + command-line-args "^4.0.6" + decompress "^4.2.0" deepmerge "2.1.0" + fs-extra "^4.0.3" + https-proxy-agent "^2.2.1" + ini "^1.3.5" nan "^2.12.1" node-fetch "^1.7.3" node-machine-id "^1.1.10" node-pre-gyp "^0.11.0" + progress "^2.0.3" prop-types "^15.6.2" request "^2.88.0" stream-counter "^1.0.0" @@ -11288,6 +11461,13 @@ secure-keys@^1.0.0: resolved "https://registry.yarnpkg.com/secure-keys/-/secure-keys-1.0.0.tgz#f0c82d98a3b139a8776a8808050b824431087fca" integrity sha1-8MgtmKOxOah3aogIBQuCRDEIf8o= +seek-bzip@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.5.tgz#cfe917cb3d274bcffac792758af53173eb1fabdc" + integrity sha1-z+kXyz0nS8/6x5J1ivUxc+sfq9w= + dependencies: + commander "~2.8.1" + semver-diff@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-2.1.0.tgz#4bbb8437c8d37e4b0cf1a68fd726ec6d645d6d36" @@ -12087,6 +12267,13 @@ strip-bom@^2.0.0: dependencies: is-utf8 "^0.2.0" +strip-dirs@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/strip-dirs/-/strip-dirs-2.1.0.tgz#4987736264fc344cf20f6c34aca9d13d1d4ed6c5" + integrity sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g== + dependencies: + is-natural-number "^4.0.1" + strip-eof@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" @@ -12192,6 +12379,19 @@ tapable@^1.0.0, tapable@^1.1.0: resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.1.tgz#4d297923c5a72a42360de2ab52dadfaaec00018e" integrity sha512-9I2ydhj8Z9veORCw5PRm4u9uebCn0mcCa6scWoNcbZ6dAtoo2618u9UUzxgmsCOreJpqDDuv61LvwofW7hLcBA== +tar-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" + integrity sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A== + dependencies: + bl "^1.0.0" + buffer-alloc "^1.2.0" + end-of-stream "^1.0.0" + fs-constants "^1.0.0" + readable-stream "^2.3.0" + to-buffer "^1.1.1" + xtend "^4.0.0" + tar@^4: version "4.4.8" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.8.tgz#b19eec3fde2a96e64666df9fdb40c5ca1bc3747d" @@ -12274,6 +12474,14 @@ test-exclude@^4.2.1: read-pkg-up "^1.0.1" require-main-filename "^1.0.1" +test-value@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/test-value/-/test-value-2.1.0.tgz#11da6ff670f3471a73b625ca4f3fdcf7bb748291" + integrity sha1-Edpv9nDzRxpztiXKTz/c97t0gpE= + dependencies: + array-back "^1.0.3" + typical "^2.6.0" + text-table@0.2.0, text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -12324,7 +12532,7 @@ through2@^2.0.0, through2@^2.0.1, through2@^2.0.2, through2@~2.0.0: readable-stream "~2.3.6" xtend "~4.0.1" -through@^2.3.6: +through@^2.3.6, through@^2.3.8: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU= @@ -12381,6 +12589,11 @@ to-arraybuffer@^1.0.0: resolved "https://registry.yarnpkg.com/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz#7d229b1fcc637e466ca081180836a7aabff83f43" integrity sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M= +to-buffer@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" + integrity sha512-lx9B5iv7msuFYE3dytT+KE5tap+rNYw+K4jVkb9R/asAb+pbBSM17jtunHplhBe6RRJdZx3Pn2Jph24O32mOVg== + to-fast-properties@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-1.0.3.tgz#b83571fa4d8c25b82e231b06e3a3055de4ca1a47" @@ -12514,6 +12727,11 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c= +typical@^2.6.0, typical@^2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/typical/-/typical-2.6.1.tgz#5c080e5d661cbbe38259d2e70a3c7253e873881d" + integrity sha1-XAgOXWYcu+OCWdLnCjxyU+hziB0= + ua-parser-js@^0.7.18: version "0.7.19" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.19.tgz#94151be4c0a7fb1d001af7022fdaca4642659e4b" @@ -12557,6 +12775,14 @@ ultron@~1.1.0: resolved "https://registry.yarnpkg.com/ultron/-/ultron-1.1.1.tgz#9fe1536a10a664a65266a1e3ccf85fd36302bc9c" integrity sha512-UIEXBNeYmKptWH6z8ZnqTeS8fV74zG0/eRU9VGkpzz+LIJNs8W/zM/L+7ctCkRrgbNnnR0xxw4bKOr0cW0N0Og== +unbzip2-stream@^1.0.9: + version "1.3.3" + resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.3.3.tgz#d156d205e670d8d8c393e1c02ebd506422873f6a" + integrity sha512-fUlAF7U9Ah1Q6EieQ4x4zLNejrRvDWUYmxXUpN3uziFYCHapjWFaCAnreY9bGgxzaMCFAPPpYNng57CypwJVhg== + dependencies: + buffer "^5.2.1" + through "^2.3.8" + unc-path-regex@^0.1.2: version "0.1.2" resolved "https://registry.yarnpkg.com/unc-path-regex/-/unc-path-regex-0.1.2.tgz#e73dd3d7b0d7c5ed86fbac6b0ae7d8c6a69d50fa" @@ -13345,3 +13571,11 @@ yargs@^9.0.0: which-module "^2.0.0" y18n "^3.2.1" yargs-parser "^7.0.0" + +yauzl@^2.4.2: + version "2.10.0" + resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" + integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk= + dependencies: + buffer-crc32 "~0.2.3" + fd-slicer "~1.1.0"