[NEW] Jump to message (#3099)

* Scrolling

* Add loadMore button at the end of loadMessagesForRoom

* Delete dummy item on tap

* Only insert loadMore dummy if there's more data

* load surrounding messages

* fixes and load next

* First dummy and dummy-next

* Save load next messages

* Check if message exists before fetching surroundings

* Refactoring List

* Jumping to message :)

* Showing blocking loader while scrolling/fetching message

* Check if message exists on local db before inserting dummy

* Delete dummies automatically when the message sent to updateMessages again

* Minor cleanup

* Fix scroll

* Highlight message

* Jump to bottom

* Load more on scroll

* Adding stories to LoadMore

* Refactoring

* Add loading indicator to LoadMore

* Small refactor

* Add LoadMore to threads

* getMoreMessages

* chat.getThreadMessages -> getThreadMessages

* Start jumping to threads

* Add jumpToMessageId on RoomView

* Nav to correct channel

* Fix PK issue on thread_messages

* Disable jump to thread from another room

* Fix nav to thread params

* Add navToRoom

* Refactor styles

* Test notch

* Fix Android border

* Fix thread message on title

* Fix NavBottomFAB on threads

* Minor cleanup

* Workaround for readThreads being called too often

* Lint

* Update tests

* Jump from search

* Go to threads from search

* Remove getItemLayout and rely on viewable items

* Fix load older

* stash working

* Fix infinite loading

* Lower itemVisiblePercentThreshhold to 10, so very long messages behave as viewable

* Add generateLoadMoreId util

* Minor cleanup

* Jump to message from notification/deep linking

* Add getMessageInfo

* Nav to threads from other rooms

* getThreadName

* Unnecessary logic

* getRoomInfo

* Colocate getMessageInfo closer to RoomView

* Minor cleanup

* Remove search from RoomActionsView

* Minor fix for search on not joined public channels

* Jump to any link

* Fix tablets

* Jump to message from MessagesView and other bug fixes

* Fix issue on Urls

* Adds race condition to cancel jump to message if it's stuck or after 5 seconds

* Jump from message search quote

* lint

* Stop onPress

* Small refactor on load methods

* Minor fixes for loadThreadMessages

* Minor typo

* LoadMore i18n

* Minor cleanup
This commit is contained in:
Diego Mello 2021-05-26 14:24:54 -03:00 committed by GitHub
parent 62336c6d3a
commit 3ef4ef5317
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 4972 additions and 365 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
export const MESSAGE_TYPE_LOAD_MORE = 'load_more';
export const MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK = 'load_previous_chunk';
export const MESSAGE_TYPE_LOAD_NEXT_CHUNK = 'load_next_chunk';
export const MESSAGE_TYPE_ANY_LOAD = [MESSAGE_TYPE_LOAD_MORE, MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK, MESSAGE_TYPE_LOAD_NEXT_CHUNK];

View File

