From 5744114d7dccfb57d0d13a4009c5d5a353cee28c Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Wed, 24 Apr 2019 15:36:29 -0300 Subject: [PATCH] [FIX] Threads (#838) Closes #826 Closes #827 Closes #828 Closes #829 Closes #830 Closes #831 Closes #832 Closes #833 --- .../__snapshots__/Storyshots.test.js.snap | 3192 +++++++++-------- app/constants/colors.js | 1 + app/containers/MessageBox/index.js | 45 +- app/containers/message/Audio.js | 38 +- app/containers/message/Markdown.js | 10 +- app/containers/message/Message.js | 55 +- app/containers/message/index.js | 4 +- app/containers/message/styles.js | 11 +- app/i18n/locales/en.js | 1 + app/i18n/locales/pt-BR.js | 1 + app/i18n/locales/pt-PT.js | 1 + app/index.js | 2 +- app/lib/methods/loadMessagesForRoom.js | 5 + app/lib/methods/loadMissedMessages.js | 11 +- app/lib/methods/loadThreadMessages.js | 14 +- app/lib/methods/sendFileMessage.js | 4 +- app/lib/realm.js | 21 +- app/lib/rocketchat.js | 14 +- app/presentation/RoomItem/LastMessage.js | 5 +- app/presentation/RoomItem/UnreadBadge.js | 4 +- app/presentation/RoomItem/index.js | 2 +- app/presentation/RoomItem/styles.js | 21 +- app/reducers/messages.js | 7 +- app/views/RoomView/Header/RightButtons.js | 9 +- app/views/RoomView/List.js | 31 +- app/views/RoomView/index.js | 54 +- app/views/RoomsListView/index.js | 111 +- app/views/ThreadMessagesView/index.js | 181 +- e2e/08-room.spec.js | 8 +- storybook/stories/Message.js | 22 +- 30 files changed, 2220 insertions(+), 1665 deletions(-) diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index a7f487408..4b04375be 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -381,6 +381,7 @@ exports[`Storyshots Message list 1`] = ` style={Object {}} > View - View + + +  + + @@ -8397,7 +8474,7 @@ exports[`Storyshots Message list 1`] = ` Object { "backgroundColor": "#1d74f5", "borderRadius": 2, - "height": 4, + "height": 2, "opacity": 0, "position": "absolute", "width": 0, @@ -8463,7 +8540,7 @@ exports[`Storyshots Message list 1`] = ` "fontFamily": "System", "fontSize": 14, "fontWeight": "400", - "marginRight": 16, + "marginHorizontal": 12, } } > @@ -8562,6 +8639,7 @@ exports[`Storyshots Message list 1`] = ` style={Object {}} > View - View + + +  + + @@ -8718,7 +8845,7 @@ exports[`Storyshots Message list 1`] = ` Object { "backgroundColor": "#1d74f5", "borderRadius": 2, - "height": 4, + "height": 2, "opacity": 0, "position": "absolute", "width": 0, @@ -8784,7 +8911,7 @@ exports[`Storyshots Message list 1`] = ` "fontFamily": "System", "fontSize": 14, "fontWeight": "400", - "marginRight": 16, + "marginHorizontal": 12, } } > @@ -8895,7 +9022,55 @@ exports[`Storyshots Message list 1`] = ` } > View - View + + +  + + @@ -8951,7 +9127,7 @@ exports[`Storyshots Message list 1`] = ` Object { "backgroundColor": "#1d74f5", "borderRadius": 2, - "height": 4, + "height": 2, "opacity": 0, "position": "absolute", "width": 0, @@ -9017,7 +9193,7 @@ exports[`Storyshots Message list 1`] = ` "fontFamily": "System", "fontSize": 14, "fontWeight": "400", - "marginRight": 16, + "marginHorizontal": 12, } } > @@ -9096,7 +9272,55 @@ exports[`Storyshots Message list 1`] = ` } > View - View + + +  + + @@ -9152,7 +9377,7 @@ exports[`Storyshots Message list 1`] = ` Object { "backgroundColor": "#1d74f5", "borderRadius": 2, - "height": 4, + "height": 2, "opacity": 0, "position": "absolute", "width": 0, @@ -9218,7 +9443,7 @@ exports[`Storyshots Message list 1`] = ` "fontFamily": "System", "fontSize": 14, "fontWeight": "400", - "marginRight": 16, + "marginHorizontal": 12, } } > @@ -9405,6 +9630,7 @@ exports[`Storyshots Message list 1`] = ` style={Object {}} > - - - - - 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? + + + + + + + I’m fine! + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + +  + + + Thread with emoji 🙂 😂 + + + + + + + I’m fine! + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + +  + + + 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 + + + + +  + + + 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 + + + + +  + + + 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 + + + + +  + + + Thread with attachment + + + + Sent an attachment + + + + + - View + + +  + + - View + + +  + + { - const { rid } = this.props; + const { rid, tmid } = this.props; this.setState({ file: { isVisible: false } }); const fileInfo = { @@ -493,7 +512,7 @@ class MessageBox extends Component { path: file.path }; try { - await RocketChat.sendFileMessage(rid, fileInfo); + await RocketChat.sendFileMessage(rid, fileInfo, tmid); } catch (e) { log('sendImageMessage', e); } @@ -539,14 +558,14 @@ class MessageBox extends Component { } finishAudioMessage = async(fileInfo) => { - const { rid } = this.props; + const { rid, tmid } = this.props; this.setState({ recording: false }); if (fileInfo) { try { - await RocketChat.sendFileMessage(rid, fileInfo); + await RocketChat.sendFileMessage(rid, fileInfo, tmid); } catch (e) { if (e && e.error === 'error-file-too-large') { return Alert.alert(I18n.t(e.error)); @@ -830,7 +849,7 @@ class MessageBox extends Component { const mapStateToProps = state => ({ message: state.messages.message, replyMessage: state.messages.replyMessage, - replying: state.messages.replyMessage && !!state.messages.replyMessage.msg, + replying: state.messages.replying, editing: state.messages.editing, baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', threadsEnabled: state.settings.Threads_enabled, diff --git a/app/containers/message/Audio.js b/app/containers/message/Audio.js index 3b53d04c5..a9cf55389 100644 --- a/app/containers/message/Audio.js +++ b/app/containers/message/Audio.js @@ -6,8 +6,8 @@ import { import Video from 'react-native-video'; import Slider from 'react-native-slider'; import moment from 'moment'; -import { BorderlessButton } from 'react-native-gesture-handler'; import equal from 'deep-equal'; +import Touchable from 'react-native-platform-touchable'; import Markdown from './Markdown'; import { CustomIcon } from '../../lib/Icons'; @@ -27,7 +27,7 @@ const styles = StyleSheet.create({ marginBottom: 6 }, playPauseButton: { - width: 56, + marginHorizontal: 10, alignItems: 'center', backgroundColor: 'transparent' }, @@ -35,11 +35,10 @@ const styles = StyleSheet.create({ color: COLOR_PRIMARY }, slider: { - flex: 1, - marginRight: 10 + flex: 1 }, duration: { - marginRight: 16, + marginHorizontal: 12, fontSize: 14, ...sharedStyles.textColorNormal, ...sharedStyles.textRegular @@ -47,10 +46,16 @@ const styles = StyleSheet.create({ thumbStyle: { width: 12, height: 12 + }, + trackStyle: { + height: 2 } }); const formatTime = seconds => moment.utc(seconds * 1000).format('mm:ss'); +const BUTTON_HIT_SLOP = { + top: 12, right: 12, bottom: 12, left: 12 +}; export default class Audio extends React.Component { static propTypes = { @@ -97,30 +102,30 @@ export default class Audio extends React.Component { return false; } - onLoad(data) { + onLoad = (data) => { this.setState({ duration: data.duration > 0 ? data.duration : 0 }); } - onProgress(data) { + onProgress = (data) => { const { duration } = this.state; if (data.currentTime <= duration) { this.setState({ currentTime: data.currentTime }); } } - onEnd() { + onEnd = () => { this.setState({ paused: true, currentTime: 0 }); requestAnimationFrame(() => { this.player.seek(0); }); } - getDuration() { + getDuration = () => { const { duration } = this.state; return formatTime(duration); } - togglePlayPause() { + togglePlayPause = () => { const { paused } = this.state; this.setState({ paused: !paused }); } @@ -152,16 +157,18 @@ export default class Audio extends React.Component { paused={paused} repeat={false} /> - this.togglePlayPause()} + onPress={this.togglePlayPause} + hitSlop={BUTTON_HIT_SLOP} + background={Touchable.SelectableBackgroundBorderless()} > { paused - ? - : + ? + : } - + this.setState({ currentTime: value })} thumbStyle={styles.thumbStyle} + trackStyle={styles.trackStyle} /> {this.getDuration()} , diff --git a/app/containers/message/Markdown.js b/app/containers/message/Markdown.js index db7b1910a..11dba2055 100644 --- a/app/containers/message/Markdown.js +++ b/app/containers/message/Markdown.js @@ -22,7 +22,7 @@ export default class Markdown extends React.Component { render() { const { - msg, customEmojis, style, rules, baseUrl, username, edited + msg, customEmojis, style, rules, baseUrl, username, edited, numberOfLines } = this.props; if (!msg) { return null; @@ -32,12 +32,15 @@ export default class Markdown extends React.Component { m = emojify(m, { output: 'unicode' }); } m = m.replace(/^\[([^\]]*)\]\(([^)]*)\)/, '').trim(); + if (numberOfLines > 0) { + m = m.replace(/[\n]+/g, '\n').trim(); + } return ( ( // eslint-disable-next-line - + {children} {edited ? (edited) : null} @@ -111,5 +114,6 @@ Markdown.propTypes = { customEmojis: PropTypes.object.isRequired, style: PropTypes.any, rules: PropTypes.object, - edited: PropTypes.bool + edited: PropTypes.bool, + numberOfLines: PropTypes.number }; diff --git a/app/containers/message/Message.js b/app/containers/message/Message.js index 34df67fe0..4e0ab42d5 100644 --- a/app/containers/message/Message.js +++ b/app/containers/message/Message.js @@ -5,10 +5,8 @@ import { } from 'react-native'; import moment from 'moment'; import { KeyboardUtils } from 'react-native-keyboard-input'; -import { - BorderlessButton -} from 'react-native-gesture-handler'; import Touchable from 'react-native-platform-touchable'; +import { emojify } from 'react-emojione'; import Image from './Image'; import User from './User'; @@ -164,6 +162,11 @@ export default class Message extends PureComponent { onPress = () => { KeyboardUtils.dismiss(); + + const { onThreadPress, tlm, tmid } = this.props; + if ((tlm || tmid) && onThreadPress) { + onThreadPress(); + } } onLongPress = () => { @@ -269,10 +272,25 @@ export default class Message extends PureComponent { if (this.isInfoMessage()) { return {getInfoMessage({ ...this.props })}; } + const { - customEmojis, msg, baseUrl, user, edited + customEmojis, msg, baseUrl, user, edited, tmid } = this.props; - return ; + + if (tmid && !msg) { + return {I18n.t('Sent_an_attachment')}; + } + + return ( + + ); } renderAttachment() { @@ -316,9 +334,9 @@ export default class Message extends PureComponent { } const { onErrorPress } = this.props; return ( - + - + ); } @@ -457,7 +475,7 @@ export default class Message extends PureComponent { renderRepliedThread = () => { const { - tmid, tmsg, header, onThreadPress, fetchThreadName + tmid, tmsg, header, fetchThreadName } = this.props; if (!tmid || !header || this.isTemp()) { return null; @@ -468,15 +486,18 @@ export default class Message extends PureComponent { return null; } + const msg = emojify(tmsg, { output: 'unicode' }); + return ( - - {I18n.t('Replied_on')} {tmsg} - + + + {msg} + ); } renderInner = () => { - const { type } = this.props; + const { type, tmid } = this.props; if (type === 'discussion-created') { return ( @@ -485,10 +506,18 @@ export default class Message extends PureComponent { ); } + if (tmid) { + return ( + + {this.renderUsername()} + {this.renderRepliedThread()} + {this.renderContent()} + + ); + } return ( {this.renderUsername()} - {this.renderRepliedThread()} {this.renderContent()} {this.renderAttachment()} {this.renderUrl()} diff --git a/app/containers/message/index.js b/app/containers/message/index.js index e5e0e909a..e3eb9fe0e 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -76,7 +76,7 @@ export default class MessageContainer extends React.Component { shouldComponentUpdate(nextProps, nextState) { const { reactionsModal } = this.state; const { - status, editingMessage, item, _updatedAt + status, editingMessage, item, _updatedAt, navigation } = this.props; if (reactionsModal !== nextState.reactionsModal) { @@ -89,7 +89,7 @@ export default class MessageContainer extends React.Component { return true; } - if (!equal(editingMessage, nextProps.editingMessage)) { + if (navigation.isFocused() && !equal(editingMessage, nextProps.editingMessage)) { if (nextProps.editingMessage && nextProps.editingMessage._id === item._id) { return true; } else if (!nextProps.editingMessage._id !== item._id && editingMessage._id === item._id) { diff --git a/app/containers/message/styles.js b/app/containers/message/styles.js index 678bec4af..954b8ed06 100644 --- a/app/containers/message/styles.js +++ b/app/containers/message/styles.js @@ -216,13 +216,16 @@ export default StyleSheet.create({ fontWeight: '300' }, repliedThread: { - fontSize: 16, - marginBottom: 6, - ...sharedStyles.textColorDescription, - ...sharedStyles.textRegular + flexDirection: 'row', + flex: 1 + }, + repliedThreadIcon: { + color: COLOR_PRIMARY }, repliedThreadName: { fontSize: 16, + fontStyle: 'normal', + flex: 1, color: COLOR_PRIMARY, ...sharedStyles.textSemibold } diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index f855e3906..364e55ccf 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -293,6 +293,7 @@ export default { Send: 'Send', Send_audio_message: 'Send audio message', Send_message: 'Send message', + Sent_an_attachment: 'Sent an attachment', Server: 'Server', Servers: 'Servers', Set_username_subtitle: 'The username is used to allow others to mention you in messages', diff --git a/app/i18n/locales/pt-BR.js b/app/i18n/locales/pt-BR.js index 228e268fb..e3c9078b3 100644 --- a/app/i18n/locales/pt-BR.js +++ b/app/i18n/locales/pt-BR.js @@ -295,6 +295,7 @@ export default { Send: 'Enviar', Send_audio_message: 'Enviar mensagem de áudio', Send_message: 'Enviar mensagem', + Sent_an_attachment: 'Enviou um anexo', Server: 'Servidor', Set_username_subtitle: 'O usuário é utilizado para permitir que você seja mencionado em mensagens', Settings: 'Configurações', diff --git a/app/i18n/locales/pt-PT.js b/app/i18n/locales/pt-PT.js index d850e25f6..f16bc95ac 100644 --- a/app/i18n/locales/pt-PT.js +++ b/app/i18n/locales/pt-PT.js @@ -284,6 +284,7 @@ export default { Send: 'Enviar', Send_audio_message: 'Enviar mensagem de áudio', Send_message: 'Enviar mensagem', + Sent_an_attachment: 'Enviou um ficheiro', Server: 'Servidor', Servers: 'Servidores', Set_username_subtitle: 'O nome de utilizador é usado para permitir que outros mencionem você em mensagens', diff --git a/app/index.js b/app/index.js index bf2b55fd4..c6978910c 100644 --- a/app/index.js +++ b/app/index.js @@ -145,7 +145,7 @@ const ProfileStack = createStackNavigator({ defaultNavigationOptions: defaultHeader }); -ProfileView.navigationOptions = ({ navigation }) => { +ProfileStack.navigationOptions = ({ navigation }) => { let drawerLockMode = 'unlocked'; if (navigation.state.index > 0) { drawerLockMode = 'locked-closed'; diff --git a/app/lib/methods/loadMessagesForRoom.js b/app/lib/methods/loadMessagesForRoom.js index bb2f26c90..e24151a89 100644 --- a/app/lib/methods/loadMessagesForRoom.js +++ b/app/lib/methods/loadMessagesForRoom.js @@ -46,6 +46,11 @@ export default function loadMessagesForRoom(...args) { if (message.tlm) { database.create('threads', message, true); } + // if it belongs to a thread + if (message.tmid) { + message.rid = message.tmid; + database.create('threadMessages', message, true); + } } catch (e) { log('loadMessagesForRoom -> create messages', e); } diff --git a/app/lib/methods/loadMissedMessages.js b/app/lib/methods/loadMissedMessages.js index 202411a06..e0d261a4b 100644 --- a/app/lib/methods/loadMissedMessages.js +++ b/app/lib/methods/loadMissedMessages.js @@ -40,11 +40,14 @@ export default function loadMissedMessages(...args) { if (message.tlm) { database.create('threads', message, true); } + if (message.tmid) { + message.rid = message.tmid; + database.create('threadMessages', message, true); + } } catch (e) { log('loadMissedMessages -> create messages', e); } })); - resolve(updated); }); } if (data.deleted && data.deleted.length) { @@ -55,6 +58,10 @@ export default function loadMissedMessages(...args) { deleted.forEach((m) => { const message = database.objects('messages').filtered('_id = $0', m._id); database.delete(message); + const thread = database.objects('threads').filtered('_id = $0', m._id); + database.delete(thread); + const threadMessage = database.objects('threadMessages').filtered('_id = $0', m._id); + database.delete(threadMessage); }); }); } catch (e) { @@ -63,7 +70,7 @@ export default function loadMissedMessages(...args) { }); } } - resolve([]); + resolve(); } catch (e) { log('loadMissedMessages', e); reject(e); diff --git a/app/lib/methods/loadThreadMessages.js b/app/lib/methods/loadThreadMessages.js index 077ca4427..1c02ef131 100644 --- a/app/lib/methods/loadThreadMessages.js +++ b/app/lib/methods/loadThreadMessages.js @@ -5,24 +5,26 @@ import buildMessage from './helpers/buildMessage'; import database from '../realm'; import log from '../../utils/log'; -async function load({ tmid, skip }) { +async function load({ tmid, offset }) { try { // RC 1.0 - const data = await this.sdk.methodCall('getThreadMessages', { tmid, limit: 50, skip }); - if (!data || data.status === 'error') { + const result = await this.sdk.get('chat.getThreadMessages', { + tmid, count: 50, offset, sort: { ts: -1 } + }); + if (!result || !result.success) { return []; } - return data; + return result.messages; } catch (error) { console.log(error); return []; } } -export default function loadThreadMessages({ tmid, skip }) { +export default function loadThreadMessages({ tmid, offset = 0 }) { return new Promise(async(resolve, reject) => { try { - const data = await load.call(this, { tmid, skip }); + const data = await load.call(this, { tmid, offset }); if (data && data.length) { InteractionManager.runAfterInteractions(() => { diff --git a/app/lib/methods/sendFileMessage.js b/app/lib/methods/sendFileMessage.js index 5d836e96a..b04277237 100644 --- a/app/lib/methods/sendFileMessage.js +++ b/app/lib/methods/sendFileMessage.js @@ -29,7 +29,7 @@ export async function cancelUpload(path) { } } -export async function sendFileMessage(rid, fileInfo) { +export async function sendFileMessage(rid, fileInfo, tmid) { try { const data = await RNFetchBlob.wrap(fileInfo.path); if (!fileInfo.size) { @@ -86,6 +86,8 @@ export async function sendFileMessage(rid, fileInfo) { name: completeResult.name, description: completeResult.description, url: completeResult.path + }, { + tmid }); database.write(() => { diff --git a/app/lib/realm.js b/app/lib/realm.js index cbb8a5cb9..ee37d1211 100644 --- a/app/lib/realm.js +++ b/app/lib/realm.js @@ -104,7 +104,8 @@ const subscriptionSchema = { muted: { type: 'list', objectType: 'usersMuted' }, broadcast: { type: 'bool', optional: true }, prid: { type: 'string', optional: true }, - draftMessage: { type: 'string', optional: true } + draftMessage: { type: 'string', optional: true }, + lastThreadSync: 'date?' } }; @@ -259,7 +260,8 @@ const threadsSchema = { tmid: { type: 'string', optional: true }, tcount: { type: 'int', optional: true }, tlm: { type: 'date', optional: true }, - replies: 'string[]' + replies: 'string[]', + draftMessage: 'string?' } }; @@ -387,9 +389,9 @@ class DB { schema: [ serversSchema ], - schemaVersion: 4, + schemaVersion: 5, migration: (oldRealm, newRealm) => { - if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 3) { + if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 5) { const newServers = newRealm.objects('servers'); // eslint-disable-next-line no-plusplus @@ -441,15 +443,12 @@ class DB { setActiveDB(database = '') { const path = database.replace(/(^\w+:|^)\/\//, ''); - if (this.database) { - this.database.close(); - } return this.databases.activeDB = new Realm({ path: `${ path }.realm`, schema, - schemaVersion: 6, + schemaVersion: 8, migration: (oldRealm, newRealm) => { - if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 6) { + if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 8) { const newSubs = newRealm.objects('subscriptions'); // eslint-disable-next-line no-plusplus @@ -459,6 +458,10 @@ class DB { } const newMessages = newRealm.objects('messages'); newRealm.delete(newMessages); + const newThreads = newRealm.objects('threads'); + newRealm.delete(newThreads); + const newThreadMessages = newRealm.objects('threadMessages'); + newRealm.delete(newThreadMessages); } } }); diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index edc93d409..d3916642e 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -416,8 +416,6 @@ const RocketChat = { data = data.filtered('t != $0', 'd'); } data = data.slice(0, 7); - const array = Array.from(data); - data = JSON.parse(JSON.stringify(array)); const usernames = data.map(sub => sub.name); try { @@ -782,9 +780,17 @@ const RocketChat = { } return this.sdk.methodCall('unfollowMessage', { mid }); }, - getThreadsList({ rid, limit, skip }) { + getThreadsList({ rid, count, offset }) { // RC 1.0 - return this.sdk.methodCall('getThreadsList', { rid, limit, skip }); + return this.sdk.get('chat.getThreadsList', { + rid, count, offset, sort: { ts: -1 } + }); + }, + getSyncThreadsList({ rid, updatedSince }) { + // RC 1.0 + return this.sdk.get('chat.syncThreadsList', { + rid, updatedSince + }); } }; diff --git a/app/presentation/RoomItem/LastMessage.js b/app/presentation/RoomItem/LastMessage.js index c89b391f2..9b3e59b56 100644 --- a/app/presentation/RoomItem/LastMessage.js +++ b/app/presentation/RoomItem/LastMessage.js @@ -41,7 +41,7 @@ const formatMsg = ({ const arePropsEqual = (oldProps, newProps) => _.isEqual(oldProps, newProps); const LastMessage = React.memo(({ - lastMessage, type, showLastMessage, username + lastMessage, type, showLastMessage, username, alert }) => ( {formatMsg({ @@ -54,7 +54,8 @@ LastMessage.propTypes = { lastMessage: PropTypes.object, type: PropTypes.string, showLastMessage: PropTypes.bool, - username: PropTypes.string + username: PropTypes.string, + alert: PropTypes.bool }; export default LastMessage; diff --git a/app/presentation/RoomItem/UnreadBadge.js b/app/presentation/RoomItem/UnreadBadge.js index d8ec84eb2..eb6a72bad 100644 --- a/app/presentation/RoomItem/UnreadBadge.js +++ b/app/presentation/RoomItem/UnreadBadge.js @@ -14,8 +14,8 @@ const UnreadBadge = React.memo(({ unread, userMentions, type }) => { const mentioned = userMentions > 0 && type !== 'd'; return ( - - { unread } + + { unread } ); }); diff --git a/app/presentation/RoomItem/index.js b/app/presentation/RoomItem/index.js index 38f50da31..335d1ef68 100644 --- a/app/presentation/RoomItem/index.js +++ b/app/presentation/RoomItem/index.js @@ -109,7 +109,7 @@ export default class RoomItem extends React.Component { {_updatedAt ? { date } : null} - + diff --git a/app/presentation/RoomItem/styles.js b/app/presentation/RoomItem/styles.js index 9c941ec1b..87fb92c94 100644 --- a/app/presentation/RoomItem/styles.js +++ b/app/presentation/RoomItem/styles.js @@ -2,7 +2,7 @@ import { StyleSheet, PixelRatio } from 'react-native'; import sharedStyles from '../../views/Styles'; import { - COLOR_SEPARATOR, COLOR_PRIMARY, COLOR_WHITE, COLOR_TEXT + COLOR_SEPARATOR, COLOR_PRIMARY, COLOR_WHITE, COLOR_UNREAD, COLOR_TEXT } from '../../constants/colors'; export const ROW_HEIGHT = 75 * PixelRatio.getFontScale(); @@ -53,27 +53,30 @@ export default StyleSheet.create({ ...sharedStyles.textSemibold }, unreadNumberContainer: { - minWidth: 22, - height: 22, + minWidth: 21, + height: 21, paddingVertical: 3, paddingHorizontal: 5, - borderRadius: 14, - backgroundColor: COLOR_TEXT, + borderRadius: 10.5, + backgroundColor: COLOR_UNREAD, alignItems: 'center', justifyContent: 'center', marginLeft: 10 }, - unreadMentioned: { + unreadMentionedContainer: { backgroundColor: COLOR_PRIMARY }, - unreadNumberText: { - color: COLOR_WHITE, + unreadText: { + color: COLOR_TEXT, overflow: 'hidden', fontSize: 13, - ...sharedStyles.textRegular, + ...sharedStyles.textMedium, letterSpacing: 0.56, textAlign: 'center' }, + unreadMentionedText: { + color: COLOR_WHITE + }, status: { marginRight: 7, marginTop: 3 diff --git a/app/reducers/messages.js b/app/reducers/messages.js index 85f7e57dd..ea9163474 100644 --- a/app/reducers/messages.js +++ b/app/reducers/messages.js @@ -4,6 +4,7 @@ const initialState = { message: {}, actionMessage: {}, replyMessage: {}, + replying: false, editing: false, showActions: false, showErrorActions: false, @@ -64,12 +65,14 @@ export default function messages(state = initialState, action) { replyMessage: { ...action.message, mention: action.mention - } + }, + replying: true }; case types.MESSAGES.REPLY_CANCEL: return { ...state, - replyMessage: {} + replyMessage: {}, + replying: false }; case types.MESSAGES.SET_INPUT: return { diff --git a/app/views/RoomView/Header/RightButtons.js b/app/views/RoomView/Header/RightButtons.js index b251990a8..10a6c8e46 100644 --- a/app/views/RoomView/Header/RightButtons.js +++ b/app/views/RoomView/Header/RightButtons.js @@ -10,10 +10,14 @@ import log from '../../../utils/log'; const styles = StyleSheet.create({ more: { - marginHorizontal: 0, marginLeft: 0, marginRight: 5 + marginHorizontal: 0, + marginLeft: 0, + marginRight: 5 }, thread: { - marginHorizontal: 0, marginLeft: 0, marginRight: 10 + marginHorizontal: 0, + marginLeft: 0, + marginRight: 15 } }); @@ -34,6 +38,7 @@ class RightButtonsContainer extends React.PureComponent { constructor(props) { super(props); if (props.tmid) { + // FIXME: it may be empty if the thread header isn't fetched yet this.thread = database.objectForPrimaryKey('messages', props.tmid); safeAddListener(this.thread, this.updateThread); } diff --git a/app/views/RoomView/List.js b/app/views/RoomView/List.js index 37b062f96..fb48ae245 100644 --- a/app/views/RoomView/List.js +++ b/app/views/RoomView/List.js @@ -1,7 +1,6 @@ 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'; @@ -30,7 +29,7 @@ export class List extends React.PureComponent { .objects('threadMessages') .filtered('rid = $0', props.tmid) .sorted('ts', true); - this.threads = []; + this.threads = database.objects('threads').filtered('_id = $0', props.tmid); } else { this.data = database .objects('messages') @@ -83,7 +82,7 @@ export class List extends React.PureComponent { }); }, 300, { leading: true }); - onEndReached = async() => { + onEndReached = debounce(async() => { const { loading, end, messages } = this.state; @@ -96,17 +95,17 @@ export class List extends React.PureComponent { try { let result; if (tmid) { - result = await RocketChat.loadThreadMessages({ tmid, skip: messages.length }); + result = await RocketChat.loadThreadMessages({ tmid, offset: messages.length }); } else { result = await RocketChat.loadMessagesForRoom({ rid, t, latest: messages[messages.length - 1].ts }); } - this.setState({ end: result.length < 50 }); + this.setState({ end: result.length < 50, loading: false }); } catch (e) { this.setState({ loading: false }); log('ListView.onEndReached', e); } - } + }, 300) renderFooter = () => { const { loading } = this.state; @@ -122,10 +121,7 @@ export class List extends React.PureComponent { 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); - if (tmsg) { - tmsg = emojify(tmsg, { output: 'unicode' }); - } + const tmsg = thread.msg || (thread.attachments && thread.attachments.length && thread.attachments[0].title); item = { ...item, tmsg }; } } @@ -134,15 +130,24 @@ export class List extends React.PureComponent { render() { console.count(`${ this.constructor.name }.render calls`); - const { messages } = this.state; + const { messages, threads } = this.state; + const { tmid } = this.props; + let data = []; + if (tmid) { + const thread = { ...threads[0] }; + thread.tlm = null; + data = [...messages, thread]; + } else { + data = messages; + } return ( - + this.list = ref} keyExtractor={item => item._id} - data={messages} + data={data} extraData={this.state} renderItem={this.renderItem} contentContainerStyle={styles.contentContainer} diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index 7625453f9..4354ca1fd 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -46,6 +46,8 @@ import buildMessage from '../../lib/methods/helpers/buildMessage'; token: state.login.user && state.login.user.token }, actionMessage: state.messages.actionMessage, + editing: state.messages.editing, + replying: state.messages.replying, showActions: state.messages.showActions, showErrorActions: state.messages.showErrorActions, appState: state.app.ready && state.app.foreground ? 'foreground' : 'background', @@ -87,6 +89,8 @@ export default class RoomView extends LoggedView { appState: PropTypes.string, useRealName: PropTypes.bool, isAuthenticated: PropTypes.bool, + editing: PropTypes.bool, + replying: PropTypes.bool, toggleReactionPicker: PropTypes.func.isRequired, actionsShow: PropTypes.func, editCancel: PropTypes.func, @@ -176,12 +180,18 @@ export default class RoomView extends LoggedView { } componentWillUnmount() { - if (this.messagebox && this.messagebox.current && this.messagebox.current.text) { + const { editing, replying } = this.props; + if (!editing && this.messagebox && this.messagebox.current && this.messagebox.current.text) { const { text } = this.messagebox.current; - const [room] = this.rooms; - if (room) { + let obj; + if (this.tmid) { + obj = database.objectForPrimaryKey('threads', this.tmid); + } else { + [obj] = this.rooms; + } + if (obj) { database.write(() => { - room.draftMessage = text; + obj.draftMessage = text; }); } } @@ -192,9 +202,14 @@ export default class RoomView extends LoggedView { if (this.beginAnimatingTimeout) { clearTimeout(this.beginAnimatingTimeout); } - const { editCancel, replyCancel } = this.props; - editCancel(); - replyCancel(); + if (editing) { + const { editCancel } = this.props; + editCancel(); + } + if (replying) { + const { replyCancel } = this.props; + replyCancel(); + } if (this.didMountInteraction && this.didMountInteraction.cancel) { this.didMountInteraction.cancel(); } @@ -217,7 +232,7 @@ export default class RoomView extends LoggedView { this.initInteraction = InteractionManager.runAfterInteractions(async() => { const { room } = this.state; if (this.tmid) { - RocketChat.loadThreadMessages({ tmid: this.tmid, t: this.t }); + await this.getThreadMessages(); } else { await this.getMessages(room); @@ -241,7 +256,7 @@ export default class RoomView extends LoggedView { onMessageLongPress = (message) => { const { actionsShow } = this.props; - actionsShow(message); + actionsShow({ ...message, rid: this.rid }); } onReactionPress = (shortname, messageId) => { @@ -311,6 +326,15 @@ export default class RoomView extends LoggedView { } } + getThreadMessages = () => { + try { + return RocketChat.loadThreadMessages({ tmid: this.tmid }); + } catch (e) { + console.log('TCL: getThreadMessages -> e', e); + log('getThreadMessages', e); + } + } + setLastOpen = lastOpen => this.setState({ lastOpen }); joinRoom = async() => { @@ -420,6 +444,7 @@ export default class RoomView extends LoggedView { renderFooter = () => { const { joined, room } = this.state; + const { navigation } = this.props; if (!joined && !this.tmid) { return ( @@ -450,7 +475,16 @@ export default class RoomView extends LoggedView { ); } - return ; + return ( + + ); }; renderActions = () => { diff --git a/app/views/RoomsListView/index.js b/app/views/RoomsListView/index.js index bffa904cc..165353b37 100644 --- a/app/views/RoomsListView/index.js +++ b/app/views/RoomsListView/index.js @@ -175,45 +175,6 @@ export default class RoomsListView extends LoggedView { return true; } - const { showUnread, showFavorites, groupByType } = this.props; - if (showUnread) { - const { unread } = this.state; - if (!isEqual(nextState.unread, unread)) { - return true; - } - } - if (showFavorites) { - const { favorites } = this.state; - if (!isEqual(nextState.favorites, favorites)) { - return true; - } - } - if (groupByType) { - const { - dicussions, channels, privateGroup, direct, livechat - } = this.state; - if (!isEqual(nextState.dicussions, dicussions)) { - return true; - } - if (!isEqual(nextState.channels, channels)) { - return true; - } - if (!isEqual(nextState.privateGroup, privateGroup)) { - return true; - } - if (!isEqual(nextState.direct, direct)) { - return true; - } - if (!isEqual(nextState.livechat, livechat)) { - return true; - } - } else { - const { chats } = this.state; - if (!isEqual(nextState.chats, chats)) { - return true; - } - } - const { search } = this.state; if (!isEqual(nextState.search, search)) { return true; @@ -311,27 +272,20 @@ export default class RoomsListView extends LoggedView { updateState = debounce(() => { this.updateStateInteraction = InteractionManager.runAfterInteractions(() => { this.internalSetState({ - chats: this.getSnapshot(this.chats), - unread: this.getSnapshot(this.unread), - favorites: this.getSnapshot(this.favorites), - discussions: this.getSnapshot(this.discussions), - channels: this.getSnapshot(this.channels), - privateGroup: this.getSnapshot(this.privateGroup), - direct: this.getSnapshot(this.direct), - livechat: this.getSnapshot(this.livechat), + chats: this.chats, + unread: this.unread, + favorites: this.favorites, + discussions: this.discussions, + channels: this.channels, + privateGroup: this.privateGroup, + direct: this.direct, + livechat: this.livechat, loading: false }); + this.forceUpdate(); }); }, 300); - getSnapshot = (data) => { - if (data && data.length) { - const array = Array.from(data); - return JSON.parse(JSON.stringify(array)); - } - return []; - } - initSearchingAndroid = () => { const { openSearchHeader, navigation } = this.props; this.setState({ searching: true }); @@ -441,26 +395,29 @@ export default class RoomsListView extends LoggedView { } = this.props; const id = item.rid.replace(userId, '').trim(); - return ( - this._onPressItem(item)} - testID={`rooms-list-view-item-${ item.name }`} - height={ROW_HEIGHT} - /> - ); + if (item.search || (item.isValid && item.isValid())) { + return ( + this._onPressItem(item)} + testID={`rooms-list-view-item-${ item.name }`} + height={ROW_HEIGHT} + /> + ); + } + return null; } renderSectionHeader = header => ( @@ -481,11 +438,10 @@ export default class RoomsListView extends LoggedView { } else if (header === 'Chats' && groupByType) { return null; } - if (data.length > 0) { + if (data && data.length > 0) { return ( ); +const API_FETCH_COUNT = 50; @connect(state => ({ baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', @@ -47,72 +46,136 @@ export default class ThreadMessagesView extends LoggedView { 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); + this.rooms = database.objects('subscriptions').filtered('rid = $0', this.rid); + this.messages = database.objects('threads').filtered('rid = $0', this.rid).sorted('ts', true); safeAddListener(this.messages, this.updateMessages); this.state = { loading: false, - messages: this.messages.slice(), end: false, - total: 0 + messages: this.messages }; + this.mounted = false; } componentDidMount() { - this.load(); + this.mountInteraction = InteractionManager.runAfterInteractions(() => { + this.init(); + this.mounted = true; + }); } - shouldComponentUpdate(nextProps, nextState) { - const { loading, messages, end } = this.state; - if (nextState.loading !== loading) { - return true; + componentWillUnmount() { + this.messages.removeAllListeners(); + if (this.mountInteraction && this.mountInteraction.cancel) { + this.mountInteraction.cancel(); } - if (!equal(nextState.messages, messages)) { - return true; + if (this.loadInteraction && this.loadInteraction.cancel) { + this.loadInteraction.cancel(); } - if (!equal(nextState.end, end)) { - return true; + if (this.syncInteraction && this.syncInteraction.cancel) { + this.syncInteraction.cancel(); } - return false; } - updateMessages = () => { - this.setState({ messages: this.messages.slice() }); + // eslint-disable-next-line react/sort-comp + updateMessages = debounce(() => { + this.setState({ messages: this.messages }); + }, 300) + + init = () => { + const [room] = this.rooms; + const lastThreadSync = new Date(); + if (room.lastThreadSync) { + this.sync(room.lastThreadSync); + } else { + this.load(); + } + database.write(() => { + room.lastThreadSync = lastThreadSync; + }); } // eslint-disable-next-line react/sort-comp load = debounce(async() => { - const { - loading, end, total - } = this.state; - if (end || loading) { + const { loading, end } = this.state; + if (end || loading || !this.mounted) { 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 - })); + const result = await RocketChat.getThreadsList({ + rid: this.rid, count: API_FETCH_COUNT, offset: this.messages.length }); + if (result.success) { + this.loadInteraction = InteractionManager.runAfterInteractions(() => { + database.write(() => result.threads.forEach((message) => { + try { + database.create('threads', buildMessage(message), true); + } catch (e) { + log('ThreadMessagesView -> load -> create', e); + } + })); + + this.setState({ + loading: false, + end: result.count < API_FETCH_COUNT + }); + }); + } } catch (error) { - console.log('ThreadMessagesView -> catch -> error', error); + console.log('ThreadMessagesView -> load -> error', error); this.setState({ loading: false, end: true }); } - }, 300, true) + }, 300) + + // eslint-disable-next-line react/sort-comp + sync = async(updatedSince) => { + this.setState({ loading: true }); + + try { + const result = await RocketChat.getSyncThreadsList({ + rid: this.rid, updatedSince: updatedSince.toISOString() + }); + if (result.success && result.threads) { + this.syncInteraction = InteractionManager.runAfterInteractions(() => { + const { update, remove } = result.threads; + database.write(() => { + if (update && update.length) { + update.forEach((message) => { + try { + database.create('threads', buildMessage(message), true); + } catch (e) { + log('ThreadMessagesView -> sync -> update', e); + } + }); + } + + if (remove && remove.length) { + remove.forEach((message) => { + const oldMessage = database.objectForPrimaryKey('threads', message._id); + if (oldMessage) { + try { + database.delete(oldMessage); + } catch (e) { + log('ThreadMessagesView -> sync -> delete', e); + } + } + }); + } + }); + + this.setState({ + loading: false + }); + }); + } + } catch (error) { + console.log('ThreadMessagesView -> sync -> error', error); + this.setState({ loading: false }); + } + } formatMessage = lm => ( lm ? moment(lm).calendar(null, { @@ -133,28 +196,31 @@ export default class ThreadMessagesView extends LoggedView { renderItem = ({ item }) => { const { user, navigation } = this.props; - return ( - - ); + if (item.isValid && item.isValid()) { + return ( + + ); + } + return null; } render() { - const { messages, loading } = this.state; + const { loading, messages } = this.state; - if (!loading && messages.length === 0) { + if (!loading && this.messages.length === 0) { return this.renderEmpty(); } @@ -163,6 +229,7 @@ export default class ThreadMessagesView extends LoggedView { { 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(); + await waitFor(element(by.id(`message-thread-button-${ thread }`))).toExist().withTimeout(5000); + await expect(element(by.id(`message-thread-button-${ thread }`))).toExist(); + await waitFor(element(by.id(`message-thread-replied-on-${ thread }`))).toExist().withTimeout(5000); + await expect(element(by.id(`message-thread-replied-on-${ thread }`))).toExist(); }); it('should navigate to thread from button', async() => { diff --git a/storybook/stories/Message.js b/storybook/stories/Message.js index 0d18d0b67..a07f4765a 100644 --- a/storybook/stories/Message.js +++ b/storybook/stories/Message.js @@ -263,7 +263,6 @@ export default ( header={false} /> - {/* Legacy thread */} + - + {/*