Tweaks on sequential threads messages layout (#858)

* Tweaks on sequential threads messages

* Update tests

* Fix quote

* Prevent from deleting thread start message when positioned inside the thread

* Remove thread listener from RightButtons

* Fix error on thread start parse

* Stop parsing threads on render

* Check replied thread only if necessary

* Fix messages don't displaying

* Fix threads e2e

* RoomsListView.updateState slice

* Stop fetching hidden messages on threads

* Set initialNumToRender to 5
This commit is contained in:
Diego Mello 2019-05-03 10:33:38 -03:00 committed by GitHub
parent 61fcadc879
commit a243b1ccd7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 2333 additions and 1996 deletions

File diff suppressed because it is too large Load Diff

View File

@ -198,6 +198,11 @@ export default class MessageActions extends React.Component {
if (this.isRoomReadOnly()) { if (this.isRoomReadOnly()) {
return false; return false;
} }
// Prevent from deleting thread start message when positioned inside the thread
if (props.tmid && props.tmid === props.actionMessage._id) {
return false;
}
const deleteOwn = this.isOwn(props); const deleteOwn = this.isOwn(props);
const { Message_AllowDeleting: isDeleteAllowed, Message_AllowDeleting_BlockDeleteInMinutes } = this.props; const { Message_AllowDeleting: isDeleteAllowed, Message_AllowDeleting_BlockDeleteInMinutes } = this.props;
if (!(this.hasDeletePermission || (isDeleteAllowed && deleteOwn) || this.hasForceDeletePermission)) { if (!(this.hasDeletePermission || (isDeleteAllowed && deleteOwn) || this.hasForceDeletePermission)) {

View File

@ -606,10 +606,10 @@ class MessageBox extends Component {
const { replyMessage, closeReply, threadsEnabled } = this.props; const { replyMessage, closeReply, threadsEnabled } = this.props;
// Thread // Thread
if (threadsEnabled) { if (threadsEnabled && replyMessage.mention) {
onSubmit(message, replyMessage._id); onSubmit(message, replyMessage._id);
// Legacy reply // Legacy reply or quote (quote is a reply without mention)
} else { } else {
const { user, roomType } = this.props; const { user, roomType } = this.props;
const permalink = await this.getPermalink(replyMessage); const permalink = await this.getPermalink(replyMessage);

View File

@ -25,6 +25,8 @@ import messagesStatus from '../../constants/messagesStatus';
import { CustomIcon } from '../../lib/Icons'; import { CustomIcon } from '../../lib/Icons';
import { COLOR_DANGER } from '../../constants/colors'; import { COLOR_DANGER } from '../../constants/colors';
import debounce from '../../utils/debounce'; import debounce from '../../utils/debounce';
import DisclosureIndicator from '../DisclosureIndicator';
import sharedStyles from '../../views/Styles';
const SYSTEM_MESSAGES = [ const SYSTEM_MESSAGES = [
'r', 'r',
@ -118,6 +120,8 @@ export default class Message extends PureComponent {
reactionsModal: PropTypes.bool, reactionsModal: PropTypes.bool,
type: PropTypes.string, type: PropTypes.string,
header: PropTypes.bool, header: PropTypes.bool,
isThreadReply: PropTypes.bool,
isThreadSequential: PropTypes.bool,
avatar: PropTypes.string, avatar: PropTypes.string,
alias: PropTypes.string, alias: PropTypes.string,
ts: PropTypes.oneOfType([ ts: PropTypes.oneOfType([
@ -230,17 +234,17 @@ export default class Message extends PureComponent {
return status === messagesStatus.ERROR; return status === messagesStatus.ERROR;
} }
renderAvatar = () => { renderAvatar = (small = false) => {
const { const {
header, avatar, author, baseUrl, user header, avatar, author, baseUrl, user
} = this.props; } = this.props;
if (header) { if (header) {
return ( return (
<Avatar <Avatar
style={styles.avatar} style={small ? styles.avatarSmall : styles.avatar}
text={avatar ? '' : author.username} text={avatar ? '' : author.username}
size={36} size={small ? 20 : 36}
borderRadius={4} borderRadius={small ? 2 : 4}
avatar={avatar} avatar={avatar}
baseUrl={baseUrl} baseUrl={baseUrl}
userId={user.id} userId={user.id}
@ -493,14 +497,15 @@ export default class Message extends PureComponent {
return ( return (
<View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}> <View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}>
<CustomIcon name='thread' size={16} style={styles.repliedThreadIcon} /> <CustomIcon name='thread' size={20} style={styles.repliedThreadIcon} />
<Text style={styles.repliedThreadName} numberOfLines={1}>{msg}</Text> <Text style={styles.repliedThreadName} numberOfLines={1}>{msg}</Text>
<DisclosureIndicator />
</View> </View>
); );
} }
renderInner = () => { renderInner = () => {
const { type, tmid } = this.props; const { type } = this.props;
if (type === 'discussion-created') { if (type === 'discussion-created') {
return ( return (
<React.Fragment> <React.Fragment>
@ -509,15 +514,6 @@ export default class Message extends PureComponent {
</React.Fragment> </React.Fragment>
); );
} }
if (tmid) {
return (
<React.Fragment>
{this.renderUsername()}
{this.renderRepliedThread()}
{this.renderContent()}
</React.Fragment>
);
}
return ( return (
<React.Fragment> <React.Fragment>
{this.renderUsername()} {this.renderUsername()}
@ -531,23 +527,32 @@ export default class Message extends PureComponent {
); );
} }
render() { renderMessage = () => {
const { const { header, isThreadReply, isThreadSequential } = this.props;
editing, style, header, reactionsModal, closeReactions, msg, ts, reactions, author, user, timeFormat, customEmojis, baseUrl
} = this.props;
const accessibilityLabel = I18n.t('Message_accessibility', { user: author.username, time: moment(ts).format(timeFormat), message: msg });
if (isThreadReply || isThreadSequential || this.isInfoMessage()) {
const thread = isThreadReply ? this.renderRepliedThread() : null;
return ( return (
<View style={styles.root}> <React.Fragment>
{this.renderError()} {thread}
<TouchableWithoutFeedback <View style={[styles.flex, sharedStyles.alignItemsCenter]}>
onLongPress={this.onLongPress} {this.renderAvatar(true)}
onPress={this.onPress}
>
<View <View
style={[styles.container, header && styles.marginTop, editing && styles.editing, style]} style={[
accessibilityLabel={accessibilityLabel} styles.messageContent,
header && styles.messageContentWithHeader,
this.hasError() && header && styles.messageContentWithHeader,
this.hasError() && !header && styles.messageContentWithError,
this.isTemp() && styles.temp
]}
> >
{this.renderContent()}
</View>
</View>
</React.Fragment>
);
}
return (
<View style={styles.flex}> <View style={styles.flex}>
{this.renderAvatar()} {this.renderAvatar()}
<View <View
@ -562,6 +567,27 @@ export default class Message extends PureComponent {
{this.renderInner()} {this.renderInner()}
</View> </View>
</View> </View>
);
}
render() {
const {
editing, style, reactionsModal, closeReactions, msg, ts, reactions, author, user, timeFormat, customEmojis, baseUrl
} = this.props;
const accessibilityLabel = I18n.t('Message_accessibility', { user: author.username, time: moment(ts).format(timeFormat), message: msg });
return (
<View style={styles.root}>
{this.renderError()}
<TouchableWithoutFeedback
onLongPress={this.onLongPress}
onPress={this.onPress}
>
<View
style={[styles.container, editing && styles.editing, style]}
accessibilityLabel={accessibilityLabel}
>
{this.renderMessage()}
{reactionsModal {reactionsModal
? ( ? (
<ReactionsModal <ReactionsModal

View File

@ -163,6 +163,26 @@ export default class MessageContainer extends React.Component {
return true; return true;
} }
isThreadReply = () => {
const {
item, previousItem
} = this.props;
if (previousItem && item.tmid && (previousItem.tmid !== item.tmid) && (previousItem._id !== item.tmid)) {
return true;
}
return false;
}
isThreadSequential = () => {
const {
item, previousItem
} = this.props;
if (previousItem && item.tmid && ((previousItem.tmid === item.tmid) || (previousItem._id === item.tmid))) {
return true;
}
return false;
}
parseMessage = () => { parseMessage = () => {
const { item } = this.props; const { item } = this.props;
return JSON.parse(JSON.stringify(item)); return JSON.parse(JSON.stringify(item));
@ -201,6 +221,8 @@ export default class MessageContainer extends React.Component {
alias={alias} alias={alias}
editing={isEditing} editing={isEditing}
header={this.isHeader()} header={this.isHeader()}
isThreadReply={this.isThreadReply()}
isThreadSequential={this.isThreadSequential()}
avatar={avatar} avatar={avatar}
user={user} user={user}
edited={editedBy && !!editedBy.username} edited={editedBy && !!editedBy.username}

View File

@ -102,6 +102,9 @@ export default StyleSheet.create({
avatar: { avatar: {
marginTop: 4 marginTop: 4
}, },
avatarSmall: {
marginLeft: 16
},
addReaction: { addReaction: {
color: COLOR_PRIMARY color: COLOR_PRIMARY
}, },
@ -217,16 +220,18 @@ export default StyleSheet.create({
}, },
repliedThread: { repliedThread: {
flexDirection: 'row', flexDirection: 'row',
flex: 1 flex: 1,
alignItems: 'center',
marginTop: 6,
marginBottom: 12
}, },
repliedThreadIcon: { repliedThreadIcon: {
color: COLOR_PRIMARY, color: COLOR_PRIMARY,
marginRight: 2 marginRight: 10,
marginLeft: 16
}, },
repliedThreadName: { repliedThreadName: {
fontSize: 14, fontSize: 16,
lineHeight: 16,
fontStyle: 'normal',
flex: 1, flex: 1,
color: COLOR_PRIMARY, color: COLOR_PRIMARY,
...sharedStyles.textRegular ...sharedStyles.textRegular

View File

@ -9,7 +9,7 @@ async function load({ tmid, offset }) {
try { try {
// RC 1.0 // RC 1.0
const result = await this.sdk.get('chat.getThreadMessages', { const result = await this.sdk.get('chat.getThreadMessages', {
tmid, count: 50, offset, sort: { ts: -1 } tmid, count: 50, offset, sort: { ts: -1 }, query: { _hidden: { $ne: true } }
}); });
if (!result || !result.success) { if (!result || !result.success) {
return []; return [];

View File

@ -48,6 +48,12 @@ class RightButtonsContainer extends React.PureComponent {
}; };
} }
componentWillUnmount() {
if (this.thread && this.thread.removeAllListeners) {
this.thread.removeAllListeners();
}
}
updateThread = () => { updateThread = () => {
const { userId } = this.props; const { userId } = this.props;
this.setState({ this.setState({

View File

@ -59,23 +59,24 @@ export class List extends React.PureComponent {
if (this.updateState && this.updateState.stop) { if (this.updateState && this.updateState.stop) {
this.updateState.stop(); this.updateState.stop();
} }
if (this.updateThreads && this.updateThreads.stop) {
this.updateThreads.stop();
}
if (this.interactionManagerState && this.interactionManagerState.cancel) { if (this.interactionManagerState && this.interactionManagerState.cancel) {
this.interactionManagerState.cancel(); this.interactionManagerState.cancel();
} }
if (this.interactionManagerThreads && this.interactionManagerThreads.cancel) {
this.interactionManagerThreads.cancel();
}
console.countReset(`${ this.constructor.name }.render calls`); console.countReset(`${ this.constructor.name }.render calls`);
} }
// eslint-disable-next-line react/sort-comp // eslint-disable-next-line react/sort-comp
updateState = debounce(() => { updateState = debounce(() => {
this.interactionManagerState = InteractionManager.runAfterInteractions(() => { this.interactionManagerState = InteractionManager.runAfterInteractions(() => {
const { tmid } = this.props;
let messages = this.data;
if (tmid && this.threads[0]) {
const thread = { ...this.threads[0] };
thread.tlm = null;
messages = [...messages, thread];
}
this.setState({ this.setState({
messages: this.data.slice(), messages: messages.slice(),
threads: this.threads.slice(), threads: this.threads.slice(),
loading: false loading: false
}); });
@ -95,7 +96,8 @@ export class List extends React.PureComponent {
try { try {
let result; let result;
if (tmid) { if (tmid) {
result = await RocketChat.loadThreadMessages({ tmid, offset: messages.length }); // `offset` is `messages.length - 1` because we append thread start to `messages` obj
result = await RocketChat.loadThreadMessages({ tmid, offset: messages.length - 1 });
} else { } else {
result = await RocketChat.loadMessagesForRoom({ rid, t, latest: messages[messages.length - 1].ts }); result = await RocketChat.loadMessagesForRoom({ rid, t, latest: messages[messages.length - 1].ts });
} }
@ -130,31 +132,22 @@ export class List extends React.PureComponent {
render() { render() {
console.count(`${ this.constructor.name }.render calls`); console.count(`${ this.constructor.name }.render calls`);
const { messages, threads } = this.state; const { messages } = this.state;
const { tmid } = this.props;
let data = [];
if (tmid) {
const thread = { ...threads[0] };
thread.tlm = null;
data = [...messages, thread];
} else {
data = messages;
}
return ( return (
<React.Fragment> <React.Fragment>
<EmptyRoom length={data.length} /> <EmptyRoom length={messages.length} />
<FlatList <FlatList
testID='room-view-messages' testID='room-view-messages'
ref={ref => this.list = ref} ref={ref => this.list = ref}
keyExtractor={item => item._id} keyExtractor={item => item._id}
data={data} data={messages}
extraData={this.state} extraData={this.state}
renderItem={this.renderItem} renderItem={this.renderItem}
contentContainerStyle={styles.contentContainer} contentContainerStyle={styles.contentContainer}
style={styles.list} style={styles.list}
inverted inverted
removeClippedSubviews removeClippedSubviews
initialNumToRender={1} initialNumToRender={5}
onEndReached={this.onEndReached} onEndReached={this.onEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={0.5}
maxToRenderPerBatch={5} maxToRenderPerBatch={5}

View File

@ -510,7 +510,7 @@ export default class RoomView extends LoggedView {
return ( return (
<React.Fragment> <React.Fragment>
{room._id && showActions {room._id && showActions
? <MessageActions room={room} user={user} /> ? <MessageActions room={room} tmid={this.tmid} user={user} />
: null : null
} }
{showErrorActions ? <MessageErrorActions /> : null} {showErrorActions ? <MessageErrorActions /> : null}

View File

@ -271,14 +271,14 @@ export default class RoomsListView extends LoggedView {
updateState = debounce(() => { updateState = debounce(() => {
this.updateStateInteraction = InteractionManager.runAfterInteractions(() => { this.updateStateInteraction = InteractionManager.runAfterInteractions(() => {
this.internalSetState({ this.internalSetState({
chats: this.chats, chats: this.chats ? this.chats.slice() : [],
unread: this.unread, unread: this.unread ? this.unread.slice() : [],
favorites: this.favorites, favorites: this.favorites ? this.favorites.slice() : [],
discussions: this.discussions, discussions: this.discussions ? this.discussions.slice() : [],
channels: this.channels, channels: this.channels ? this.channels.slice() : [],
privateGroup: this.privateGroup, privateGroup: this.privateGroup ? this.privateGroup.slice() : [],
direct: this.direct, direct: this.direct ? this.direct.slice() : [],
livechat: this.livechat, livechat: this.livechat ? this.livechat.slice() : [],
loading: false loading: false
}); });
this.forceUpdate(); this.forceUpdate();

View File

@ -66,6 +66,9 @@ export default StyleSheet.create({
alignItemsFlexStart: { alignItemsFlexStart: {
alignItems: 'flex-start' alignItems: 'flex-start'
}, },
alignItemsCenter: {
alignItems: 'center'
},
textAlignRight: { textAlignRight: {
textAlign: 'right' textAlign: 'right'
}, },

View File

@ -286,8 +286,6 @@ describe('Room screen', () => {
await element(by.id('messagebox-send-message')).tap(); await element(by.id('messagebox-send-message')).tap();
await waitFor(element(by.id(`message-thread-button-${ thread }`))).toExist().withTimeout(5000); await waitFor(element(by.id(`message-thread-button-${ thread }`))).toExist().withTimeout(5000);
await expect(element(by.id(`message-thread-button-${ thread }`))).toExist(); await expect(element(by.id(`message-thread-button-${ thread }`))).toExist();
await waitFor(element(by.id(`message-thread-replied-on-${ thread }`))).toExist().withTimeout(5000);
await expect(element(by.id(`message-thread-replied-on-${ thread }`))).toExist();
}); });
it('should navigate to thread from button', async() => { it('should navigate to thread from button', async() => {
@ -313,6 +311,16 @@ describe('Room screen', () => {
}); });
it('should navigate to thread from thread name', async() => { it('should navigate to thread from thread name', async() => {
await mockMessage('dummymessagebetweenthethread');
await element(by.text(thread)).longPress();
await waitFor(element(by.text('Message actions'))).toBeVisible().withTimeout(5000);
await expect(element(by.text('Message actions'))).toBeVisible();
await element(by.text('Reply')).tap();
await element(by.id('messagebox-input')).typeText('repliedagain');
await element(by.id('messagebox-send-message')).tap();
await waitFor(element(by.id(`message-thread-replied-on-${ thread }`))).toExist().withTimeout(5000);
await expect(element(by.id(`message-thread-replied-on-${ thread }`))).toExist();
await element(by.id(`message-thread-replied-on-${ thread }`)).tap(); await element(by.id(`message-thread-replied-on-${ thread }`)).tap();
await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000); await waitFor(element(by.id('room-view'))).toBeVisible().withTimeout(5000);
await waitFor(element(by.id(`room-view-title-${ thread }`))).toBeVisible().withTimeout(5000); await waitFor(element(by.id(`room-view-title-${ thread }`))).toBeVisible().withTimeout(5000);

View File

@ -298,31 +298,37 @@ export default (
msg="I'm fine!" msg="I'm fine!"
tmid='1' tmid='1'
tmsg='How are you?' tmsg='How are you?'
isThreadReply
/> />
<Message <Message
msg="I'm fine!" msg="I'm fine!"
tmid='1' tmid='1'
tmsg='Thread with emoji :) :joy:' tmsg='Thread with emoji :) :joy:'
isThreadReply
/> />
<Message <Message
msg="I'm fine!" msg="I'm fine!"
tmid='1' tmid='1'
tmsg='Markdown: [link](http://www.google.com/) ```block code```' tmsg='Markdown: [link](http://www.google.com/) ```block code```'
isThreadReply
/> />
<Message <Message
msg="I'm fine!" msg="I'm fine!"
tmid='1' tmid='1'
tmsg={longText} tmsg={longText}
isThreadReply
/> />
<Message <Message
msg={longText} msg={longText}
tmid='1' tmid='1'
tmsg='How are you?' tmsg='How are you?'
isThreadReply
/> />
<Message <Message
msg={longText} msg={longText}
tmid='1' tmid='1'
tmsg={longText} tmsg={longText}
isThreadReply
/> />
<Message <Message
tmid='1' tmid='1'
@ -332,6 +338,60 @@ export default (
description: 'This is a description', description: 'This is a description',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac' audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]} }]}
isThreadReply
/>
<Separator title='Sequential thread messages following thread button' />
<Message
msg='How are you?'
tcount={1}
tlm={date}
/>
<Message
msg="I'm fine!"
tmid='1'
isThreadSequential
/>
<Message
msg={longText}
tmid='1'
isThreadSequential
/>
<Message
attachments={[{
title: 'This is a title',
description: 'This is a description',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]}
tmid='1'
isThreadSequential
/>
<Separator title='Sequential thread messages following thread reply' />
<Message
msg="I'm fine!"
tmid='1'
tmsg='How are you?'
isThreadReply
/>
<Message
msg='Cool!'
tmid='1'
isThreadSequential
/>
<Message
msg={longText}
tmid='1'
isThreadSequential
/>
<Message
attachments={[{
title: 'This is a title',
description: 'This is a description',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]}
tmid='1'
isThreadSequential
/> />
{/* <Message {/* <Message