@ -4,19 +4,18 @@ import { Text, Clipboard } from 'react-native';
import styles from './styles'; import styles from './styles';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
import openLink from '../../utils/openLink';
import { LISTENER } from '../Toast'; import { LISTENER } from '../Toast';
import EventEmitter from '../../utils/events'; import EventEmitter from '../../utils/events';
import I18n from '../../i18n'; import I18n from '../../i18n';
const Link = React.memo(({ const Link = React.memo(({
children, link, theme children, link, theme, onLinkPress
}) => { }) => {
const handlePress = () => { const handlePress = () => {
if (!link) { if (!link) {
return; return;
} }
openLink(link, theme); onLinkPress(link);
}; };
const childLength = React.Children.toArray(children).filter(o => o).length; const childLength = React.Children.toArray(children).filter(o => o).length;
@ -40,7 +39,8 @@ const Link = React.memo(({
Link.propTypes = { Link.propTypes = {
children: PropTypes.node, children: PropTypes.node,
link: PropTypes.string, link: PropTypes.string,
theme: PropTypes.string theme: PropTypes.string,
onLinkPress: PropTypes.func
}; };
export default Link; export default Link;

View File

@ -82,7 +82,8 @@ class Markdown extends PureComponent {
preview: PropTypes.bool, preview: PropTypes.bool,
theme: PropTypes.string, theme: PropTypes.string,
testID: PropTypes.string, testID: PropTypes.string,
style: PropTypes.array style: PropTypes.array,
onLinkPress: PropTypes.func
}; };
constructor(props) { constructor(props) {
@ -218,11 +219,12 @@ class Markdown extends PureComponent {
}; };
renderLink = ({ children, href }) => { renderLink = ({ children, href }) => {
const { theme } = this.props; const { theme, onLinkPress } = this.props;
return ( return (
<MarkdownLink <MarkdownLink
link={href} link={href}
theme={theme} theme={theme}
onLinkPress={onLinkPress}
> >
{children} {children}
</MarkdownLink> </MarkdownLink>

View File

@ -45,7 +45,7 @@ const Content = React.memo((props) => {
} else if (props.isEncrypted) { } else if (props.isEncrypted) {
content = <Text style={[styles.textInfo, { color: themes[props.theme].auxiliaryText }]}>{I18n.t('Encrypted_message')}</Text>; content = <Text style={[styles.textInfo, { color: themes[props.theme].auxiliaryText }]}>{I18n.t('Encrypted_message')}</Text>;
} else { } else {
const { baseUrl, user } = useContext(MessageContext); const { baseUrl, user, onLinkPress } = useContext(MessageContext);
content = ( content = (
<Markdown <Markdown
msg={props.msg} msg={props.msg}
@ -61,6 +61,7 @@ const Content = React.memo((props) => {
tmid={props.tmid} tmid={props.tmid}
useRealName={props.useRealName} useRealName={props.useRealName}
theme={props.theme} theme={props.theme}
onLinkPress={onLinkPress}
/> />
); );
} }

View File

@ -19,6 +19,7 @@ import Discussion from './Discussion';
import Content from './Content'; import Content from './Content';
import ReadReceipt from './ReadReceipt'; import ReadReceipt from './ReadReceipt';
import CallButton from './CallButton'; import CallButton from './CallButton';
import { themes } from '../../constants/colors';
const MessageInner = React.memo((props) => { const MessageInner = React.memo((props) => {
if (props.type === 'discussion-created') { if (props.type === 'discussion-created') {
@ -120,6 +121,7 @@ const MessageTouchable = React.memo((props) => {
onLongPress={onLongPress} onLongPress={onLongPress}
onPress={onPress} onPress={onPress}
disabled={(props.isInfo && !props.isThreadReply) || props.archived || props.isTemp} disabled={(props.isInfo && !props.isThreadReply) || props.archived || props.isTemp}
style={{ backgroundColor: props.highlighted ? themes[props.theme].headerBackground : null }}
> >
<View> <View>
<Message {...props} /> <Message {...props} />
@ -134,7 +136,9 @@ MessageTouchable.propTypes = {
isInfo: PropTypes.bool, isInfo: PropTypes.bool,
isThreadReply: PropTypes.bool, isThreadReply: PropTypes.bool,
isTemp: PropTypes.bool, isTemp: PropTypes.bool,
archived: PropTypes.bool archived: PropTypes.bool,
highlighted: PropTypes.bool,
theme: PropTypes.string
}; };
Message.propTypes = { Message.propTypes = {

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { memo, useEffect, useState } from 'react';
import { View } from 'react-native'; import { View } from 'react-native';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -8,24 +8,29 @@ import { themes } from '../../constants/colors';
import I18n from '../../i18n'; import I18n from '../../i18n';
import Markdown from '../markdown'; import Markdown from '../markdown';
const RepliedThread = React.memo(({ const RepliedThread = memo(({
tmid, tmsg, isHeader, fetchThreadName, id, isEncrypted, theme tmid, tmsg, isHeader, fetchThreadName, id, isEncrypted, theme
}) => { }) => {
if (!tmid || !isHeader) { if (!tmid || !isHeader) {
return null; return null;
} }
if (!tmsg) { const [msg, setMsg] = useState(isEncrypted ? I18n.t('Encrypted_message') : tmsg);
fetchThreadName(tmid, id); const fetch = async() => {
const threadName = await fetchThreadName(tmid, id);
setMsg(threadName);
};
useEffect(() => {
if (!msg) {
fetch();
}
}, []);
if (!msg) {
return null; return null;
} }
let msg = tmsg;
if (isEncrypted) {
msg = I18n.t('Encrypted_message');
}
return ( return (
<View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}> <View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}>
<CustomIcon name='threads' size={20} style={styles.repliedThreadIcon} color={themes[theme].tintColor} /> <CustomIcon name='threads' size={20} style={styles.repliedThreadIcon} color={themes[theme].tintColor} />
@ -45,23 +50,6 @@ const RepliedThread = React.memo(({
</View> </View>
</View> </View>
); );
}, (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 = { RepliedThread.propTypes = {

View File

@ -142,10 +142,13 @@ const Reply = React.memo(({
if (!attachment) { if (!attachment) {
return null; return null;
} }
const { baseUrl, user } = useContext(MessageContext); const { baseUrl, user, jumpToMessage } = useContext(MessageContext);
const onPress = () => { const onPress = () => {
let url = attachment.title_link || attachment.author_link; let url = attachment.title_link || attachment.author_link;
if (attachment.message_link) {
return jumpToMessage(attachment.message_link);
}
if (!url) { if (!url) {
return; return;
} }

View File

@ -80,7 +80,7 @@ const UrlContent = React.memo(({ title, description, theme }) => (
}); });
const Url = React.memo(({ url, index, theme }) => { const Url = React.memo(({ url, index, theme }) => {
if (!url) { if (!url || url?.ignoreParse) {
return null; return null;
} }

View File

@ -9,6 +9,7 @@ import { SYSTEM_MESSAGES, getMessageTranslation } from './utils';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
import messagesStatus from '../../constants/messagesStatus'; import messagesStatus from '../../constants/messagesStatus';
import { withTheme } from '../../theme'; import { withTheme } from '../../theme';
import openLink from '../../utils/openLink';
class MessageContainer extends React.Component { class MessageContainer extends React.Component {
static propTypes = { static propTypes = {
@ -33,6 +34,7 @@ class MessageContainer extends React.Component {
autoTranslateLanguage: PropTypes.string, autoTranslateLanguage: PropTypes.string,
status: PropTypes.number, status: PropTypes.number,
isIgnored: PropTypes.bool, isIgnored: PropTypes.bool,
highlighted: PropTypes.bool,
getCustomEmoji: PropTypes.func, getCustomEmoji: PropTypes.func,
onLongPress: PropTypes.func, onLongPress: PropTypes.func,
onReactionPress: PropTypes.func, onReactionPress: PropTypes.func,
@ -50,7 +52,9 @@ class MessageContainer extends React.Component {
blockAction: PropTypes.func, blockAction: PropTypes.func,
theme: PropTypes.string, theme: PropTypes.string,
threadBadgeColor: PropTypes.string, threadBadgeColor: PropTypes.string,
toggleFollowThread: PropTypes.func toggleFollowThread: PropTypes.func,
jumpToMessage: PropTypes.func,
onPress: PropTypes.func
} }
static defaultProps = { static defaultProps = {
@ -89,10 +93,15 @@ class MessageContainer extends React.Component {
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const { isManualUnignored } = this.state; const { isManualUnignored } = this.state;
const { theme, threadBadgeColor, isIgnored } = this.props; const {
theme, threadBadgeColor, isIgnored, highlighted
} = this.props;
if (nextProps.theme !== theme) { if (nextProps.theme !== theme) {
return true; return true;
} }
if (nextProps.highlighted !== highlighted) {
return true;
}
if (nextProps.threadBadgeColor !== threadBadgeColor) { if (nextProps.threadBadgeColor !== threadBadgeColor) {
return true; return true;
} }
@ -112,10 +121,15 @@ class MessageContainer extends React.Component {
} }
onPress = debounce(() => { onPress = debounce(() => {
const { onPress } = this.props;
if (this.isIgnored) { if (this.isIgnored) {
return this.onIgnoredMessagePress(); return this.onIgnoredMessagePress();
} }
if (onPress) {
return onPress();
}
const { item, isThreadRoom } = this.props; const { item, isThreadRoom } = this.props;
Keyboard.dismiss(); 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() { render() {
const { 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; } = this.props;
const { 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; } = item;
let message = msg; let message = msg;
@ -294,6 +365,8 @@ class MessageContainer extends React.Component {
onEncryptedPress: this.onEncryptedPress, onEncryptedPress: this.onEncryptedPress,
onDiscussionPress: this.onDiscussionPress, onDiscussionPress: this.onDiscussionPress,
onReactionLongPress: this.onReactionLongPress, onReactionLongPress: this.onReactionLongPress,
onLinkPress: this.onLinkPress,
jumpToMessage,
threadBadgeColor, threadBadgeColor,
toggleFollowThread, toggleFollowThread,
replies replies
@ -347,6 +420,7 @@ class MessageContainer extends React.Component {
callJitsi={callJitsi} callJitsi={callJitsi}
blockAction={blockAction} blockAction={blockAction}
theme={theme} theme={theme}
highlighted={highlighted}
/> />
</MessageContext.Provider> </MessageContext.Provider>
); );

View File

@ -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_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", "last-owner-can-not-be-removed": "Last owner cannot be removed",
"leaving_team": "leaving team", "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"
} }

View File

@ -5,8 +5,10 @@ import {
import { sanitizer } from '../utils'; import { sanitizer } from '../utils';
export const TABLE_NAME = 'messages';
export default class Message extends Model { export default class Message extends Model {
static table = 'messages'; static table = TABLE_NAME;
static associations = { static associations = {
subscriptions: { type: 'belongs_to', key: 'rid' } subscriptions: { type: 'belongs_to', key: 'rid' }

View File

@ -4,8 +4,10 @@ import {
} from '@nozbe/watermelondb/decorators'; } from '@nozbe/watermelondb/decorators';
import { sanitizer } from '../utils'; import { sanitizer } from '../utils';
export const TABLE_NAME = 'subscriptions';
export default class Subscription extends Model { export default class Subscription extends Model {
static table = 'subscriptions'; static table = TABLE_NAME;
static associations = { static associations = {
messages: { type: 'has_many', foreignKey: 'rid' }, messages: { type: 'has_many', foreignKey: 'rid' },

View File

@ -5,8 +5,10 @@ import {
import { sanitizer } from '../utils'; import { sanitizer } from '../utils';
export const TABLE_NAME = 'threads';
export default class Thread extends Model { export default class Thread extends Model {
static table = 'threads'; static table = TABLE_NAME;
static associations = { static associations = {
subscriptions: { type: 'belongs_to', key: 'rid' } subscriptions: { type: 'belongs_to', key: 'rid' }

View File

@ -5,8 +5,10 @@ import {
import { sanitizer } from '../utils'; import { sanitizer } from '../utils';
export const TABLE_NAME = 'thread_messages';
export default class ThreadMessage extends Model { export default class ThreadMessage extends Model {
static table = 'thread_messages'; static table = TABLE_NAME;
static associations = { static associations = {
subscriptions: { type: 'belongs_to', key: 'subscription_id' } subscriptions: { type: 'belongs_to', key: 'subscription_id' }

View File

@ -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;
}
};

View File

@ -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;
}
};

View File

@ -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;
}
};

View File

@ -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;
}
};

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -1,8 +1,15 @@
import moment from 'moment';
import { MESSAGE_TYPE_LOAD_MORE } from '../../constants/messageTypeLoad';
import log from '../../utils/log'; import log from '../../utils/log';
import { getMessageById } from '../database/services/Message';
import updateMessages from './updateMessages'; import updateMessages from './updateMessages';
import { generateLoadMoreId } from '../utils';
const COUNT = 50;
async function load({ rid: roomId, latest, t }) { async function load({ rid: roomId, latest, t }) {
let params = { roomId, count: 50 }; let params = { roomId, count: COUNT };
if (latest) { if (latest) {
params = { ...params, latest: new Date(latest).toISOString() }; params = { ...params, latest: new Date(latest).toISOString() };
} }
@ -24,9 +31,20 @@ export default function loadMessagesForRoom(args) {
return new Promise(async(resolve, reject) => { return new Promise(async(resolve, reject) => {
try { try {
const data = await load.call(this, args); const data = await load.call(this, args);
if (data?.length) {
if (data && data.length) { const lastMessage = data[data.length - 1];
await updateMessages({ rid: args.rid, update: data }); 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); return resolve(data);
} else { } else {
return resolve([]); return resolve([]);

View File

@ -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);
}
});
}

View File

@ -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);
}
});
}

View File

@ -1,5 +1,6 @@
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import EJSON from 'ejson';
import buildMessage from './helpers/buildMessage'; import buildMessage from './helpers/buildMessage';
import database from '../database'; import database from '../database';
@ -7,30 +8,27 @@ import log from '../../utils/log';
import protectedFunction from './helpers/protectedFunction'; import protectedFunction from './helpers/protectedFunction';
import { Encryption } from '../encryption'; import { Encryption } from '../encryption';
async function load({ tmid, offset }) { async function load({ tmid }) {
try { try {
// RC 1.0 // RC 1.0
const result = await this.sdk.get('chat.getThreadMessages', { const result = await this.methodCallWrapper('getThreadMessages', { tmid });
tmid, count: 50, offset, sort: { ts: -1 }, query: { _hidden: { $ne: true } } if (!result) {
});
if (!result || !result.success) {
return []; return [];
} }
return result.messages; return EJSON.fromJSONValue(result);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
return []; return [];
} }
} }
export default function loadThreadMessages({ tmid, rid, offset = 0 }) { export default function loadThreadMessages({ tmid, rid }) {
return new Promise(async(resolve, reject) => { return new Promise(async(resolve, reject) => {
try { try {
let data = await load.call(this, { tmid, offset }); let data = await load.call(this, { tmid });
if (data && data.length) { if (data && data.length) {
try { try {
data = data.map(m => buildMessage(m)); data = data.filter(m => m.tmid).map(m => buildMessage(m));
data = await Encryption.decryptMessages(data); data = await Encryption.decryptMessages(data);
const db = database.active; const db = database.active;
const threadMessagesCollection = db.get('thread_messages'); const threadMessagesCollection = db.get('thread_messages');

View File

@ -6,8 +6,12 @@ import log from '../../utils/log';
import database from '../database'; import database from '../database';
import protectedFunction from './helpers/protectedFunction'; import protectedFunction from './helpers/protectedFunction';
import { Encryption } from '../encryption'; 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 { try {
if (!((update && update.length) || (remove && remove.length))) { if (!((update && update.length) || (remove && remove.length))) {
return; return;
@ -30,7 +34,13 @@ export default function updateMessages({ rid, update = [], remove = [] }) {
const threadCollection = db.get('threads'); const threadCollection = db.get('threads');
const threadMessagesCollection = db.get('thread_messages'); const threadMessagesCollection = db.get('thread_messages');
const allMessagesRecords = await msgCollection 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(); .fetch();
const allThreadsRecords = await threadCollection const allThreadsRecords = await threadCollection
.query(Q.where('rid', rid), Q.where('id', Q.oneOf(messagesIds))) .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 threadMessagesToCreate = allThreadMessages.filter(i1 => !allThreadMessagesRecords.find(i2 => i1._id === i2.id));
let threadMessagesToUpdate = allThreadMessagesRecords.filter(i1 => allThreadMessages.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 // Create
msgsToCreate = msgsToCreate.map(message => msgCollection.prepareCreate(protectedFunction((m) => { msgsToCreate = msgsToCreate.map(message => msgCollection.prepareCreate(protectedFunction((m) => {
m._raw = sanitizedRaw({ id: message._id }, msgCollection.schema); 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()); threadMessagesToDelete = threadMessagesToDelete.map(tm => tm.prepareDestroyPermanently());
} }
// Delete loaders
loadersToDelete = loadersToDelete.map(m => m.prepareDestroyPermanently());
if (loaderItem) {
loadersToDelete.push(loaderItem.prepareDestroyPermanently());
}
const allRecords = [ const allRecords = [
...msgsToCreate, ...msgsToCreate,
...msgsToUpdate, ...msgsToUpdate,
@ -130,7 +149,8 @@ export default function updateMessages({ rid, update = [], remove = [] }) {
...threadsToDelete, ...threadsToDelete,
...threadMessagesToCreate, ...threadMessagesToCreate,
...threadMessagesToUpdate, ...threadMessagesToUpdate,
...threadMessagesToDelete ...threadMessagesToDelete,
...loadersToDelete
]; ];
try { try {

View File

@ -1,4 +1,5 @@
import { InteractionManager } from 'react-native'; import { InteractionManager } from 'react-native';
import EJSON from 'ejson';
import { import {
Rocketchat as RocketchatClient, Rocketchat as RocketchatClient,
settings as RocketChatSettings settings as RocketChatSettings
@ -41,6 +42,8 @@ import canOpenRoom from './methods/canOpenRoom';
import triggerBlockAction, { triggerSubmitView, triggerCancel } from './methods/actions'; import triggerBlockAction, { triggerSubmitView, triggerCancel } from './methods/actions';
import loadMessagesForRoom from './methods/loadMessagesForRoom'; import loadMessagesForRoom from './methods/loadMessagesForRoom';
import loadSurroundingMessages from './methods/loadSurroundingMessages';
import loadNextMessages from './methods/loadNextMessages';
import loadMissedMessages from './methods/loadMissedMessages'; import loadMissedMessages from './methods/loadMissedMessages';
import loadThreadMessages from './methods/loadThreadMessages'; import loadThreadMessages from './methods/loadThreadMessages';
@ -624,6 +627,8 @@ const RocketChat = {
}, },
loadMissedMessages, loadMissedMessages,
loadMessagesForRoom, loadMessagesForRoom,
loadSurroundingMessages,
loadNextMessages,
loadThreadMessages, loadThreadMessages,
sendMessage, sendMessage,
getRooms, getRooms,
@ -938,7 +943,7 @@ const RocketChat = {
methodCallWrapper(method, ...params) { methodCallWrapper(method, ...params) {
const { API_Use_REST_For_DDP_Calls } = reduxStore.getState().settings; const { API_Use_REST_For_DDP_Calls } = reduxStore.getState().settings;
if (API_Use_REST_For_DDP_Calls) { 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); return this.methodCall(method, ...params);
}, },

View File

@ -20,3 +20,5 @@ export const methods = {
}; };
export const compareServerVersion = (currentServerVersion, versionToCompare, func) => currentServerVersion && func(coerce(currentServerVersion), versionToCompare); export const compareServerVersion = (currentServerVersion, versionToCompare, func) => currentServerVersion && func(coerce(currentServerVersion), versionToCompare);
export const generateLoadMoreId = id => `load-more-${ id }`;

View File

@ -10,7 +10,7 @@ export const onNotification = (notification) => {
if (data) { if (data) {
try { try {
const { const {
rid, name, sender, type, host, messageType rid, name, sender, type, host, messageType, messageId
} = EJSON.parse(data.ejson); } = EJSON.parse(data.ejson);
const types = { const types = {
@ -24,6 +24,7 @@ export const onNotification = (notification) => {
const params = { const params = {
host, host,
rid, rid,
messageId,
path: `${ types[type] }/${ roomName }`, path: `${ types[type] }/${ roomName }`,
isCall: messageType === 'jitsi_call_started' isCall: messageType === 'jitsi_call_started'
}; };

View File

@ -60,18 +60,19 @@ const navigate = function* navigate({ params }) {
const isMasterDetail = yield select(state => state.app.isMasterDetail); const isMasterDetail = yield select(state => state.app.isMasterDetail);
const focusedRooms = yield select(state => state.room.rooms); const focusedRooms = yield select(state => state.room.rooms);
const jumpToMessageId = params.messageId;
if (focusedRooms.includes(room.rid)) { if (focusedRooms.includes(room.rid)) {
// if there's one room on the list or last room is the one // if there's one room on the list or last room is the one
if (focusedRooms.length === 1 || focusedRooms[0] === room.rid) { if (focusedRooms.length === 1 || focusedRooms[0] === room.rid) {
yield goRoom({ item, isMasterDetail }); yield goRoom({ item, isMasterDetail, jumpToMessageId });
} else { } else {
popToRoot({ isMasterDetail }); popToRoot({ isMasterDetail });
yield goRoom({ item, isMasterDetail }); yield goRoom({ item, isMasterDetail, jumpToMessageId });
} }
} else { } else {
popToRoot({ isMasterDetail }); popToRoot({ isMasterDetail });
yield goRoom({ item, isMasterDetail }); yield goRoom({ item, isMasterDetail, jumpToMessageId });
} }
if (params.isCall) { if (params.isCall) {

View File

@ -14,7 +14,6 @@ const navigate = ({ item, isMasterDetail, ...props }) => {
t: item.t, t: item.t,
prid: item.prid, prid: item.prid,
room: item, room: item,
search: item.search,
visitor: item.visitor, visitor: item.visitor,
roomUserId: RocketChat.getUidDirectMessage(item), roomUserId: RocketChat.getUidDirectMessage(item),
...props ...props

View File

@ -16,6 +16,7 @@ import { withTheme } from '../../theme';
import { getUserSelector } from '../../selectors/login'; import { getUserSelector } from '../../selectors/login';
import { withActionSheet } from '../../containers/ActionSheet'; import { withActionSheet } from '../../containers/ActionSheet';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import getThreadName from '../../lib/methods/getThreadName';
class MessagesView extends React.Component { class MessagesView extends React.Component {
static propTypes = { static propTypes = {
@ -26,7 +27,8 @@ class MessagesView extends React.Component {
customEmojis: PropTypes.object, customEmojis: PropTypes.object,
theme: PropTypes.string, theme: PropTypes.string,
showActionSheet: PropTypes.func, showActionSheet: PropTypes.func,
useRealName: PropTypes.bool useRealName: PropTypes.bool,
isMasterDetail: PropTypes.bool
} }
constructor(props) { constructor(props) {
@ -81,6 +83,32 @@ class MessagesView extends React.Component {
navigation.navigate('RoomInfoView', navParam); 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) => { defineMessagesViewContent = (name) => {
const { const {
user, baseUrl, theme, useRealName user, baseUrl, theme, useRealName
@ -93,11 +121,13 @@ class MessagesView extends React.Component {
timeFormat: 'MMM Do YYYY, h:mm:ss a', timeFormat: 'MMM Do YYYY, h:mm:ss a',
isEdited: !!item.editedAt, isEdited: !!item.editedAt,
isHeader: true, isHeader: true,
isThreadRoom: true,
attachments: item.attachments || [], attachments: item.attachments || [],
useRealName, useRealName,
showAttachment: this.showAttachment, showAttachment: this.showAttachment,
getCustomEmoji: this.getCustomEmoji, getCustomEmoji: this.getCustomEmoji,
navToRoomInfo: this.navToRoomInfo navToRoomInfo: this.navToRoomInfo,
onPress: () => this.jumpToMessage({ item })
}); });
return ({ return ({
@ -315,7 +345,8 @@ const mapStateToProps = state => ({
baseUrl: state.server.server, baseUrl: state.server.server,
user: getUserSelector(state), user: getUserSelector(state),
customEmojis: state.customEmojis, 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))); export default connect(mapStateToProps)(withTheme(withActionSheet(MessagesView)));

View File

@ -636,7 +636,7 @@ class RoomActionsView extends React.Component {
room, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue room, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue
} = this.state; } = this.state;
const { const {
rid, t, encrypted rid, t
} = room; } = room;
const isGroupChat = RocketChat.isGroupChat(room); const isGroupChat = RocketChat.isGroupChat(room);
@ -761,24 +761,6 @@ class RoomActionsView extends React.Component {
) )
: null} : null}
{['c', 'p', 'd'].includes(t)
? (
<>
<List.Item
title='Search'
onPress={() => this.onPressTouchable({
route: 'SearchMessagesView',
params: { rid, encrypted }
})}
testID='room-actions-search'
left={() => <List.Icon name='search' />}
showActionIndicator
/>
<List.Separator />
</>
)
: null}
{['c', 'p', 'd'].includes(t) {['c', 'p', 'd'].includes(t)
? ( ? (
<> <>

View File

@ -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 }) => (
<AnimatedFlatList
testID='room-view-messages'
ref={listRef}
keyExtractor={item => 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;

View File

@ -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 (
<Animated.View style={[styles.container, { bottom }]}>
<Touch
onPress={handleOnPress}
theme={theme}
style={[styles.button, { backgroundColor: themes[theme].backgroundColor }]}
>
<View style={[styles.content, { borderColor: themes[theme].borderColor }]}>
<CustomIcon name='chevron-down' color={themes[theme].auxiliaryTintColor} size={36} />
</View>
</Touch>
</Animated.View>
);
};
NavBottomFAB.propTypes = {
y: Animated.Value,
onPress: PropTypes.func,
isThread: PropTypes.bool
};
export default NavBottomFAB;

View File

@ -1,30 +1,39 @@
import React from 'react'; import React from 'react';
import { FlatList, RefreshControl } from 'react-native'; import { RefreshControl } from 'react-native';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
import moment from 'moment'; import moment from 'moment';
import { dequal } from 'dequal'; import { dequal } from 'dequal';
import { Value, event } from 'react-native-reanimated';
import styles from './styles'; import database from '../../../lib/database';
import database from '../../lib/database'; import RocketChat from '../../../lib/rocketchat';
import scrollPersistTaps from '../../utils/scrollPersistTaps'; import log from '../../../utils/log';
import RocketChat from '../../lib/rocketchat'; import EmptyRoom from '../EmptyRoom';
import log from '../../utils/log'; import { animateNextTransition } from '../../../utils/layoutAnimation';
import EmptyRoom from './EmptyRoom'; import ActivityIndicator from '../../../containers/ActivityIndicator';
import { isIOS } from '../../utils/deviceInfo'; import { themes } from '../../../constants/colors';
import { animateNextTransition } from '../../utils/layoutAnimation'; import List from './List';
import ActivityIndicator from '../../containers/ActivityIndicator'; import NavBottomFAB from './NavBottomFAB';
import { themes } from '../../constants/colors'; import debounce from '../../../utils/debounce';
const QUERY_SIZE = 50; 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 = { static propTypes = {
onEndReached: PropTypes.func,
renderFooter: PropTypes.func,
renderRow: PropTypes.func, renderRow: PropTypes.func,
rid: PropTypes.string, rid: PropTypes.string,
t: PropTypes.string,
tmid: PropTypes.string, tmid: PropTypes.string,
theme: PropTypes.string, theme: PropTypes.string,
loading: PropTypes.bool, loading: PropTypes.bool,
@ -36,34 +45,28 @@ class List extends React.Component {
showMessageInMainThread: PropTypes.bool 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) { constructor(props) {
super(props); super(props);
console.time(`${ this.constructor.name } init`); console.time(`${ this.constructor.name } init`);
console.time(`${ this.constructor.name } mount`); console.time(`${ this.constructor.name } mount`);
this.count = 0; this.count = 0;
this.needsFetch = false;
this.mounted = false; this.mounted = false;
this.animated = false; this.animated = false;
this.jumping = false;
this.state = { this.state = {
loading: true,
end: false,
messages: [], messages: [],
refreshing: false refreshing: false,
highlightedMessage: null
}; };
this.y = new Value(0);
this.onScroll = onScroll({ y: this.y });
this.query(); this.query();
this.unsubscribeFocus = props.navigation.addListener('focus', () => { this.unsubscribeFocus = props.navigation.addListener('focus', () => {
this.animated = true; this.animated = true;
}); });
this.viewabilityConfig = {
itemVisiblePercentThreshold: 10
};
console.timeEnd(`${ this.constructor.name } init`); console.timeEnd(`${ this.constructor.name } init`);
} }
@ -73,17 +76,17 @@ class List extends React.Component {
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const { loading, end, refreshing } = this.state; const { refreshing, highlightedMessage } = this.state;
const { const {
hideSystemMessages, theme, tunread, ignored hideSystemMessages, theme, tunread, ignored, loading
} = this.props; } = this.props;
if (theme !== nextProps.theme) { if (theme !== nextProps.theme) {
return true; return true;
} }
if (loading !== nextState.loading) { if (loading !== nextProps.loading) {
return true; return true;
} }
if (end !== nextState.end) { if (highlightedMessage !== nextState.highlightedMessage) {
return true; return true;
} }
if (refreshing !== nextState.refreshing) { if (refreshing !== nextState.refreshing) {
@ -116,32 +119,14 @@ class List extends React.Component {
if (this.unsubscribeFocus) { if (this.unsubscribeFocus) {
this.unsubscribeFocus(); this.unsubscribeFocus();
} }
this.clearHighlightedMessageTimeout();
console.countReset(`${ this.constructor.name }.render calls`); console.countReset(`${ this.constructor.name }.render calls`);
} }
fetchData = async() => { clearHighlightedMessageTimeout = () => {
const { if (this.highlightedMessageTimeout) {
loading, end, messages, latest = messages[messages.length - 1]?.ts clearTimeout(this.highlightedMessageTimeout);
} = this.state; this.highlightedMessageTimeout = false;
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);
} }
} }
@ -198,9 +183,6 @@ class List extends React.Component {
this.unsubscribeMessages(); this.unsubscribeMessages();
this.messagesSubscription = this.messagesObservable this.messagesSubscription = this.messagesObservable
.subscribe((messages) => { .subscribe((messages) => {
if (messages.length <= this.count) {
this.needsFetch = true;
}
if (tmid && this.thread) { if (tmid && this.thread) {
messages = [...messages, this.thread]; messages = [...messages, this.thread];
} }
@ -211,6 +193,7 @@ class List extends React.Component {
} else { } else {
this.state.messages = messages; this.state.messages = messages;
} }
// TODO: move it away from here
this.readThreads(); this.readThreads();
}); });
} }
@ -221,7 +204,7 @@ class List extends React.Component {
this.query(); this.query();
} }
readThreads = async() => { readThreads = debounce(async() => {
const { tmid } = this.props; const { tmid } = this.props;
if (tmid) { if (tmid) {
@ -231,39 +214,9 @@ class List extends React.Component {
// Do nothing // Do nothing
} }
} }
} }, 300)
onEndReached = async() => { onEndReached = () => this.query()
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();
}
}
onRefresh = () => this.setState({ refreshing: true }, async() => { onRefresh = () => this.setState({ refreshing: true }, async() => {
const { messages } = this.state; const { messages } = this.state;
@ -272,7 +225,7 @@ class List extends React.Component {
if (messages.length) { if (messages.length) {
try { try {
if (tmid) { if (tmid) {
await RocketChat.loadThreadMessages({ tmid, rid, offset: messages.length - 1 }); await RocketChat.loadThreadMessages({ tmid, rid });
} else { } else {
await RocketChat.loadMissedMessages({ rid, lastOpen: moment().subtract(7, 'days').toDate() }); await RocketChat.loadMissedMessages({ rid, lastOpen: moment().subtract(7, 'days').toDate() });
} }
@ -284,7 +237,6 @@ class List extends React.Component {
this.setState({ refreshing: false }); this.setState({ refreshing: false });
}) })
// eslint-disable-next-line react/sort-comp
update = () => { update = () => {
if (this.animated) { if (this.animated) {
animateNextTransition(); animateNextTransition();
@ -306,9 +258,53 @@ class List extends React.Component {
return null; 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 = () => { renderFooter = () => {
const { loading } = this.state; const { rid, theme, loading } = this.props;
const { rid, theme } = this.props;
if (loading && rid) { if (loading && rid) {
return <ActivityIndicator theme={theme} />; return <ActivityIndicator theme={theme} />;
} }
@ -316,36 +312,34 @@ class List extends React.Component {
} }
renderItem = ({ item, index }) => { renderItem = ({ item, index }) => {
const { messages } = this.state; const { messages, highlightedMessage } = this.state;
const { renderRow } = this.props; const { renderRow } = this.props;
return renderRow(item, messages[index + 1]); return renderRow(item, messages[index + 1], highlightedMessage);
}
onViewableItemsChanged = ({ viewableItems }) => {
this.viewableItems = viewableItems;
} }
render() { render() {
console.count(`${ this.constructor.name }.render calls`); console.count(`${ this.constructor.name }.render calls`);
const { rid, listRef } = this.props; const { rid, tmid, listRef } = this.props;
const { messages, refreshing } = this.state; const { messages, refreshing } = this.state;
const { theme } = this.props; const { theme } = this.props;
return ( return (
<> <>
<EmptyRoom rid={rid} length={messages.length} mounted={this.mounted} theme={theme} /> <EmptyRoom rid={rid} length={messages.length} mounted={this.mounted} theme={theme} />
<FlatList <List
testID='room-view-messages' onScroll={this.onScroll}
ref={listRef} scrollEventThrottle={16}
keyExtractor={item => item.id} listRef={listRef}
data={messages} data={messages}
extraData={this.state}
renderItem={this.renderItem} renderItem={this.renderItem}
contentContainerStyle={styles.contentContainer}
style={styles.list}
inverted
removeClippedSubviews={isIOS}
initialNumToRender={7}
onEndReached={this.onEndReached} onEndReached={this.onEndReached}
onEndReachedThreshold={0.5}
maxToRenderPerBatch={5}
windowSize={10}
ListFooterComponent={this.renderFooter} ListFooterComponent={this.renderFooter}
onScrollToIndexFailed={this.handleScrollToIndexFailed}
onViewableItemsChanged={this.onViewableItemsChanged}
viewabilityConfig={this.viewabilityConfig}
refreshControl={( refreshControl={(
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}
@ -353,11 +347,11 @@ class List extends React.Component {
tintColor={themes[theme].auxiliaryText} tintColor={themes[theme].auxiliaryText}
/> />
)} )}
{...scrollPersistTaps}
/> />
<NavBottomFAB y={this.y} onPress={this.jumpToBottom} isThread={!!tmid} />
</> </>
); );
} }
} }
export default List; export default ListContainer;

View File

@ -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', () => (
<>
<LoadMore load={load} />
<LoadMore load={load} runOnRender />
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK} />
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_NEXT_CHUNK} />
</>
));
const ThemeStory = ({ theme }) => (
<ThemeContext.Provider
value={{ theme }}
>
<ScrollView style={{ backgroundColor: themes[theme].backgroundColor }}>
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_PREVIOUS_CHUNK} />
<Message msg='Hey!' theme={theme} />
<Message msg={longText} theme={theme} isHeader={false} />
<Message msg='Older message' theme={theme} isHeader={false} />
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_NEXT_CHUNK} />
<LoadMore load={load} type={MESSAGE_TYPE_LOAD_MORE} />
<Message msg={longText} theme={theme} />
<Message msg='This is the third message' isHeader={false} theme={theme} />
<Message msg='This is the second message' isHeader={false} theme={theme} />
<Message msg='This is the first message' theme={theme} />
</ScrollView>
</ThemeContext.Provider>
);
stories
.addDecorator(StoryProvider)
.addDecorator(MessageDecorator)
.add('light theme', () => <ThemeStory theme='light' />);
stories
.addDecorator(StoryProvider)
.addDecorator(MessageDecorator)
.add('dark theme', () => <ThemeStory theme='dark' />);
stories
.addDecorator(StoryProvider)
.addDecorator(MessageDecorator)
.add('black theme', () => <ThemeStory theme='black' />);

View File

@ -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 (
<Touch
onPress={handleLoad}
style={styles.button}
theme={theme}
enabled={!loading}
>
{
loading
? <ActivityIndicator color={themes[theme].auxiliaryText} />
: <Text style={[styles.text, { color: themes[theme].titleText }]}>{I18n.t(text)}</Text>
}
</Touch>
);
};
LoadMore.propTypes = {
load: PropTypes.func,
type: PropTypes.string,
runOnRender: PropTypes.bool
};
export default LoadMore;

View File

@ -142,12 +142,12 @@ class RightButtonsContainer extends Component {
goSearchView = () => { goSearchView = () => {
logEvent(events.ROOM_GO_SEARCH); logEvent(events.ROOM_GO_SEARCH);
const { const {
rid, navigation, isMasterDetail rid, t, navigation, isMasterDetail
} = this.props; } = this.props;
if (isMasterDetail) { if (isMasterDetail) {
navigation.navigate('ModalStackNavigator', { screen: 'SearchMessagesView', params: { rid, showCloseModal: true } }); navigation.navigate('ModalStackNavigator', { screen: 'SearchMessagesView', params: { rid, showCloseModal: true } });
} else { } else {
navigation.navigate('SearchMessagesView', { rid }); navigation.navigate('SearchMessagesView', { rid, t });
} }
} }

View File

@ -2,8 +2,8 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Text, View, InteractionManager } from 'react-native'; import { Text, View, InteractionManager } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import parse from 'url-parse';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import moment from 'moment'; import moment from 'moment';
import * as Haptics from 'expo-haptics'; import * as Haptics from 'expo-haptics';
import { Q } from '@nozbe/watermelondb'; import { Q } from '@nozbe/watermelondb';
@ -17,7 +17,6 @@ import {
import List from './List'; import List from './List';
import database from '../../lib/database'; import database from '../../lib/database';
import RocketChat from '../../lib/rocketchat'; import RocketChat from '../../lib/rocketchat';
import { Encryption } from '../../lib/encryption';
import Message from '../../containers/message'; import Message from '../../containers/message';
import MessageActions from '../../containers/MessageActions'; import MessageActions from '../../containers/MessageActions';
import MessageErrorActions from '../../containers/MessageErrorActions'; import MessageErrorActions from '../../containers/MessageErrorActions';
@ -35,6 +34,7 @@ import RightButtons from './RightButtons';
import StatusBar from '../../containers/StatusBar'; import StatusBar from '../../containers/StatusBar';
import Separator from './Separator'; import Separator from './Separator';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
import { MESSAGE_TYPE_ANY_LOAD, MESSAGE_TYPE_LOAD_MORE } from '../../constants/messageTypeLoad';
import debounce from '../../utils/debounce'; import debounce from '../../utils/debounce';
import ReactionsModal from '../../containers/ReactionsModal'; import ReactionsModal from '../../containers/ReactionsModal';
import { LISTENER } from '../../containers/Toast'; 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 { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
import { takeInquiry } from '../../ee/omnichannel/lib'; 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 = [ const stateAttrsUpdate = [
'joined', 'joined',
@ -76,7 +82,8 @@ const stateAttrsUpdate = [
'replying', 'replying',
'reacting', 'reacting',
'readOnly', 'readOnly',
'member' 'member',
'showingBlockingLoader'
]; ];
const roomAttrsUpdate = ['f', 'ro', 'blocked', 'blocker', 'archived', 'tunread', 'muted', 'ignored', 'jitsiTimeout', 'announcement', 'sysMes', 'topic', 'name', 'fname', 'roles', 'bannerClosed', 'visitor', 'joinCodeRequired']; 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 selectedMessage = props.route.params?.message;
const name = props.route.params?.name; const name = props.route.params?.name;
const fname = props.route.params?.fname; const fname = props.route.params?.fname;
const search = props.route.params?.search;
const prid = props.route.params?.prid; const prid = props.route.params?.prid;
const room = props.route.params?.room ?? { const room = props.route.params?.room ?? {
rid: this.rid, t: this.t, name, fname, prid rid: this.rid, t: this.t, name, fname, prid
}; };
this.jumpToMessageId = props.route.params?.jumpToMessageId;
const roomUserId = props.route.params?.roomUserId ?? RocketChat.getUidDirectMessage(room); const roomUserId = props.route.params?.roomUserId ?? RocketChat.getUidDirectMessage(room);
this.state = { this.state = {
joined: true, joined: true,
@ -133,6 +140,7 @@ class RoomView extends React.Component {
selectedMessage: selectedMessage || {}, selectedMessage: selectedMessage || {},
canAutoTranslate: false, canAutoTranslate: false,
loading: true, loading: true,
showingBlockingLoader: false,
editing: false, editing: false,
replying: !!selectedMessage, replying: !!selectedMessage,
replyWithMention: false, replyWithMention: false,
@ -151,13 +159,10 @@ class RoomView extends React.Component {
this.setReadOnly(); this.setReadOnly();
if (search) {
this.updateRoom();
}
this.messagebox = React.createRef(); this.messagebox = React.createRef();
this.list = React.createRef(); this.list = React.createRef();
this.joinCode = React.createRef(); this.joinCode = React.createRef();
this.flatList = React.createRef();
this.mounted = false; this.mounted = false;
// we don't need to subscribe to threads // we don't need to subscribe to threads
@ -181,6 +186,9 @@ class RoomView extends React.Component {
EventEmitter.addEventListener('connected', this.handleConnected); EventEmitter.addEventListener('connected', this.handleConnected);
} }
} }
if (this.jumpToMessageId) {
this.jumpToMessage(this.jumpToMessageId);
}
if (isIOS && this.rid) { if (isIOS && this.rid) {
this.updateUnreadCount(); this.updateUnreadCount();
} }
@ -195,7 +203,9 @@ class RoomView extends React.Component {
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const { state } = this; const { state } = this;
const { roomUpdate, member } = state; const { roomUpdate, member } = state;
const { appState, theme, insets } = this.props; const {
appState, theme, insets, route
} = this.props;
if (theme !== nextProps.theme) { if (theme !== nextProps.theme) {
return true; return true;
} }
@ -212,12 +222,19 @@ class RoomView extends React.Component {
if (!dequal(nextProps.insets, insets)) { if (!dequal(nextProps.insets, insets)) {
return true; return true;
} }
if (!dequal(nextProps.route?.params, route?.params)) {
return true;
}
return roomAttrsUpdate.some(key => !dequal(nextState.roomUpdate[key], roomUpdate[key])); return roomAttrsUpdate.some(key => !dequal(nextState.roomUpdate[key], roomUpdate[key]));
} }
componentDidUpdate(prevProps, prevState) { componentDidUpdate(prevProps, prevState) {
const { roomUpdate } = this.state; 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) { if (appState === 'foreground' && appState !== prevProps.appState && this.rid) {
// Fire List.query() just to keep observables working // Fire List.query() just to keep observables working
@ -417,34 +434,15 @@ class RoomView extends React.Component {
this.setState({ readOnly }); 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() => { init = async() => {
try { try {
this.setState({ loading: true }); this.setState({ loading: true });
const { room, joined } = this.state; const { room, joined } = this.state;
if (this.tmid) { if (this.tmid) {
await this.getThreadMessages(); await RoomServices.getThreadMessages(this.tmid, this.rid);
} else { } else {
const newLastOpen = new Date(); const newLastOpen = new Date();
await this.getMessages(room); await RoomServices.getMessages(room);
// if room is joined // if room is joined
if (joined) { if (joined) {
@ -453,7 +451,7 @@ class RoomView extends React.Component {
} else { } else {
this.setLastOpen(null); 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) => { onThreadPress = debounce(item => this.navToThread(item), 1000, true)
const { roomUserId } = this.state;
const { navigation } = this.props; shouldNavigateToRoom = (message) => {
if (item.tmid) { if (message.tmid && message.tmid === this.tmid) {
if (!item.tmsg) { return false;
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
});
} }
}, 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) => { replyBroadcast = (message) => {
const { replyBroadcast } = this.props; 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) => { getCustomEmoji = (name) => {
const { customEmojis } = this.props; const { customEmojis } = this.props;
const emoji = customEmojis[name]; const emoji = customEmojis[name];
@ -767,45 +797,7 @@ class RoomView extends React.Component {
} }
} }
// eslint-disable-next-line react/sort-comp getThreadName = (tmid, messageId) => getThreadName(this.rid, tmid, messageId)
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);
}
}
toggleFollowThread = async(isFollowingThread, tmid) => { toggleFollowThread = async(isFollowingThread, tmid) => {
try { 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 = () => { callJitsi = () => {
const { room } = this.state; const { room } = this.state;
const { jitsiTimeout } = room; const { jitsiTimeout } = room;
@ -900,7 +924,11 @@ class RoomView extends React.Component {
return room?.ignored?.includes?.(message?.u?._id) ?? false; 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 { room, lastOpen, canAutoTranslate } = this.state;
const { const {
user, Message_GroupingPeriod, Message_TimeFormat, useRealName, baseUrl, Message_Read_Receipt_Enabled, theme 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;
<Message if (MESSAGE_TYPE_ANY_LOAD.includes(item.t)) {
item={item} content = <LoadMore load={() => this.onLoadMoreMessages(item)} type={item.t} runOnRender={item.t === MESSAGE_TYPE_LOAD_MORE && !previousItem} />;
user={user} } else {
rid={room.rid} content = (
archived={room.archived} <Message
broadcast={room.broadcast} item={item}
status={item.status} user={user}
isThreadRoom={!!this.tmid} rid={room.rid}
isIgnored={this.isIgnored(item)} archived={room.archived}
previousItem={previousItem} broadcast={room.broadcast}
fetchThreadName={this.fetchThreadName} status={item.status}
onReactionPress={this.onReactionPress} isThreadRoom={!!this.tmid}
onReactionLongPress={this.onReactionLongPress} isIgnored={this.isIgnored(item)}
onLongPress={this.onMessageLongPress} previousItem={previousItem}
onEncryptedPress={this.onEncryptedPress} fetchThreadName={this.getThreadName}
onDiscussionPress={this.onDiscussionPress} onReactionPress={this.onReactionPress}
onThreadPress={this.onThreadPress} onReactionLongPress={this.onReactionLongPress}
showAttachment={this.showAttachment} onLongPress={this.onMessageLongPress}
reactionInit={this.onReactionInit} onEncryptedPress={this.onEncryptedPress}
replyBroadcast={this.replyBroadcast} onDiscussionPress={this.onDiscussionPress}
errorActionsShow={this.errorActionsShow} onThreadPress={this.onThreadPress}
baseUrl={baseUrl} showAttachment={this.showAttachment}
Message_GroupingPeriod={Message_GroupingPeriod} reactionInit={this.onReactionInit}
timeFormat={Message_TimeFormat} replyBroadcast={this.replyBroadcast}
useRealName={useRealName} errorActionsShow={this.errorActionsShow}
isReadReceiptEnabled={Message_Read_Receipt_Enabled} baseUrl={baseUrl}
autoTranslateRoom={canAutoTranslate && room.autoTranslate} Message_GroupingPeriod={Message_GroupingPeriod}
autoTranslateLanguage={room.autoTranslateLanguage} timeFormat={Message_TimeFormat}
navToRoomInfo={this.navToRoomInfo} useRealName={useRealName}
getCustomEmoji={this.getCustomEmoji} isReadReceiptEnabled={Message_Read_Receipt_Enabled}
callJitsi={this.callJitsi} autoTranslateRoom={canAutoTranslate && room.autoTranslate}
blockAction={this.blockAction} autoTranslateLanguage={room.autoTranslateLanguage}
threadBadgeColor={this.getBadgeColor(item?.id)} navToRoomInfo={this.navToRoomInfo}
toggleFollowThread={this.toggleFollowThread} getCustomEmoji={this.getCustomEmoji}
/> callJitsi={this.callJitsi}
); blockAction={this.blockAction}
threadBadgeColor={this.getBadgeColor(item?.id)}
toggleFollowThread={this.toggleFollowThread}
jumpToMessage={this.jumpToMessageByUrl}
highlighted={highlightedMessage === item.id}
/>
);
}
if (showUnreadSeparator || dateSeparator) { if (showUnreadSeparator || dateSeparator) {
return ( return (
<> <>
{message} {content}
<Separator <Separator
ts={dateSeparator} ts={dateSeparator}
unread={showUnreadSeparator} unread={showUnreadSeparator}
@ -971,7 +1006,7 @@ class RoomView extends React.Component {
); );
} }
return message; return content;
} }
renderFooter = () => { renderFooter = () => {
@ -1057,12 +1092,10 @@ class RoomView extends React.Component {
); );
} }
setListRef = ref => this.flatList = ref;
render() { render() {
console.count(`${ this.constructor.name }.render calls`); console.count(`${ this.constructor.name }.render calls`);
const { const {
room, reactionsModalVisible, selectedMessage, loading, reacting room, reactionsModalVisible, selectedMessage, loading, reacting, showingBlockingLoader
} = this.state; } = this.state;
const { const {
user, baseUrl, theme, navigation, Hide_System_Messages, width, height user, baseUrl, theme, navigation, Hide_System_Messages, width, height
@ -1087,7 +1120,7 @@ class RoomView extends React.Component {
/> />
<List <List
ref={this.list} ref={this.list}
listRef={this.setListRef} listRef={this.flatList}
rid={rid} rid={rid}
t={t} t={t}
tmid={this.tmid} tmid={this.tmid}
@ -1127,6 +1160,7 @@ class RoomView extends React.Component {
t={t} t={t}
theme={theme} theme={theme}
/> />
<Loading visible={showingBlockingLoader} />
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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
};

View File

@ -0,0 +1,5 @@
import RocketChat from '../../../lib/rocketchat';
const readMessages = (rid, newLastOpen) => RocketChat.readMessages(rid, newLastOpen, true);
export default readMessages;

View File

@ -9,12 +9,6 @@ export default StyleSheet.create({
safeAreaView: { safeAreaView: {
flex: 1 flex: 1
}, },
list: {
flex: 1
},
contentContainer: {
paddingTop: 10
},
readOnly: { readOnly: {
justifyContent: 'flex-end', justifyContent: 'flex-end',
alignItems: 'center', alignItems: 'center',

View File

@ -23,6 +23,8 @@ import SafeAreaView from '../../containers/SafeAreaView';
import * as HeaderButton from '../../containers/HeaderButton'; import * as HeaderButton from '../../containers/HeaderButton';
import database from '../../lib/database'; import database from '../../lib/database';
import { sanitizeLikeString } from '../../lib/database/utils'; import { sanitizeLikeString } from '../../lib/database/utils';
import getThreadName from '../../lib/methods/getThreadName';
import getRoomInfo from '../../lib/methods/getRoomInfo';
class SearchMessagesView extends React.Component { class SearchMessagesView extends React.Component {
static navigationOptions = ({ navigation, route }) => { static navigationOptions = ({ navigation, route }) => {
@ -54,9 +56,14 @@ class SearchMessagesView extends React.Component {
searchText: '' searchText: ''
}; };
this.rid = props.route.params?.rid; this.rid = props.route.params?.rid;
this.t = props.route.params?.t;
this.encrypted = props.route.params?.encrypted; this.encrypted = props.route.params?.encrypted;
} }
async componentDidMount() {
this.room = await getRoomInfo(this.rid);
}
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const { loading, searchText, messages } = this.state; const { loading, searchText, messages } = this.state;
const { theme } = this.props; const { theme } = this.props;
@ -126,6 +133,11 @@ class SearchMessagesView extends React.Component {
return null; return null;
} }
showAttachment = (attachment) => {
const { navigation } = this.props;
navigation.navigate('AttachmentView', { attachment });
}
navToRoomInfo = (navParam) => { navToRoomInfo = (navParam) => {
const { navigation, user } = this.props; const { navigation, user } = this.props;
if (navParam.rid === user.id) { if (navParam.rid === user.id) {
@ -134,6 +146,28 @@ class SearchMessagesView extends React.Component {
navigation.navigate('RoomInfoView', navParam); 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 = () => { renderEmpty = () => {
const { theme } = this.props; const { theme } = this.props;
return ( return (
@ -152,13 +186,16 @@ class SearchMessagesView extends React.Component {
item={item} item={item}
baseUrl={baseUrl} baseUrl={baseUrl}
user={user} user={user}
timeFormat='LLL' timeFormat='MMM Do YYYY, h:mm:ss a'
isHeader isHeader
showAttachment={() => {}} isThreadRoom
showAttachment={this.showAttachment}
getCustomEmoji={this.getCustomEmoji} getCustomEmoji={this.getCustomEmoji}
navToRoomInfo={this.navToRoomInfo} navToRoomInfo={this.navToRoomInfo}
useRealName={useRealName} useRealName={useRealName}
theme={theme} theme={theme}
onPress={() => this.jumpToMessage({ item })}
jumpToMessage={() => this.jumpToMessage({ item })}
/> />
); );
} }

View File

@ -40,7 +40,7 @@ const getCustomEmoji = (content) => {
return customEmoji; return customEmoji;
}; };
const messageDecorator = story => ( export const MessageDecorator = story => (
<MessageContext.Provider <MessageContext.Provider
value={{ value={{
user, user,
@ -60,7 +60,7 @@ const messageDecorator = story => (
</MessageContext.Provider> </MessageContext.Provider>
); );
const Message = props => ( export const Message = props => (
<MessageComponent <MessageComponent
baseUrl={baseUrl} baseUrl={baseUrl}
user={user} user={user}
@ -74,12 +74,14 @@ const Message = props => (
/> />
); );
export const StoryProvider = story => <Provider store={store}>{story()}</Provider>;
const MessageScrollView = story => <ScrollView style={{ backgroundColor: themes[_theme].backgroundColor }}>{story()}</ScrollView>;
const stories = storiesOf('Message', module) const stories = storiesOf('Message', module)
.addDecorator(story => <Provider store={store}>{story()}</Provider>) .addDecorator(StoryProvider)
.addDecorator(story => <ScrollView style={{ backgroundColor: themes[_theme].backgroundColor }}>{story()}</ScrollView>) .addDecorator(MessageScrollView)
.addDecorator(messageDecorator); .addDecorator(MessageDecorator);
stories.add('Basic', () => ( stories.add('Basic', () => (
<> <>

View File

@ -14,6 +14,7 @@ import '../../app/views/ThreadMessagesView/Item.stories.js';
import './Avatar'; import './Avatar';
import '../../app/containers/BackgroundContainer/index.stories.js'; import '../../app/containers/BackgroundContainer/index.stories.js';
import '../../app/containers/RoomHeader/RoomHeader.stories.js'; import '../../app/containers/RoomHeader/RoomHeader.stories.js';
import '../../app/views/RoomView/LoadMore/LoadMore.stories';
// Change here to see themed storybook // Change here to see themed storybook
export const theme = 'light'; export const theme = 'light';