diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index 36433392c..eec5b535a 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -11185,6 +11185,3689 @@ exports[`Storyshots List with small font 1`] = ` `; +exports[`Storyshots LoadMore basic 1`] = ` +Array [ + + Load More + , + + Load More + , + + Load Older + , + + Load Newer + , +] +`; + +exports[`Storyshots LoadMore black theme 1`] = ` + + + + Load Older + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + Hey! + + + + + + + + + + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + + + + + + + + + + + + + + + Older message + + + + + + + + + + Load Newer + + + Load More + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + + + + + + + + + + + + + + + This is the third message + + + + + + + + + + + + + + + + + This is the second message + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + This is the first message + + + + + + + + + + +`; + +exports[`Storyshots LoadMore dark theme 1`] = ` + + + + Load Older + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + Hey! + + + + + + + + + + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + + + + + + + + + + + + + + + Older message + + + + + + + + + + Load Newer + + + Load More + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + + + + + + + + + + + + + + + This is the third message + + + + + + + + + + + + + + + + + This is the second message + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + This is the first message + + + + + + + + + + +`; + +exports[`Storyshots LoadMore light theme 1`] = ` + + + + Load Older + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + Hey! + + + + + + + + + + + + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + + + + + + + + + + + + + + + Older message + + + + + + + + + + Load Newer + + + Load More + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industrys standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries + + + + + + + + + + + + + + + + + This is the third message + + + + + + + + + + + + + + + + + This is the second message + + + + + + + + + + + + + + + + + + + + + + + + diego.mello + + + + 10:00 AM + + + + + + This is the first message + + + + + + + + + + +`; + exports[`Storyshots Markdown Block quote 1`] = ` { const handlePress = () => { if (!link) { return; } - openLink(link, theme); + onLinkPress(link); }; const childLength = React.Children.toArray(children).filter(o => o).length; @@ -40,7 +39,8 @@ const Link = React.memo(({ Link.propTypes = { children: PropTypes.node, link: PropTypes.string, - theme: PropTypes.string + theme: PropTypes.string, + onLinkPress: PropTypes.func }; export default Link; diff --git a/app/containers/markdown/index.js b/app/containers/markdown/index.js index dfbae1841..bc2fdba73 100644 --- a/app/containers/markdown/index.js +++ b/app/containers/markdown/index.js @@ -82,7 +82,8 @@ class Markdown extends PureComponent { preview: PropTypes.bool, theme: PropTypes.string, testID: PropTypes.string, - style: PropTypes.array + style: PropTypes.array, + onLinkPress: PropTypes.func }; constructor(props) { @@ -218,11 +219,12 @@ class Markdown extends PureComponent { }; renderLink = ({ children, href }) => { - const { theme } = this.props; + const { theme, onLinkPress } = this.props; return ( {children} diff --git a/app/containers/message/Content.js b/app/containers/message/Content.js index 2f29bf4ac..90af48acd 100644 --- a/app/containers/message/Content.js +++ b/app/containers/message/Content.js @@ -45,7 +45,7 @@ const Content = React.memo((props) => { } else if (props.isEncrypted) { content = {I18n.t('Encrypted_message')}; } else { - const { baseUrl, user } = useContext(MessageContext); + const { baseUrl, user, onLinkPress } = useContext(MessageContext); content = ( { tmid={props.tmid} useRealName={props.useRealName} theme={props.theme} + onLinkPress={onLinkPress} /> ); } diff --git a/app/containers/message/Message.js b/app/containers/message/Message.js index 104244740..4bc03c000 100644 --- a/app/containers/message/Message.js +++ b/app/containers/message/Message.js @@ -19,6 +19,7 @@ import Discussion from './Discussion'; import Content from './Content'; import ReadReceipt from './ReadReceipt'; import CallButton from './CallButton'; +import { themes } from '../../constants/colors'; const MessageInner = React.memo((props) => { if (props.type === 'discussion-created') { @@ -120,6 +121,7 @@ const MessageTouchable = React.memo((props) => { onLongPress={onLongPress} onPress={onPress} disabled={(props.isInfo && !props.isThreadReply) || props.archived || props.isTemp} + style={{ backgroundColor: props.highlighted ? themes[props.theme].headerBackground : null }} > @@ -134,7 +136,9 @@ MessageTouchable.propTypes = { isInfo: PropTypes.bool, isThreadReply: PropTypes.bool, isTemp: PropTypes.bool, - archived: PropTypes.bool + archived: PropTypes.bool, + highlighted: PropTypes.bool, + theme: PropTypes.string }; Message.propTypes = { diff --git a/app/containers/message/RepliedThread.js b/app/containers/message/RepliedThread.js index 733315485..46be5b1f6 100644 --- a/app/containers/message/RepliedThread.js +++ b/app/containers/message/RepliedThread.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { memo, useEffect, useState } from 'react'; import { View } from 'react-native'; import PropTypes from 'prop-types'; @@ -8,24 +8,29 @@ import { themes } from '../../constants/colors'; import I18n from '../../i18n'; import Markdown from '../markdown'; -const RepliedThread = React.memo(({ +const RepliedThread = memo(({ tmid, tmsg, isHeader, fetchThreadName, id, isEncrypted, theme }) => { if (!tmid || !isHeader) { return null; } - if (!tmsg) { - fetchThreadName(tmid, id); + const [msg, setMsg] = useState(isEncrypted ? I18n.t('Encrypted_message') : tmsg); + const fetch = async() => { + const threadName = await fetchThreadName(tmid, id); + setMsg(threadName); + }; + + useEffect(() => { + if (!msg) { + fetch(); + } + }, []); + + if (!msg) { return null; } - let msg = tmsg; - - if (isEncrypted) { - msg = I18n.t('Encrypted_message'); - } - return ( @@ -45,23 +50,6 @@ const RepliedThread = React.memo(({ ); -}, (prevProps, nextProps) => { - if (prevProps.tmid !== nextProps.tmid) { - return false; - } - if (prevProps.tmsg !== nextProps.tmsg) { - return false; - } - if (prevProps.isEncrypted !== nextProps.isEncrypted) { - return false; - } - if (prevProps.isHeader !== nextProps.isHeader) { - return false; - } - if (prevProps.theme !== nextProps.theme) { - return false; - } - return true; }); RepliedThread.propTypes = { diff --git a/app/containers/message/Reply.js b/app/containers/message/Reply.js index 4acecbc42..5dcf0447f 100644 --- a/app/containers/message/Reply.js +++ b/app/containers/message/Reply.js @@ -142,10 +142,13 @@ const Reply = React.memo(({ if (!attachment) { return null; } - const { baseUrl, user } = useContext(MessageContext); + const { baseUrl, user, jumpToMessage } = useContext(MessageContext); const onPress = () => { let url = attachment.title_link || attachment.author_link; + if (attachment.message_link) { + return jumpToMessage(attachment.message_link); + } if (!url) { return; } diff --git a/app/containers/message/Urls.js b/app/containers/message/Urls.js index 742b2f478..b82d029af 100644 --- a/app/containers/message/Urls.js +++ b/app/containers/message/Urls.js @@ -80,7 +80,7 @@ const UrlContent = React.memo(({ title, description, theme }) => ( }); const Url = React.memo(({ url, index, theme }) => { - if (!url) { + if (!url || url?.ignoreParse) { return null; } diff --git a/app/containers/message/index.js b/app/containers/message/index.js index b4339ff0e..467c634a6 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -9,6 +9,7 @@ import { SYSTEM_MESSAGES, getMessageTranslation } from './utils'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants'; import messagesStatus from '../../constants/messagesStatus'; import { withTheme } from '../../theme'; +import openLink from '../../utils/openLink'; class MessageContainer extends React.Component { static propTypes = { @@ -33,6 +34,7 @@ class MessageContainer extends React.Component { autoTranslateLanguage: PropTypes.string, status: PropTypes.number, isIgnored: PropTypes.bool, + highlighted: PropTypes.bool, getCustomEmoji: PropTypes.func, onLongPress: PropTypes.func, onReactionPress: PropTypes.func, @@ -50,7 +52,9 @@ class MessageContainer extends React.Component { blockAction: PropTypes.func, theme: PropTypes.string, threadBadgeColor: PropTypes.string, - toggleFollowThread: PropTypes.func + toggleFollowThread: PropTypes.func, + jumpToMessage: PropTypes.func, + onPress: PropTypes.func } static defaultProps = { @@ -89,10 +93,15 @@ class MessageContainer extends React.Component { shouldComponentUpdate(nextProps, nextState) { const { isManualUnignored } = this.state; - const { theme, threadBadgeColor, isIgnored } = this.props; + const { + theme, threadBadgeColor, isIgnored, highlighted + } = this.props; if (nextProps.theme !== theme) { return true; } + if (nextProps.highlighted !== highlighted) { + return true; + } if (nextProps.threadBadgeColor !== threadBadgeColor) { return true; } @@ -112,10 +121,15 @@ class MessageContainer extends React.Component { } onPress = debounce(() => { + const { onPress } = this.props; if (this.isIgnored) { return this.onIgnoredMessagePress(); } + if (onPress) { + return onPress(); + } + const { item, isThreadRoom } = this.props; Keyboard.dismiss(); @@ -265,12 +279,69 @@ class MessageContainer extends React.Component { } } + onLinkPress = (link) => { + const { item, theme, jumpToMessage } = this.props; + const isMessageLink = item?.attachments?.findIndex(att => att?.message_link === link) !== -1; + if (isMessageLink) { + return jumpToMessage(link); + } + openLink(link, theme); + } + render() { const { - item, user, style, archived, baseUrl, useRealName, broadcast, fetchThreadName, showAttachment, timeFormat, isReadReceiptEnabled, autoTranslateRoom, autoTranslateLanguage, navToRoomInfo, getCustomEmoji, isThreadRoom, callJitsi, blockAction, rid, theme, threadBadgeColor, toggleFollowThread + item, + user, + style, + archived, + baseUrl, + useRealName, + broadcast, + fetchThreadName, + showAttachment, + timeFormat, + isReadReceiptEnabled, + autoTranslateRoom, + autoTranslateLanguage, + navToRoomInfo, + getCustomEmoji, + isThreadRoom, + callJitsi, + blockAction, + rid, + theme, + threadBadgeColor, + toggleFollowThread, + jumpToMessage, + highlighted } = this.props; const { - id, msg, ts, attachments, urls, reactions, t, avatar, emoji, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg, mentions, channels, unread, blocks, autoTranslate: autoTranslateMessage, replies + id, + msg, + ts, + attachments, + urls, + reactions, + t, + avatar, + emoji, + u, + alias, + editedBy, + role, + drid, + dcount, + dlm, + tmid, + tcount, + tlm, + tmsg, + mentions, + channels, + unread, + blocks, + autoTranslate: autoTranslateMessage, + replies } = item; let message = msg; @@ -294,6 +365,8 @@ class MessageContainer extends React.Component { onEncryptedPress: this.onEncryptedPress, onDiscussionPress: this.onDiscussionPress, onReactionLongPress: this.onReactionLongPress, + onLinkPress: this.onLinkPress, + jumpToMessage, threadBadgeColor, toggleFollowThread, replies @@ -347,6 +420,7 @@ class MessageContainer extends React.Component { callJitsi={callJitsi} blockAction={blockAction} theme={theme} + highlighted={highlighted} /> ); diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 2e8391956..1648d3bf2 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -735,5 +735,8 @@ "Last_owner_team_room": "You are the last owner of this channel. Once you leave the team, the channel will be kept inside the team but you will be managing it from outside.", "last-owner-can-not-be-removed": "Last owner cannot be removed", "leaving_team": "leaving team", - "member-does-not-exist": "Member does not exist" + "member-does-not-exist": "Member does not exist", + "Load_More": "Load More", + "Load_Newer": "Load Newer", + "Load_Older": "Load Older" } diff --git a/app/lib/database/model/Message.js b/app/lib/database/model/Message.js index bf776fc73..52cf63f0c 100644 --- a/app/lib/database/model/Message.js +++ b/app/lib/database/model/Message.js @@ -5,8 +5,10 @@ import { import { sanitizer } from '../utils'; +export const TABLE_NAME = 'messages'; + export default class Message extends Model { - static table = 'messages'; + static table = TABLE_NAME; static associations = { subscriptions: { type: 'belongs_to', key: 'rid' } diff --git a/app/lib/database/model/Subscription.js b/app/lib/database/model/Subscription.js index 5b1ebd141..275dae217 100644 --- a/app/lib/database/model/Subscription.js +++ b/app/lib/database/model/Subscription.js @@ -4,8 +4,10 @@ import { } from '@nozbe/watermelondb/decorators'; import { sanitizer } from '../utils'; +export const TABLE_NAME = 'subscriptions'; + export default class Subscription extends Model { - static table = 'subscriptions'; + static table = TABLE_NAME; static associations = { messages: { type: 'has_many', foreignKey: 'rid' }, diff --git a/app/lib/database/model/Thread.js b/app/lib/database/model/Thread.js index e0179fc35..04e658392 100644 --- a/app/lib/database/model/Thread.js +++ b/app/lib/database/model/Thread.js @@ -5,8 +5,10 @@ import { import { sanitizer } from '../utils'; +export const TABLE_NAME = 'threads'; + export default class Thread extends Model { - static table = 'threads'; + static table = TABLE_NAME; static associations = { subscriptions: { type: 'belongs_to', key: 'rid' } diff --git a/app/lib/database/model/ThreadMessage.js b/app/lib/database/model/ThreadMessage.js index b3b4216b5..687e09f96 100644 --- a/app/lib/database/model/ThreadMessage.js +++ b/app/lib/database/model/ThreadMessage.js @@ -5,8 +5,10 @@ import { import { sanitizer } from '../utils'; +export const TABLE_NAME = 'thread_messages'; + export default class ThreadMessage extends Model { - static table = 'thread_messages'; + static table = TABLE_NAME; static associations = { subscriptions: { type: 'belongs_to', key: 'subscription_id' } diff --git a/app/lib/database/services/Message.js b/app/lib/database/services/Message.js new file mode 100644 index 000000000..5999446ba --- /dev/null +++ b/app/lib/database/services/Message.js @@ -0,0 +1,15 @@ +import database from '..'; +import { TABLE_NAME } from '../model/Message'; + +const getCollection = db => db.get(TABLE_NAME); + +export const getMessageById = async(messageId) => { + const db = database.active; + const messageCollection = getCollection(db); + try { + const result = await messageCollection.find(messageId); + return result; + } catch (error) { + return null; + } +}; diff --git a/app/lib/database/services/Subscription.js b/app/lib/database/services/Subscription.js new file mode 100644 index 000000000..925bb97e4 --- /dev/null +++ b/app/lib/database/services/Subscription.js @@ -0,0 +1,15 @@ +import database from '..'; +import { TABLE_NAME } from '../model/Subscription'; + +const getCollection = db => db.get(TABLE_NAME); + +export const getSubscriptionByRoomId = async(rid) => { + const db = database.active; + const subCollection = getCollection(db); + try { + const result = await subCollection.find(rid); + return result; + } catch (error) { + return null; + } +}; diff --git a/app/lib/database/services/Thread.js b/app/lib/database/services/Thread.js new file mode 100644 index 000000000..4c4208609 --- /dev/null +++ b/app/lib/database/services/Thread.js @@ -0,0 +1,15 @@ +import database from '..'; +import { TABLE_NAME } from '../model/Thread'; + +const getCollection = db => db.get(TABLE_NAME); + +export const getThreadById = async(tmid) => { + const db = database.active; + const threadCollection = getCollection(db); + try { + const result = await threadCollection.find(tmid); + return result; + } catch (error) { + return null; + } +}; diff --git a/app/lib/database/services/ThreadMessage.js b/app/lib/database/services/ThreadMessage.js new file mode 100644 index 000000000..ca1e5fc83 --- /dev/null +++ b/app/lib/database/services/ThreadMessage.js @@ -0,0 +1,15 @@ +import database from '..'; +import { TABLE_NAME } from '../model/ThreadMessage'; + +const getCollection = db => db.get(TABLE_NAME); + +export const getThreadMessageById = async(messageId) => { + const db = database.active; + const threadMessageCollection = getCollection(db); + try { + const result = await threadMessageCollection.find(messageId); + return result; + } catch (error) { + return null; + } +}; diff --git a/app/lib/methods/getRoomInfo.js b/app/lib/methods/getRoomInfo.js new file mode 100644 index 000000000..293d97c56 --- /dev/null +++ b/app/lib/methods/getRoomInfo.js @@ -0,0 +1,29 @@ +import { getSubscriptionByRoomId } from '../database/services/Subscription'; +import RocketChat from '../rocketchat'; + +const getRoomInfo = async(rid) => { + let result; + result = await getSubscriptionByRoomId(rid); + if (result) { + return { + rid, + name: result.name, + fname: result.fname, + t: result.t + }; + } + + result = await RocketChat.getRoomInfo(rid); + if (result?.success) { + return { + rid, + name: result.room.name, + fname: result.room.fname, + t: result.room.t + }; + } + + return null; +}; + +export default getRoomInfo; diff --git a/app/lib/methods/getSingleMessage.js b/app/lib/methods/getSingleMessage.js new file mode 100644 index 000000000..56ecb3e63 --- /dev/null +++ b/app/lib/methods/getSingleMessage.js @@ -0,0 +1,15 @@ +import RocketChat from '../rocketchat'; + +const getSingleMessage = messageId => new Promise(async(resolve, reject) => { + try { + const result = await RocketChat.getSingleMessage(messageId); + if (result.success) { + return resolve(result.message); + } + return reject(); + } catch (e) { + return reject(); + } +}); + +export default getSingleMessage; diff --git a/app/lib/methods/getThreadName.js b/app/lib/methods/getThreadName.js new file mode 100644 index 000000000..1eb1fbc78 --- /dev/null +++ b/app/lib/methods/getThreadName.js @@ -0,0 +1,49 @@ +import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; + +import database from '../database'; +import { getMessageById } from '../database/services/Message'; +import { getThreadById } from '../database/services/Thread'; +import log from '../../utils/log'; +import getSingleMessage from './getSingleMessage'; +import { Encryption } from '../encryption'; + +const buildThreadName = thread => thread.msg || thread?.attachments?.[0]?.title; + +const getThreadName = async(rid, tmid, messageId) => { + let tmsg; + try { + const db = database.active; + const threadCollection = db.get('threads'); + const messageRecord = await getMessageById(messageId); + const threadRecord = await getThreadById(tmid); + if (threadRecord) { + tmsg = buildThreadName(threadRecord); + await db.action(async() => { + await messageRecord?.update((m) => { + m.tmsg = tmsg; + }); + }); + } else { + let thread = await getSingleMessage(tmid); + thread = await Encryption.decryptMessage(thread); + tmsg = buildThreadName(thread); + await db.action(async() => { + await db.batch( + threadCollection?.prepareCreate((t) => { + t._raw = sanitizedRaw({ id: thread._id }, threadCollection.schema); + t.subscription.id = rid; + Object.assign(t, thread); + }), + messageRecord?.prepareUpdate((m) => { + m.tmsg = tmsg; + }) + ); + }); + } + } catch (e) { + log(e); + } + return tmsg; +}; + +export default getThreadName; diff --git a/app/lib/methods/loadMessagesForRoom.js b/app/lib/methods/loadMessagesForRoom.js index 012e1ea32..a8dc733ac 100644 --- a/app/lib/methods/loadMessagesForRoom.js +++ b/app/lib/methods/loadMessagesForRoom.js @@ -1,8 +1,15 @@ +import moment from 'moment'; + +import { MESSAGE_TYPE_LOAD_MORE } from '../../constants/messageTypeLoad'; import log from '../../utils/log'; +import { getMessageById } from '../database/services/Message'; import updateMessages from './updateMessages'; +import { generateLoadMoreId } from '../utils'; + +const COUNT = 50; async function load({ rid: roomId, latest, t }) { - let params = { roomId, count: 50 }; + let params = { roomId, count: COUNT }; if (latest) { params = { ...params, latest: new Date(latest).toISOString() }; } @@ -24,9 +31,20 @@ export default function loadMessagesForRoom(args) { return new Promise(async(resolve, reject) => { try { const data = await load.call(this, args); - - if (data && data.length) { - await updateMessages({ rid: args.rid, update: data }); + if (data?.length) { + const lastMessage = data[data.length - 1]; + const lastMessageRecord = await getMessageById(lastMessage._id); + if (!lastMessageRecord && data.length === COUNT) { + const loadMoreItem = { + _id: generateLoadMoreId(lastMessage._id), + rid: lastMessage.rid, + ts: moment(lastMessage.ts).subtract(1, 'millisecond'), + t: MESSAGE_TYPE_LOAD_MORE, + msg: lastMessage.msg + }; + data.push(loadMoreItem); + } + await updateMessages({ rid: args.rid, update: data, loaderItem: args.loaderItem }); return resolve(data); } else { return resolve([]); diff --git a/app/lib/methods/loadNextMessages.js b/app/lib/methods/loadNextMessages.js new file mode 100644 index 000000000..3a5e5e6ff --- /dev/null +++ b/app/lib/methods/loadNextMessages.js @@ -0,0 +1,42 @@ +import EJSON from 'ejson'; +import moment from 'moment'; +import orderBy from 'lodash/orderBy'; + +import log from '../../utils/log'; +import updateMessages from './updateMessages'; +import { getMessageById } from '../database/services/Message'; +import { MESSAGE_TYPE_LOAD_NEXT_CHUNK } from '../../constants/messageTypeLoad'; +import { generateLoadMoreId } from '../utils'; + +const COUNT = 50; + +export default function loadNextMessages(args) { + return new Promise(async(resolve, reject) => { + try { + const data = await this.methodCallWrapper('loadNextMessages', args.rid, args.ts, COUNT); + let messages = EJSON.fromJSONValue(data?.messages); + messages = orderBy(messages, 'ts'); + if (messages?.length) { + const lastMessage = messages[messages.length - 1]; + const lastMessageRecord = await getMessageById(lastMessage._id); + if (!lastMessageRecord && messages.length === COUNT) { + const loadMoreItem = { + _id: generateLoadMoreId(lastMessage._id), + rid: lastMessage.rid, + tmid: args.tmid, + ts: moment(lastMessage.ts).add(1, 'millisecond'), + t: MESSAGE_TYPE_LOAD_NEXT_CHUNK + }; + messages.push(loadMoreItem); + } + await updateMessages({ rid: args.rid, update: messages, loaderItem: args.loaderItem }); + return resolve(messages); + } else { + return resolve([]); + } + } catch (e) { + log(e); + reject(e); + } + }); +} diff --git a/app/lib/methods/loadSurroundingMessages.js b/app/lib/methods/loadSurroundingMessages.js new file mode 100644 index 000000000..74c345c2f --- /dev/null +++ b/app/lib/methods/loadSurroundingMessages.js @@ -0,0 +1,65 @@ +import EJSON from 'ejson'; +import moment from 'moment'; +import orderBy from 'lodash/orderBy'; + +import log from '../../utils/log'; +import updateMessages from './updateMessages'; +import { getMessageById } from '../database/services/Message'; +import { MESSAGE_TYPE_LOAD_NEXT_CHUNK, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK } from '../../constants/messageTypeLoad'; +import { generateLoadMoreId } from '../utils'; + +const COUNT = 50; + +export default function loadSurroundingMessages({ messageId, rid }) { + return new Promise(async(resolve, reject) => { + try { + const data = await this.methodCallWrapper('loadSurroundingMessages', { _id: messageId, rid }, COUNT); + let messages = EJSON.fromJSONValue(data?.messages); + messages = orderBy(messages, 'ts'); + + const message = messages.find(m => m._id === messageId); + const { tmid } = message; + + if (messages?.length) { + if (data?.moreBefore) { + const firstMessage = messages[0]; + const firstMessageRecord = await getMessageById(firstMessage._id); + if (!firstMessageRecord) { + const loadMoreItem = { + _id: generateLoadMoreId(firstMessage._id), + rid: firstMessage.rid, + tmid, + ts: moment(firstMessage.ts).subtract(1, 'millisecond'), + t: MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK, + msg: firstMessage.msg + }; + messages.unshift(loadMoreItem); + } + } + + if (data?.moreAfter) { + const lastMessage = messages[messages.length - 1]; + const lastMessageRecord = await getMessageById(lastMessage._id); + if (!lastMessageRecord) { + const loadMoreItem = { + _id: generateLoadMoreId(lastMessage._id), + rid: lastMessage.rid, + tmid, + ts: moment(lastMessage.ts).add(1, 'millisecond'), + t: MESSAGE_TYPE_LOAD_NEXT_CHUNK, + msg: lastMessage.msg + }; + messages.push(loadMoreItem); + } + } + await updateMessages({ rid, update: messages }); + return resolve(messages); + } else { + return resolve([]); + } + } catch (e) { + log(e); + reject(e); + } + }); +} diff --git a/app/lib/methods/loadThreadMessages.js b/app/lib/methods/loadThreadMessages.js index de6f244c6..d170635e8 100644 --- a/app/lib/methods/loadThreadMessages.js +++ b/app/lib/methods/loadThreadMessages.js @@ -1,5 +1,6 @@ import { Q } from '@nozbe/watermelondb'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import EJSON from 'ejson'; import buildMessage from './helpers/buildMessage'; import database from '../database'; @@ -7,30 +8,27 @@ import log from '../../utils/log'; import protectedFunction from './helpers/protectedFunction'; import { Encryption } from '../encryption'; -async function load({ tmid, offset }) { +async function load({ tmid }) { try { // RC 1.0 - const result = await this.sdk.get('chat.getThreadMessages', { - tmid, count: 50, offset, sort: { ts: -1 }, query: { _hidden: { $ne: true } } - }); - if (!result || !result.success) { + const result = await this.methodCallWrapper('getThreadMessages', { tmid }); + if (!result) { return []; } - return result.messages; + return EJSON.fromJSONValue(result); } catch (error) { console.log(error); return []; } } -export default function loadThreadMessages({ tmid, rid, offset = 0 }) { +export default function loadThreadMessages({ tmid, rid }) { return new Promise(async(resolve, reject) => { try { - let data = await load.call(this, { tmid, offset }); - + let data = await load.call(this, { tmid }); if (data && data.length) { try { - data = data.map(m => buildMessage(m)); + data = data.filter(m => m.tmid).map(m => buildMessage(m)); data = await Encryption.decryptMessages(data); const db = database.active; const threadMessagesCollection = db.get('thread_messages'); diff --git a/app/lib/methods/updateMessages.js b/app/lib/methods/updateMessages.js index 5f0db0f66..0b6b6c7c0 100644 --- a/app/lib/methods/updateMessages.js +++ b/app/lib/methods/updateMessages.js @@ -6,8 +6,12 @@ import log from '../../utils/log'; import database from '../database'; import protectedFunction from './helpers/protectedFunction'; import { Encryption } from '../encryption'; +import { MESSAGE_TYPE_ANY_LOAD } from '../../constants/messageTypeLoad'; +import { generateLoadMoreId } from '../utils'; -export default function updateMessages({ rid, update = [], remove = [] }) { +export default function updateMessages({ + rid, update = [], remove = [], loaderItem +}) { try { if (!((update && update.length) || (remove && remove.length))) { return; @@ -30,7 +34,13 @@ export default function updateMessages({ rid, update = [], remove = [] }) { const threadCollection = db.get('threads'); const threadMessagesCollection = db.get('thread_messages'); const allMessagesRecords = await msgCollection - .query(Q.where('rid', rid), Q.where('id', Q.oneOf(messagesIds))) + .query( + Q.where('rid', rid), + Q.or( + Q.where('id', Q.oneOf(messagesIds)), + Q.where('t', Q.oneOf(MESSAGE_TYPE_ANY_LOAD)) + ) + ) .fetch(); const allThreadsRecords = await threadCollection .query(Q.where('rid', rid), Q.where('id', Q.oneOf(messagesIds))) @@ -55,6 +65,9 @@ export default function updateMessages({ rid, update = [], remove = [] }) { let threadMessagesToCreate = allThreadMessages.filter(i1 => !allThreadMessagesRecords.find(i2 => i1._id === i2.id)); let threadMessagesToUpdate = allThreadMessagesRecords.filter(i1 => allThreadMessages.find(i2 => i1.id === i2._id)); + // filter loaders to delete + let loadersToDelete = allMessagesRecords.filter(i1 => update.find(i2 => i1.id === generateLoadMoreId(i2._id))); + // Create msgsToCreate = msgsToCreate.map(message => msgCollection.prepareCreate(protectedFunction((m) => { m._raw = sanitizedRaw({ id: message._id }, msgCollection.schema); @@ -121,6 +134,12 @@ export default function updateMessages({ rid, update = [], remove = [] }) { threadMessagesToDelete = threadMessagesToDelete.map(tm => tm.prepareDestroyPermanently()); } + // Delete loaders + loadersToDelete = loadersToDelete.map(m => m.prepareDestroyPermanently()); + if (loaderItem) { + loadersToDelete.push(loaderItem.prepareDestroyPermanently()); + } + const allRecords = [ ...msgsToCreate, ...msgsToUpdate, @@ -130,7 +149,8 @@ export default function updateMessages({ rid, update = [], remove = [] }) { ...threadsToDelete, ...threadMessagesToCreate, ...threadMessagesToUpdate, - ...threadMessagesToDelete + ...threadMessagesToDelete, + ...loadersToDelete ]; try { diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 3891d88cb..561e02c73 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -1,4 +1,5 @@ import { InteractionManager } from 'react-native'; +import EJSON from 'ejson'; import { Rocketchat as RocketchatClient, settings as RocketChatSettings @@ -41,6 +42,8 @@ import canOpenRoom from './methods/canOpenRoom'; import triggerBlockAction, { triggerSubmitView, triggerCancel } from './methods/actions'; import loadMessagesForRoom from './methods/loadMessagesForRoom'; +import loadSurroundingMessages from './methods/loadSurroundingMessages'; +import loadNextMessages from './methods/loadNextMessages'; import loadMissedMessages from './methods/loadMissedMessages'; import loadThreadMessages from './methods/loadThreadMessages'; @@ -624,6 +627,8 @@ const RocketChat = { }, loadMissedMessages, loadMessagesForRoom, + loadSurroundingMessages, + loadNextMessages, loadThreadMessages, sendMessage, getRooms, @@ -938,7 +943,7 @@ const RocketChat = { methodCallWrapper(method, ...params) { const { API_Use_REST_For_DDP_Calls } = reduxStore.getState().settings; if (API_Use_REST_For_DDP_Calls) { - return this.post(`method.call/${ method }`, { message: JSON.stringify({ method, params }) }); + return this.post(`method.call/${ method }`, { message: EJSON.stringify({ method, params }) }); } return this.methodCall(method, ...params); }, diff --git a/app/lib/utils.js b/app/lib/utils.js index 769fd6d76..615b353ae 100644 --- a/app/lib/utils.js +++ b/app/lib/utils.js @@ -20,3 +20,5 @@ export const methods = { }; export const compareServerVersion = (currentServerVersion, versionToCompare, func) => currentServerVersion && func(coerce(currentServerVersion), versionToCompare); + +export const generateLoadMoreId = id => `load-more-${ id }`; diff --git a/app/notifications/push/index.js b/app/notifications/push/index.js index df4ac152d..13e929164 100644 --- a/app/notifications/push/index.js +++ b/app/notifications/push/index.js @@ -10,7 +10,7 @@ export const onNotification = (notification) => { if (data) { try { const { - rid, name, sender, type, host, messageType + rid, name, sender, type, host, messageType, messageId } = EJSON.parse(data.ejson); const types = { @@ -24,6 +24,7 @@ export const onNotification = (notification) => { const params = { host, rid, + messageId, path: `${ types[type] }/${ roomName }`, isCall: messageType === 'jitsi_call_started' }; diff --git a/app/sagas/deepLinking.js b/app/sagas/deepLinking.js index 184a4d96e..985a28556 100644 --- a/app/sagas/deepLinking.js +++ b/app/sagas/deepLinking.js @@ -60,18 +60,19 @@ const navigate = function* navigate({ params }) { const isMasterDetail = yield select(state => state.app.isMasterDetail); const focusedRooms = yield select(state => state.room.rooms); + const jumpToMessageId = params.messageId; if (focusedRooms.includes(room.rid)) { // if there's one room on the list or last room is the one if (focusedRooms.length === 1 || focusedRooms[0] === room.rid) { - yield goRoom({ item, isMasterDetail }); + yield goRoom({ item, isMasterDetail, jumpToMessageId }); } else { popToRoot({ isMasterDetail }); - yield goRoom({ item, isMasterDetail }); + yield goRoom({ item, isMasterDetail, jumpToMessageId }); } } else { popToRoot({ isMasterDetail }); - yield goRoom({ item, isMasterDetail }); + yield goRoom({ item, isMasterDetail, jumpToMessageId }); } if (params.isCall) { diff --git a/app/utils/goRoom.js b/app/utils/goRoom.js index 94adfde49..e9811e651 100644 --- a/app/utils/goRoom.js +++ b/app/utils/goRoom.js @@ -14,7 +14,6 @@ const navigate = ({ item, isMasterDetail, ...props }) => { t: item.t, prid: item.prid, room: item, - search: item.search, visitor: item.visitor, roomUserId: RocketChat.getUidDirectMessage(item), ...props diff --git a/app/views/MessagesView/index.js b/app/views/MessagesView/index.js index fc840b2d6..f6ea91942 100644 --- a/app/views/MessagesView/index.js +++ b/app/views/MessagesView/index.js @@ -16,6 +16,7 @@ import { withTheme } from '../../theme'; import { getUserSelector } from '../../selectors/login'; import { withActionSheet } from '../../containers/ActionSheet'; import SafeAreaView from '../../containers/SafeAreaView'; +import getThreadName from '../../lib/methods/getThreadName'; class MessagesView extends React.Component { static propTypes = { @@ -26,7 +27,8 @@ class MessagesView extends React.Component { customEmojis: PropTypes.object, theme: PropTypes.string, showActionSheet: PropTypes.func, - useRealName: PropTypes.bool + useRealName: PropTypes.bool, + isMasterDetail: PropTypes.bool } constructor(props) { @@ -81,6 +83,32 @@ class MessagesView extends React.Component { navigation.navigate('RoomInfoView', navParam); } + jumpToMessage = async({ item }) => { + const { navigation, isMasterDetail } = this.props; + let params = { + rid: this.rid, + jumpToMessageId: item._id, + t: this.t, + room: this.room + }; + if (item.tmid) { + if (isMasterDetail) { + navigation.navigate('DrawerNavigator'); + } else { + navigation.pop(2); + } + params = { + ...params, + tmid: item.tmid, + name: await getThreadName(this.rid, item.tmid, item._id), + t: 'thread' + }; + navigation.push('RoomView', params); + } else { + navigation.navigate('RoomView', params); + } + } + defineMessagesViewContent = (name) => { const { user, baseUrl, theme, useRealName @@ -93,11 +121,13 @@ class MessagesView extends React.Component { timeFormat: 'MMM Do YYYY, h:mm:ss a', isEdited: !!item.editedAt, isHeader: true, + isThreadRoom: true, attachments: item.attachments || [], useRealName, showAttachment: this.showAttachment, getCustomEmoji: this.getCustomEmoji, - navToRoomInfo: this.navToRoomInfo + navToRoomInfo: this.navToRoomInfo, + onPress: () => this.jumpToMessage({ item }) }); return ({ @@ -315,7 +345,8 @@ const mapStateToProps = state => ({ baseUrl: state.server.server, user: getUserSelector(state), customEmojis: state.customEmojis, - useRealName: state.settings.UI_Use_Real_Name + useRealName: state.settings.UI_Use_Real_Name, + isMasterDetail: state.app.isMasterDetail }); export default connect(mapStateToProps)(withTheme(withActionSheet(MessagesView))); diff --git a/app/views/RoomActionsView/index.js b/app/views/RoomActionsView/index.js index 72ec2548b..3256dd0b9 100644 --- a/app/views/RoomActionsView/index.js +++ b/app/views/RoomActionsView/index.js @@ -636,7 +636,7 @@ class RoomActionsView extends React.Component { room, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue } = this.state; const { - rid, t, encrypted + rid, t } = room; const isGroupChat = RocketChat.isGroupChat(room); @@ -761,24 +761,6 @@ class RoomActionsView extends React.Component { ) : null} - {['c', 'p', 'd'].includes(t) - ? ( - <> - this.onPressTouchable({ - route: 'SearchMessagesView', - params: { rid, encrypted } - })} - testID='room-actions-search' - left={() => } - showActionIndicator - /> - - - ) - : null} - {['c', 'p', 'd'].includes(t) ? ( <> diff --git a/app/views/RoomView/List/List.js b/app/views/RoomView/List/List.js new file mode 100644 index 000000000..407dcbf10 --- /dev/null +++ b/app/views/RoomView/List/List.js @@ -0,0 +1,42 @@ +import React from 'react'; +import { FlatList, StyleSheet } from 'react-native'; +import Animated from 'react-native-reanimated'; +import PropTypes from 'prop-types'; + +import { isIOS } from '../../../utils/deviceInfo'; +import scrollPersistTaps from '../../../utils/scrollPersistTaps'; + +const AnimatedFlatList = Animated.createAnimatedComponent(FlatList); + +const styles = StyleSheet.create({ + list: { + flex: 1 + }, + contentContainer: { + paddingTop: 10 + } +}); + +const List = ({ listRef, ...props }) => ( + item.id} + contentContainerStyle={styles.contentContainer} + style={styles.list} + inverted + removeClippedSubviews={isIOS} + initialNumToRender={7} + onEndReachedThreshold={0.5} + maxToRenderPerBatch={5} + windowSize={10} + {...props} + {...scrollPersistTaps} + /> +); + +List.propTypes = { + listRef: PropTypes.object +}; + +export default List; diff --git a/app/views/RoomView/List/NavBottomFAB.js b/app/views/RoomView/List/NavBottomFAB.js new file mode 100644 index 000000000..5c5aee746 --- /dev/null +++ b/app/views/RoomView/List/NavBottomFAB.js @@ -0,0 +1,75 @@ +import React, { useCallback, useState } from 'react'; +import { View, StyleSheet } from 'react-native'; +import PropTypes from 'prop-types'; +import Animated, { + call, cond, greaterOrEq, useCode +} from 'react-native-reanimated'; + +import { themes } from '../../../constants/colors'; +import { CustomIcon } from '../../../lib/Icons'; +import { useTheme } from '../../../theme'; +import Touch from '../../../utils/touch'; +import { hasNotch } from '../../../utils/deviceInfo'; + +const SCROLL_LIMIT = 200; +const SEND_TO_CHANNEL_HEIGHT = 40; + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + right: 15 + }, + button: { + borderRadius: 25 + }, + content: { + width: 50, + height: 50, + borderRadius: 25, + borderWidth: 1, + alignItems: 'center', + justifyContent: 'center' + } +}); + +const NavBottomFAB = ({ y, onPress, isThread }) => { + const { theme } = useTheme(); + const [show, setShow] = useState(false); + const handleOnPress = useCallback(() => onPress()); + const toggle = v => setShow(v); + + useCode(() => cond(greaterOrEq(y, SCROLL_LIMIT), + call([y], () => toggle(true)), + call([y], () => toggle(false))), + [y]); + + if (!show) { + return null; + } + + let bottom = hasNotch ? 100 : 60; + if (isThread) { + bottom += SEND_TO_CHANNEL_HEIGHT; + } + return ( + + + + + + + + ); +}; + +NavBottomFAB.propTypes = { + y: Animated.Value, + onPress: PropTypes.func, + isThread: PropTypes.bool +}; + +export default NavBottomFAB; diff --git a/app/views/RoomView/List.js b/app/views/RoomView/List/index.js similarity index 59% rename from app/views/RoomView/List.js rename to app/views/RoomView/List/index.js index 41d424fa3..19a8ccb90 100644 --- a/app/views/RoomView/List.js +++ b/app/views/RoomView/List/index.js @@ -1,30 +1,39 @@ import React from 'react'; -import { FlatList, RefreshControl } from 'react-native'; +import { RefreshControl } from 'react-native'; import PropTypes from 'prop-types'; import { Q } from '@nozbe/watermelondb'; import moment from 'moment'; import { dequal } from 'dequal'; +import { Value, event } from 'react-native-reanimated'; -import styles from './styles'; -import database from '../../lib/database'; -import scrollPersistTaps from '../../utils/scrollPersistTaps'; -import RocketChat from '../../lib/rocketchat'; -import log from '../../utils/log'; -import EmptyRoom from './EmptyRoom'; -import { isIOS } from '../../utils/deviceInfo'; -import { animateNextTransition } from '../../utils/layoutAnimation'; -import ActivityIndicator from '../../containers/ActivityIndicator'; -import { themes } from '../../constants/colors'; +import database from '../../../lib/database'; +import RocketChat from '../../../lib/rocketchat'; +import log from '../../../utils/log'; +import EmptyRoom from '../EmptyRoom'; +import { animateNextTransition } from '../../../utils/layoutAnimation'; +import ActivityIndicator from '../../../containers/ActivityIndicator'; +import { themes } from '../../../constants/colors'; +import List from './List'; +import NavBottomFAB from './NavBottomFAB'; +import debounce from '../../../utils/debounce'; const QUERY_SIZE = 50; -class List extends React.Component { +const onScroll = ({ y }) => event( + [ + { + nativeEvent: { + contentOffset: { y } + } + } + ], + { useNativeDriver: true } +); + +class ListContainer extends React.Component { static propTypes = { - onEndReached: PropTypes.func, - renderFooter: PropTypes.func, renderRow: PropTypes.func, rid: PropTypes.string, - t: PropTypes.string, tmid: PropTypes.string, theme: PropTypes.string, loading: PropTypes.bool, @@ -36,34 +45,28 @@ class List extends React.Component { showMessageInMainThread: PropTypes.bool }; - // this.state.loading works for this.onEndReached and RoomView.init - static getDerivedStateFromProps(props, state) { - if (props.loading !== state.loading) { - return { - loading: props.loading - }; - } - return null; - } - constructor(props) { super(props); console.time(`${ this.constructor.name } init`); console.time(`${ this.constructor.name } mount`); this.count = 0; - this.needsFetch = false; this.mounted = false; this.animated = false; + this.jumping = false; this.state = { - loading: true, - end: false, messages: [], - refreshing: false + refreshing: false, + highlightedMessage: null }; + this.y = new Value(0); + this.onScroll = onScroll({ y: this.y }); this.query(); this.unsubscribeFocus = props.navigation.addListener('focus', () => { this.animated = true; }); + this.viewabilityConfig = { + itemVisiblePercentThreshold: 10 + }; console.timeEnd(`${ this.constructor.name } init`); } @@ -73,17 +76,17 @@ class List extends React.Component { } shouldComponentUpdate(nextProps, nextState) { - const { loading, end, refreshing } = this.state; + const { refreshing, highlightedMessage } = this.state; const { - hideSystemMessages, theme, tunread, ignored + hideSystemMessages, theme, tunread, ignored, loading } = this.props; if (theme !== nextProps.theme) { return true; } - if (loading !== nextState.loading) { + if (loading !== nextProps.loading) { return true; } - if (end !== nextState.end) { + if (highlightedMessage !== nextState.highlightedMessage) { return true; } if (refreshing !== nextState.refreshing) { @@ -116,32 +119,14 @@ class List extends React.Component { if (this.unsubscribeFocus) { this.unsubscribeFocus(); } + this.clearHighlightedMessageTimeout(); console.countReset(`${ this.constructor.name }.render calls`); } - fetchData = async() => { - const { - loading, end, messages, latest = messages[messages.length - 1]?.ts - } = this.state; - if (loading || end) { - return; - } - - this.setState({ loading: true }); - const { rid, t, tmid } = this.props; - try { - let result; - if (tmid) { - // `offset` is `messages.length - 1` because we append thread start to `messages` obj - result = await RocketChat.loadThreadMessages({ tmid, rid, offset: messages.length - 1 }); - } else { - result = await RocketChat.loadMessagesForRoom({ rid, t, latest }); - } - - this.setState({ end: result.length < QUERY_SIZE, loading: false, latest: result[result.length - 1]?.ts }, () => this.loadMoreMessages(result)); - } catch (e) { - this.setState({ loading: false }); - log(e); + clearHighlightedMessageTimeout = () => { + if (this.highlightedMessageTimeout) { + clearTimeout(this.highlightedMessageTimeout); + this.highlightedMessageTimeout = false; } } @@ -198,9 +183,6 @@ class List extends React.Component { this.unsubscribeMessages(); this.messagesSubscription = this.messagesObservable .subscribe((messages) => { - if (messages.length <= this.count) { - this.needsFetch = true; - } if (tmid && this.thread) { messages = [...messages, this.thread]; } @@ -211,6 +193,7 @@ class List extends React.Component { } else { this.state.messages = messages; } + // TODO: move it away from here this.readThreads(); }); } @@ -221,7 +204,7 @@ class List extends React.Component { this.query(); } - readThreads = async() => { + readThreads = debounce(async() => { const { tmid } = this.props; if (tmid) { @@ -231,39 +214,9 @@ class List extends React.Component { // Do nothing } } - } + }, 300) - onEndReached = async() => { - if (this.needsFetch) { - this.needsFetch = false; - await this.fetchData(); - } - this.query(); - } - - loadMoreMessages = (result) => { - const { end } = this.state; - - if (end) { - return; - } - - // handle servers with version < 3.0.0 - let { hideSystemMessages = [] } = this.props; - if (!Array.isArray(hideSystemMessages)) { - hideSystemMessages = []; - } - - if (!hideSystemMessages.length) { - return; - } - - const hasReadableMessages = result.filter(message => !message.t || (message.t && !hideSystemMessages.includes(message.t))).length > 0; - // if this batch doesn't contain any messages that will be displayed, we'll request a new batch - if (!hasReadableMessages) { - this.onEndReached(); - } - } + onEndReached = () => this.query() onRefresh = () => this.setState({ refreshing: true }, async() => { const { messages } = this.state; @@ -272,7 +225,7 @@ class List extends React.Component { if (messages.length) { try { if (tmid) { - await RocketChat.loadThreadMessages({ tmid, rid, offset: messages.length - 1 }); + await RocketChat.loadThreadMessages({ tmid, rid }); } else { await RocketChat.loadMissedMessages({ rid, lastOpen: moment().subtract(7, 'days').toDate() }); } @@ -284,7 +237,6 @@ class List extends React.Component { this.setState({ refreshing: false }); }) - // eslint-disable-next-line react/sort-comp update = () => { if (this.animated) { animateNextTransition(); @@ -306,9 +258,53 @@ class List extends React.Component { return null; } + handleScrollToIndexFailed = (params) => { + const { listRef } = this.props; + listRef.current.getNode().scrollToIndex({ index: params.highestMeasuredFrameIndex, animated: false }); + } + + jumpToMessage = messageId => new Promise(async(resolve) => { + this.jumping = true; + const { messages } = this.state; + const { listRef } = this.props; + const index = messages.findIndex(item => item.id === messageId); + if (index > -1) { + listRef.current.getNode().scrollToIndex({ index, viewPosition: 0.5 }); + await new Promise(res => setTimeout(res, 300)); + if (!this.viewableItems.map(vi => vi.key).includes(messageId)) { + if (!this.jumping) { + return resolve(); + } + await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300); + return; + } + this.setState({ highlightedMessage: messageId }); + this.clearHighlightedMessageTimeout(); + this.highlightedMessageTimeout = setTimeout(() => { + this.setState({ highlightedMessage: null }); + }, 10000); + await setTimeout(() => resolve(), 300); + } else { + listRef.current.getNode().scrollToIndex({ index: messages.length - 1, animated: false }); + if (!this.jumping) { + return resolve(); + } + await setTimeout(() => resolve(this.jumpToMessage(messageId)), 300); + } + }); + + // this.jumping is checked in between operations to make sure we're not stuck + cancelJumpToMessage = () => { + this.jumping = false; + } + + jumpToBottom = () => { + const { listRef } = this.props; + listRef.current.getNode().scrollToOffset({ offset: -100 }); + } + renderFooter = () => { - const { loading } = this.state; - const { rid, theme } = this.props; + const { rid, theme, loading } = this.props; if (loading && rid) { return ; } @@ -316,36 +312,34 @@ class List extends React.Component { } renderItem = ({ item, index }) => { - const { messages } = this.state; + const { messages, highlightedMessage } = this.state; const { renderRow } = this.props; - return renderRow(item, messages[index + 1]); + return renderRow(item, messages[index + 1], highlightedMessage); + } + + onViewableItemsChanged = ({ viewableItems }) => { + this.viewableItems = viewableItems; } render() { console.count(`${ this.constructor.name }.render calls`); - const { rid, listRef } = this.props; + const { rid, tmid, listRef } = this.props; const { messages, refreshing } = this.state; const { theme } = this.props; return ( <> - item.id} + )} - {...scrollPersistTaps} /> + ); } } -export default List; +export default ListContainer; diff --git a/app/views/RoomView/LoadMore/LoadMore.stories.js b/app/views/RoomView/LoadMore/LoadMore.stories.js new file mode 100644 index 000000000..1f110a9cf --- /dev/null +++ b/app/views/RoomView/LoadMore/LoadMore.stories.js @@ -0,0 +1,62 @@ +/* eslint-disable import/no-extraneous-dependencies, import/no-unresolved, import/extensions, react/prop-types, react/destructuring-assignment */ +import React from 'react'; +import { ScrollView } from 'react-native'; +import { storiesOf } from '@storybook/react-native'; + +import LoadMore from './index'; +import { longText } from '../../../../storybook/utils'; +import { ThemeContext } from '../../../theme'; +import { + Message, StoryProvider, MessageDecorator +} from '../../../../storybook/stories/Message'; +import { themes } from '../../../constants/colors'; +import { MESSAGE_TYPE_LOAD_MORE, MESSAGE_TYPE_LOAD_NEXT_CHUNK, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK } from '../../../constants/messageTypeLoad'; + +const stories = storiesOf('LoadMore', module); + +// FIXME: for some reason, this promise never resolves on Storybook (it works on the app, so maybe the issue isn't on the component) +const load = () => new Promise(res => setTimeout(res, 1000)); + +stories.add('basic', () => ( + <> + + + + + +)); + +const ThemeStory = ({ theme }) => ( + + + + + + + + + + + + + + +); + +stories + .addDecorator(StoryProvider) + .addDecorator(MessageDecorator) + .add('light theme', () => ); + +stories + .addDecorator(StoryProvider) + .addDecorator(MessageDecorator) + .add('dark theme', () => ); + +stories + .addDecorator(StoryProvider) + .addDecorator(MessageDecorator) + .add('black theme', () => ); + diff --git a/app/views/RoomView/LoadMore/index.js b/app/views/RoomView/LoadMore/index.js new file mode 100644 index 000000000..04b922835 --- /dev/null +++ b/app/views/RoomView/LoadMore/index.js @@ -0,0 +1,76 @@ +import React, { useEffect, useCallback, useState } from 'react'; +import { Text, StyleSheet, ActivityIndicator } from 'react-native'; +import PropTypes from 'prop-types'; + +import { themes } from '../../../constants/colors'; +import { MESSAGE_TYPE_LOAD_NEXT_CHUNK, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK } from '../../../constants/messageTypeLoad'; +import { useTheme } from '../../../theme'; +import Touch from '../../../utils/touch'; +import sharedStyles from '../../Styles'; +import I18n from '../../../i18n'; + +const styles = StyleSheet.create({ + button: { + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center' + }, + text: { + fontSize: 16, + ...sharedStyles.textMedium + } +}); + +const LoadMore = ({ load, type, runOnRender }) => { + const { theme } = useTheme(); + const [loading, setLoading] = useState(false); + + const handleLoad = useCallback(async() => { + try { + if (loading) { + return; + } + setLoading(true); + await load(); + } finally { + setLoading(false); + } + }, [loading]); + + useEffect(() => { + if (runOnRender) { + handleLoad(); + } + }, []); + + let text = 'Load_More'; + if (type === MESSAGE_TYPE_LOAD_NEXT_CHUNK) { + text = 'Load_Newer'; + } + if (type === MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK) { + text = 'Load_Older'; + } + + return ( + + { + loading + ? + : {I18n.t(text)} + } + + ); +}; + +LoadMore.propTypes = { + load: PropTypes.func, + type: PropTypes.string, + runOnRender: PropTypes.bool +}; + +export default LoadMore; diff --git a/app/views/RoomView/RightButtons.js b/app/views/RoomView/RightButtons.js index 81b8f153b..f61488b5b 100644 --- a/app/views/RoomView/RightButtons.js +++ b/app/views/RoomView/RightButtons.js @@ -142,12 +142,12 @@ class RightButtonsContainer extends Component { goSearchView = () => { logEvent(events.ROOM_GO_SEARCH); const { - rid, navigation, isMasterDetail + rid, t, navigation, isMasterDetail } = this.props; if (isMasterDetail) { navigation.navigate('ModalStackNavigator', { screen: 'SearchMessagesView', params: { rid, showCloseModal: true } }); } else { - navigation.navigate('SearchMessagesView', { rid }); + navigation.navigate('SearchMessagesView', { rid, t }); } } diff --git a/app/views/RoomView/index.js b/app/views/RoomView/index.js index b6b700db3..18d123ec4 100644 --- a/app/views/RoomView/index.js +++ b/app/views/RoomView/index.js @@ -2,8 +2,8 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Text, View, InteractionManager } from 'react-native'; import { connect } from 'react-redux'; +import parse from 'url-parse'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import moment from 'moment'; import * as Haptics from 'expo-haptics'; import { Q } from '@nozbe/watermelondb'; @@ -17,7 +17,6 @@ import { import List from './List'; import database from '../../lib/database'; import RocketChat from '../../lib/rocketchat'; -import { Encryption } from '../../lib/encryption'; import Message from '../../containers/message'; import MessageActions from '../../containers/MessageActions'; import MessageErrorActions from '../../containers/MessageErrorActions'; @@ -35,6 +34,7 @@ import RightButtons from './RightButtons'; import StatusBar from '../../containers/StatusBar'; import Separator from './Separator'; import { themes } from '../../constants/colors'; +import { MESSAGE_TYPE_ANY_LOAD, MESSAGE_TYPE_LOAD_MORE } from '../../constants/messageTypeLoad'; import debounce from '../../utils/debounce'; import ReactionsModal from '../../containers/ReactionsModal'; import { LISTENER } from '../../containers/Toast'; @@ -64,6 +64,12 @@ import { getHeaderTitlePosition } from '../../containers/Header'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants'; import { takeInquiry } from '../../ee/omnichannel/lib'; +import Loading from '../../containers/Loading'; +import LoadMore from './LoadMore'; +import RoomServices from './services'; +import { goRoom } from '../../utils/goRoom'; +import getThreadName from '../../lib/methods/getThreadName'; +import getRoomInfo from '../../lib/methods/getRoomInfo'; const stateAttrsUpdate = [ 'joined', @@ -76,7 +82,8 @@ const stateAttrsUpdate = [ 'replying', 'reacting', 'readOnly', - 'member' + 'member', + 'showingBlockingLoader' ]; const roomAttrsUpdate = ['f', 'ro', 'blocked', 'blocker', 'archived', 'tunread', 'muted', 'ignored', 'jitsiTimeout', 'announcement', 'sysMes', 'topic', 'name', 'fname', 'roles', 'bannerClosed', 'visitor', 'joinCodeRequired']; @@ -117,11 +124,11 @@ class RoomView extends React.Component { const selectedMessage = props.route.params?.message; const name = props.route.params?.name; const fname = props.route.params?.fname; - const search = props.route.params?.search; const prid = props.route.params?.prid; const room = props.route.params?.room ?? { rid: this.rid, t: this.t, name, fname, prid }; + this.jumpToMessageId = props.route.params?.jumpToMessageId; const roomUserId = props.route.params?.roomUserId ?? RocketChat.getUidDirectMessage(room); this.state = { joined: true, @@ -133,6 +140,7 @@ class RoomView extends React.Component { selectedMessage: selectedMessage || {}, canAutoTranslate: false, loading: true, + showingBlockingLoader: false, editing: false, replying: !!selectedMessage, replyWithMention: false, @@ -151,13 +159,10 @@ class RoomView extends React.Component { this.setReadOnly(); - if (search) { - this.updateRoom(); - } - this.messagebox = React.createRef(); this.list = React.createRef(); this.joinCode = React.createRef(); + this.flatList = React.createRef(); this.mounted = false; // we don't need to subscribe to threads @@ -181,6 +186,9 @@ class RoomView extends React.Component { EventEmitter.addEventListener('connected', this.handleConnected); } } + if (this.jumpToMessageId) { + this.jumpToMessage(this.jumpToMessageId); + } if (isIOS && this.rid) { this.updateUnreadCount(); } @@ -195,7 +203,9 @@ class RoomView extends React.Component { shouldComponentUpdate(nextProps, nextState) { const { state } = this; const { roomUpdate, member } = state; - const { appState, theme, insets } = this.props; + const { + appState, theme, insets, route + } = this.props; if (theme !== nextProps.theme) { return true; } @@ -212,12 +222,19 @@ class RoomView extends React.Component { if (!dequal(nextProps.insets, insets)) { return true; } + if (!dequal(nextProps.route?.params, route?.params)) { + return true; + } return roomAttrsUpdate.some(key => !dequal(nextState.roomUpdate[key], roomUpdate[key])); } componentDidUpdate(prevProps, prevState) { const { roomUpdate } = this.state; - const { appState, insets } = this.props; + const { appState, insets, route } = this.props; + + if (route?.params?.jumpToMessageId !== prevProps.route?.params?.jumpToMessageId) { + this.jumpToMessage(route?.params?.jumpToMessageId); + } if (appState === 'foreground' && appState !== prevProps.appState && this.rid) { // Fire List.query() just to keep observables working @@ -417,34 +434,15 @@ class RoomView extends React.Component { this.setState({ readOnly }); } - updateRoom = async() => { - const db = database.active; - - try { - const subCollection = db.get('subscriptions'); - const sub = await subCollection.find(this.rid); - - const { room } = await RocketChat.getRoomInfo(this.rid); - - await db.action(async() => { - await sub.update((s) => { - Object.assign(s, room); - }); - }); - } catch { - // do nothing - } - } - init = async() => { try { this.setState({ loading: true }); const { room, joined } = this.state; if (this.tmid) { - await this.getThreadMessages(); + await RoomServices.getThreadMessages(this.tmid, this.rid); } else { const newLastOpen = new Date(); - await this.getMessages(room); + await RoomServices.getMessages(room); // if room is joined if (joined) { @@ -453,7 +451,7 @@ class RoomView extends React.Component { } else { this.setLastOpen(null); } - RocketChat.readMessages(room.rid, newLastOpen, true).catch(e => console.log(e)); + RoomServices.readMessages(room.rid, newLastOpen, true).catch(e => console.log(e)); } } @@ -660,26 +658,69 @@ class RoomView extends React.Component { }); }; - onThreadPress = debounce(async(item) => { - const { roomUserId } = this.state; - const { navigation } = this.props; - if (item.tmid) { - if (!item.tmsg) { - await this.fetchThreadName(item.tmid, item.id); - } - let name = item.tmsg; - if (item.t === E2E_MESSAGE_TYPE && item.e2e !== E2E_STATUS.DONE) { - name = I18n.t('Encrypted_message'); - } - navigation.push('RoomView', { - rid: item.subscription.id, tmid: item.tmid, name, t: 'thread', roomUserId - }); - } else if (item.tlm) { - navigation.push('RoomView', { - rid: item.subscription.id, tmid: item.id, name: makeThreadName(item), t: 'thread', roomUserId - }); + onThreadPress = debounce(item => this.navToThread(item), 1000, true) + + shouldNavigateToRoom = (message) => { + if (message.tmid && message.tmid === this.tmid) { + return false; } - }, 1000, true) + if (!message.tmid && message.rid === this.rid) { + return false; + } + return true; + } + + jumpToMessageByUrl = async(messageUrl) => { + if (!messageUrl) { + return; + } + try { + this.setState({ showingBlockingLoader: true }); + const parsedUrl = parse(messageUrl, true); + const messageId = parsedUrl.query.msg; + await this.jumpToMessage(messageId); + this.setState({ showingBlockingLoader: false }); + } catch (e) { + this.setState({ showingBlockingLoader: false }); + log(e); + } + } + + jumpToMessage = async(messageId) => { + try { + this.setState({ showingBlockingLoader: true }); + const message = await RoomServices.getMessageInfo(messageId); + + if (!message) { + return; + } + + if (this.shouldNavigateToRoom(message)) { + if (message.rid !== this.rid) { + this.navToRoom(message); + } else { + this.navToThread(message); + } + } else { + /** + * if it's from server, we don't have it saved locally and so we fetch surroundings + * we test if it's not from threads because we're fetching from threads currently with `getThreadMessages` + */ + if (message.fromServer && !message.tmid) { + await RocketChat.loadSurroundingMessages({ messageId, rid: this.rid }); + } + await Promise.race([ + this.list.current.jumpToMessage(message.id), + new Promise(res => setTimeout(res, 5000)) + ]); + this.list.current.cancelJumpToMessage(); + } + } catch (e) { + log(e); + } finally { + this.setState({ showingBlockingLoader: false }); + } + } replyBroadcast = (message) => { const { replyBroadcast } = this.props; @@ -718,17 +759,6 @@ class RoomView extends React.Component { }); }; - getMessages = () => { - const { room } = this.state; - if (room.lastOpen) { - return RocketChat.loadMissedMessages(room); - } else { - return RocketChat.loadMessagesForRoom(room); - } - } - - getThreadMessages = () => RocketChat.loadThreadMessages({ tmid: this.tmid, rid: this.rid }) - getCustomEmoji = (name) => { const { customEmojis } = this.props; const emoji = customEmojis[name]; @@ -767,45 +797,7 @@ class RoomView extends React.Component { } } - // eslint-disable-next-line react/sort-comp - fetchThreadName = async(tmid, messageId) => { - try { - const db = database.active; - const threadCollection = db.get('threads'); - const messageCollection = db.get('messages'); - const messageRecord = await messageCollection.find(messageId); - let threadRecord; - try { - threadRecord = await threadCollection.find(tmid); - } catch (error) { - console.log('Thread not found. We have to search for it.'); - } - if (threadRecord) { - await db.action(async() => { - await messageRecord.update((m) => { - m.tmsg = threadRecord.msg || (threadRecord.attachments && threadRecord.attachments.length && threadRecord.attachments[0].title); - }); - }); - } else { - let { message: thread } = await RocketChat.getSingleMessage(tmid); - thread = await Encryption.decryptMessage(thread); - await db.action(async() => { - await db.batch( - threadCollection.prepareCreate((t) => { - t._raw = sanitizedRaw({ id: thread._id }, threadCollection.schema); - t.subscription.id = this.rid; - Object.assign(t, thread); - }), - messageRecord.prepareUpdate((m) => { - m.tmsg = thread.msg || (thread.attachments && thread.attachments.length && thread.attachments[0].title); - }) - ); - }); - } - } catch (e) { - // log(e); - } - } + getThreadName = (tmid, messageId) => getThreadName(this.rid, tmid, messageId) toggleFollowThread = async(isFollowingThread, tmid) => { try { @@ -836,6 +828,38 @@ class RoomView extends React.Component { } } + navToThread = async(item) => { + const { roomUserId } = this.state; + const { navigation } = this.props; + + if (item.tmid) { + let name = item.tmsg; + if (!name) { + name = await this.getThreadName(item.tmid, item.id); + } + if (item.t === E2E_MESSAGE_TYPE && item.e2e !== E2E_STATUS.DONE) { + name = I18n.t('Encrypted_message'); + } + return navigation.push('RoomView', { + rid: this.rid, tmid: item.tmid, name, t: 'thread', roomUserId, jumpToMessageId: item.id + }); + } + + if (item.tlm) { + return navigation.push('RoomView', { + rid: this.rid, tmid: item.id, name: makeThreadName(item), t: 'thread', roomUserId + }); + } + } + + navToRoom = async(message) => { + const { navigation, isMasterDetail } = this.props; + const roomInfo = await getRoomInfo(message.rid); + return goRoom({ + item: roomInfo, isMasterDetail, navigationMethod: navigation.push, jumpToMessageId: message.id + }); + } + callJitsi = () => { const { room } = this.state; const { jitsiTimeout } = room; @@ -900,7 +924,11 @@ class RoomView extends React.Component { return room?.ignored?.includes?.(message?.u?._id) ?? false; } - renderItem = (item, previousItem) => { + onLoadMoreMessages = loaderItem => RoomServices.getMoreMessages({ + rid: this.rid, tmid: this.tmid, t: this.t, loaderItem + }) + + renderItem = (item, previousItem, highlightedMessage) => { const { room, lastOpen, canAutoTranslate } = this.state; const { user, Message_GroupingPeriod, Message_TimeFormat, useRealName, baseUrl, Message_Read_Receipt_Enabled, theme @@ -920,48 +948,55 @@ class RoomView extends React.Component { } } - const message = ( - - ); + let content = null; + if (MESSAGE_TYPE_ANY_LOAD.includes(item.t)) { + content = this.onLoadMoreMessages(item)} type={item.t} runOnRender={item.t === MESSAGE_TYPE_LOAD_MORE && !previousItem} />; + } else { + content = ( + + ); + } if (showUnreadSeparator || dateSeparator) { return ( <> - {message} + {content} { @@ -1057,12 +1092,10 @@ class RoomView extends React.Component { ); } - setListRef = ref => this.flatList = ref; - render() { console.count(`${ this.constructor.name }.render calls`); const { - room, reactionsModalVisible, selectedMessage, loading, reacting + room, reactionsModalVisible, selectedMessage, loading, reacting, showingBlockingLoader } = this.state; const { user, baseUrl, theme, navigation, Hide_System_Messages, width, height @@ -1087,7 +1120,7 @@ class RoomView extends React.Component { /> + ); } diff --git a/app/views/RoomView/services/getMessageInfo.js b/app/views/RoomView/services/getMessageInfo.js new file mode 100644 index 000000000..f7f008c46 --- /dev/null +++ b/app/views/RoomView/services/getMessageInfo.js @@ -0,0 +1,41 @@ +import { getMessageById } from '../../../lib/database/services/Message'; +import { getThreadMessageById } from '../../../lib/database/services/ThreadMessage'; +import getSingleMessage from '../../../lib/methods/getSingleMessage'; + +const getMessageInfo = async(messageId) => { + let result; + result = await getMessageById(messageId); + if (result) { + return { + id: result.id, + rid: result.subscription.id, + tmid: result.tmid, + msg: result.msg + }; + } + + result = await getThreadMessageById(messageId); + if (result) { + return { + id: result.id, + rid: result.subscription.id, + tmid: result.rid, + msg: result.msg + }; + } + + result = await getSingleMessage(messageId); + if (result) { + return { + id: result._id, + rid: result.rid, + tmid: result.tmid, + msg: result.msg, + fromServer: true + }; + } + + return null; +}; + +export default getMessageInfo; diff --git a/app/views/RoomView/services/getMessages.js b/app/views/RoomView/services/getMessages.js new file mode 100644 index 000000000..7e9c03de0 --- /dev/null +++ b/app/views/RoomView/services/getMessages.js @@ -0,0 +1,10 @@ +import RocketChat from '../../../lib/rocketchat'; + +const getMessages = (room) => { + if (room.lastOpen) { + return RocketChat.loadMissedMessages(room); + } else { + return RocketChat.loadMessagesForRoom(room); + } +}; +export default getMessages; diff --git a/app/views/RoomView/services/getMoreMessages.js b/app/views/RoomView/services/getMoreMessages.js new file mode 100644 index 000000000..6d16f69c2 --- /dev/null +++ b/app/views/RoomView/services/getMoreMessages.js @@ -0,0 +1,19 @@ +import { MESSAGE_TYPE_LOAD_MORE, MESSAGE_TYPE_LOAD_NEXT_CHUNK, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK } from '../../../constants/messageTypeLoad'; +import RocketChat from '../../../lib/rocketchat'; + +const getMoreMessages = ({ + rid, t, tmid, loaderItem +}) => { + if ([MESSAGE_TYPE_LOAD_MORE, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK].includes(loaderItem.t)) { + return RocketChat.loadMessagesForRoom({ + rid, t, latest: loaderItem.ts, loaderItem + }); + } + + if (loaderItem.t === MESSAGE_TYPE_LOAD_NEXT_CHUNK) { + return RocketChat.loadNextMessages({ + rid, tmid, ts: loaderItem.ts, loaderItem + }); + } +}; +export default getMoreMessages; diff --git a/app/views/RoomView/services/getThreadMessages.js b/app/views/RoomView/services/getThreadMessages.js new file mode 100644 index 000000000..0f9529cfc --- /dev/null +++ b/app/views/RoomView/services/getThreadMessages.js @@ -0,0 +1,6 @@ +import RocketChat from '../../../lib/rocketchat'; + +// unlike getMessages, sync isn't required for threads, because loadMissedMessages does it already +const getThreadMessages = (tmid, rid) => RocketChat.loadThreadMessages({ tmid, rid }); + +export default getThreadMessages; diff --git a/app/views/RoomView/services/index.js b/app/views/RoomView/services/index.js new file mode 100644 index 000000000..f8799cda8 --- /dev/null +++ b/app/views/RoomView/services/index.js @@ -0,0 +1,13 @@ +import getMessages from './getMessages'; +import getMoreMessages from './getMoreMessages'; +import getThreadMessages from './getThreadMessages'; +import readMessages from './readMessages'; +import getMessageInfo from './getMessageInfo'; + +export default { + getMessages, + getMoreMessages, + getThreadMessages, + readMessages, + getMessageInfo +}; diff --git a/app/views/RoomView/services/readMessages.js b/app/views/RoomView/services/readMessages.js new file mode 100644 index 000000000..060d9aa7e --- /dev/null +++ b/app/views/RoomView/services/readMessages.js @@ -0,0 +1,5 @@ +import RocketChat from '../../../lib/rocketchat'; + +const readMessages = (rid, newLastOpen) => RocketChat.readMessages(rid, newLastOpen, true); + +export default readMessages; diff --git a/app/views/RoomView/styles.js b/app/views/RoomView/styles.js index fdbb61a7b..4f84d8f7a 100644 --- a/app/views/RoomView/styles.js +++ b/app/views/RoomView/styles.js @@ -9,12 +9,6 @@ export default StyleSheet.create({ safeAreaView: { flex: 1 }, - list: { - flex: 1 - }, - contentContainer: { - paddingTop: 10 - }, readOnly: { justifyContent: 'flex-end', alignItems: 'center', diff --git a/app/views/SearchMessagesView/index.js b/app/views/SearchMessagesView/index.js index e11a5b83d..09c9e6c15 100644 --- a/app/views/SearchMessagesView/index.js +++ b/app/views/SearchMessagesView/index.js @@ -23,6 +23,8 @@ import SafeAreaView from '../../containers/SafeAreaView'; import * as HeaderButton from '../../containers/HeaderButton'; import database from '../../lib/database'; import { sanitizeLikeString } from '../../lib/database/utils'; +import getThreadName from '../../lib/methods/getThreadName'; +import getRoomInfo from '../../lib/methods/getRoomInfo'; class SearchMessagesView extends React.Component { static navigationOptions = ({ navigation, route }) => { @@ -54,9 +56,14 @@ class SearchMessagesView extends React.Component { searchText: '' }; this.rid = props.route.params?.rid; + this.t = props.route.params?.t; this.encrypted = props.route.params?.encrypted; } + async componentDidMount() { + this.room = await getRoomInfo(this.rid); + } + shouldComponentUpdate(nextProps, nextState) { const { loading, searchText, messages } = this.state; const { theme } = this.props; @@ -126,6 +133,11 @@ class SearchMessagesView extends React.Component { return null; } + showAttachment = (attachment) => { + const { navigation } = this.props; + navigation.navigate('AttachmentView', { attachment }); + } + navToRoomInfo = (navParam) => { const { navigation, user } = this.props; if (navParam.rid === user.id) { @@ -134,6 +146,28 @@ class SearchMessagesView extends React.Component { navigation.navigate('RoomInfoView', navParam); } + jumpToMessage = async({ item }) => { + const { navigation } = this.props; + let params = { + rid: this.rid, + jumpToMessageId: item._id, + t: this.t, + room: this.room + }; + if (item.tmid) { + navigation.pop(); + params = { + ...params, + tmid: item.tmid, + name: await getThreadName(this.rid, item.tmid, item._id), + t: 'thread' + }; + navigation.push('RoomView', params); + } else { + navigation.navigate('RoomView', params); + } + } + renderEmpty = () => { const { theme } = this.props; return ( @@ -152,13 +186,16 @@ class SearchMessagesView extends React.Component { item={item} baseUrl={baseUrl} user={user} - timeFormat='LLL' + timeFormat='MMM Do YYYY, h:mm:ss a' isHeader - showAttachment={() => {}} + isThreadRoom + showAttachment={this.showAttachment} getCustomEmoji={this.getCustomEmoji} navToRoomInfo={this.navToRoomInfo} useRealName={useRealName} theme={theme} + onPress={() => this.jumpToMessage({ item })} + jumpToMessage={() => this.jumpToMessage({ item })} /> ); } diff --git a/storybook/stories/Message.js b/storybook/stories/Message.js index 82cfa1b03..6d6d2b4f1 100644 --- a/storybook/stories/Message.js +++ b/storybook/stories/Message.js @@ -40,7 +40,7 @@ const getCustomEmoji = (content) => { return customEmoji; }; -const messageDecorator = story => ( +export const MessageDecorator = story => ( ( ); -const Message = props => ( +export const Message = props => ( ( /> ); +export const StoryProvider = story => {story()}; +const MessageScrollView = story => {story()}; const stories = storiesOf('Message', module) - .addDecorator(story => {story()}) - .addDecorator(story => {story()}) - .addDecorator(messageDecorator); + .addDecorator(StoryProvider) + .addDecorator(MessageScrollView) + .addDecorator(MessageDecorator); stories.add('Basic', () => ( <> diff --git a/storybook/stories/index.js b/storybook/stories/index.js index 20bfc4f25..b695d49d6 100644 --- a/storybook/stories/index.js +++ b/storybook/stories/index.js @@ -14,6 +14,7 @@ import '../../app/views/ThreadMessagesView/Item.stories.js'; import './Avatar'; import '../../app/containers/BackgroundContainer/index.stories.js'; import '../../app/containers/RoomHeader/RoomHeader.stories.js'; +import '../../app/views/RoomView/LoadMore/LoadMore.stories'; // Change here to see themed storybook export const theme = 'light';