[FIX] Threads (#838)

Closes #826
Closes #827
Closes #828
Closes #829
Closes #830
Closes #831
Closes #832
Closes #833
This commit is contained in:
Diego Mello 2019-04-24 15:36:29 -03:00 committed by GitHub
parent 0266cc2e01
commit 5744114d7d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 2220 additions and 1665 deletions

File diff suppressed because it is too large Load Diff

View File

@ -11,6 +11,7 @@ export const COLOR_TEXT_DESCRIPTION = '#9ca2a8';
export const COLOR_SEPARATOR = '#A7A7AA';
export const COLOR_BACKGROUND_CONTAINER = '#f3f4f5';
export const COLOR_BORDER = '#e1e5e8';
export const COLOR_UNREAD = '#e1e5e8';
export const STATUS_COLORS = {
online: '#2de0a5',
busy: COLOR_DANGER,

View File

@ -57,12 +57,14 @@ class MessageBox extends Component {
replying: PropTypes.bool,
editing: PropTypes.bool,
threadsEnabled: PropTypes.bool,
isFocused: PropTypes.bool,
user: PropTypes.shape({
id: PropTypes.string,
username: PropTypes.string,
token: PropTypes.string
}),
roomType: PropTypes.string,
tmid: PropTypes.string,
editCancel: PropTypes.func.isRequired,
editRequest: PropTypes.func.isRequired,
onSubmit: PropTypes.func.isRequired,
@ -92,23 +94,37 @@ class MessageBox extends Component {
}
componentDidMount() {
const { rid } = this.props;
const [room] = database.objects('subscriptions').filtered('rid = $0', rid);
if (room && room.draftMessage) {
this.setInput(room.draftMessage);
const { rid, tmid } = this.props;
let msg;
if (tmid) {
const thread = database.objectForPrimaryKey('threads', tmid);
if (thread) {
msg = thread.draftMessage;
}
} else {
const [room] = database.objects('subscriptions').filtered('rid = $0', rid);
if (room) {
msg = room.draftMessage;
}
}
if (msg) {
this.setInput(msg);
this.setShowSend(true);
}
}
componentWillReceiveProps(nextProps) {
const { message, replyMessage } = this.props;
if (message !== nextProps.message && nextProps.message.msg) {
const { message, replyMessage, isFocused } = this.props;
if (!isFocused) {
return;
}
if (!equal(message, nextProps.message) && nextProps.message.msg) {
this.setInput(nextProps.message.msg);
if (this.text) {
this.setShowSend(true);
}
this.focus();
} else if (replyMessage !== nextProps.replyMessage && nextProps.replyMessage.msg) {
} else if (!equal(replyMessage, nextProps.replyMessage)) {
this.focus();
} else if (!nextProps.message) {
this.clearInput();
@ -120,8 +136,11 @@ class MessageBox extends Component {
showEmojiKeyboard, showFilesAction, showSend, recording, mentions, file
} = this.state;
const {
roomType, replying, editing
roomType, replying, editing, isFocused
} = this.props;
if (!isFocused) {
return false;
}
if (nextProps.roomType !== roomType) {
return true;
}
@ -481,7 +500,7 @@ class MessageBox extends Component {
}
sendImageMessage = async(file) => {
const { rid } = this.props;
const { rid, tmid } = this.props;
this.setState({ file: { isVisible: false } });
const fileInfo = {
@ -493,7 +512,7 @@ class MessageBox extends Component {
path: file.path
};
try {
await RocketChat.sendFileMessage(rid, fileInfo);
await RocketChat.sendFileMessage(rid, fileInfo, tmid);
} catch (e) {
log('sendImageMessage', e);
}
@ -539,14 +558,14 @@ class MessageBox extends Component {
}
finishAudioMessage = async(fileInfo) => {
const { rid } = this.props;
const { rid, tmid } = this.props;
this.setState({
recording: false
});
if (fileInfo) {
try {
await RocketChat.sendFileMessage(rid, fileInfo);
await RocketChat.sendFileMessage(rid, fileInfo, tmid);
} catch (e) {
if (e && e.error === 'error-file-too-large') {
return Alert.alert(I18n.t(e.error));
@ -830,7 +849,7 @@ class MessageBox extends Component {
const mapStateToProps = state => ({
message: state.messages.message,
replyMessage: state.messages.replyMessage,
replying: state.messages.replyMessage && !!state.messages.replyMessage.msg,
replying: state.messages.replying,
editing: state.messages.editing,
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
threadsEnabled: state.settings.Threads_enabled,

View File

@ -6,8 +6,8 @@ import {
import Video from 'react-native-video';
import Slider from 'react-native-slider';
import moment from 'moment';
import { BorderlessButton } from 'react-native-gesture-handler';
import equal from 'deep-equal';
import Touchable from 'react-native-platform-touchable';
import Markdown from './Markdown';
import { CustomIcon } from '../../lib/Icons';
@ -27,7 +27,7 @@ const styles = StyleSheet.create({
marginBottom: 6
},
playPauseButton: {
width: 56,
marginHorizontal: 10,
alignItems: 'center',
backgroundColor: 'transparent'
},
@ -35,11 +35,10 @@ const styles = StyleSheet.create({
color: COLOR_PRIMARY
},
slider: {
flex: 1,
marginRight: 10
flex: 1
},
duration: {
marginRight: 16,
marginHorizontal: 12,
fontSize: 14,
...sharedStyles.textColorNormal,
...sharedStyles.textRegular
@ -47,10 +46,16 @@ const styles = StyleSheet.create({
thumbStyle: {
width: 12,
height: 12
},
trackStyle: {
height: 2
}
});
const formatTime = seconds => moment.utc(seconds * 1000).format('mm:ss');
const BUTTON_HIT_SLOP = {
top: 12, right: 12, bottom: 12, left: 12
};
export default class Audio extends React.Component {
static propTypes = {
@ -97,30 +102,30 @@ export default class Audio extends React.Component {
return false;
}
onLoad(data) {
onLoad = (data) => {
this.setState({ duration: data.duration > 0 ? data.duration : 0 });
}
onProgress(data) {
onProgress = (data) => {
const { duration } = this.state;
if (data.currentTime <= duration) {
this.setState({ currentTime: data.currentTime });
}
}
onEnd() {
onEnd = () => {
this.setState({ paused: true, currentTime: 0 });
requestAnimationFrame(() => {
this.player.seek(0);
});
}
getDuration() {
getDuration = () => {
const { duration } = this.state;
return formatTime(duration);
}
togglePlayPause() {
togglePlayPause = () => {
const { paused } = this.state;
this.setState({ paused: !paused });
}
@ -152,16 +157,18 @@ export default class Audio extends React.Component {
paused={paused}
repeat={false}
/>
<BorderlessButton
<Touchable
style={styles.playPauseButton}
onPress={() => this.togglePlayPause()}
onPress={this.togglePlayPause}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
{
paused
? <CustomIcon name='play' size={30} style={styles.playPauseImage} />
: <CustomIcon name='pause' size={30} style={styles.playPauseImage} />
? <CustomIcon name='play' size={36} style={styles.playPauseImage} />
: <CustomIcon name='pause' size={36} style={styles.playPauseImage} />
}
</BorderlessButton>
</Touchable>
<Slider
style={styles.slider}
value={currentTime}
@ -177,6 +184,7 @@ export default class Audio extends React.Component {
minimumTrackTintColor={COLOR_PRIMARY}
onValueChange={value => this.setState({ currentTime: value })}
thumbStyle={styles.thumbStyle}
trackStyle={styles.trackStyle}
/>
<Text style={styles.duration}>{this.getDuration()}</Text>
</View>,

View File

@ -22,7 +22,7 @@ export default class Markdown extends React.Component {
render() {
const {
msg, customEmojis, style, rules, baseUrl, username, edited
msg, customEmojis, style, rules, baseUrl, username, edited, numberOfLines
} = this.props;
if (!msg) {
return null;
@ -32,12 +32,15 @@ export default class Markdown extends React.Component {
m = emojify(m, { output: 'unicode' });
}
m = m.replace(/^\[([^\]]*)\]\(([^)]*)\)/, '').trim();
if (numberOfLines > 0) {
m = m.replace(/[\n]+/g, '\n').trim();
}
return (
<MarkdownRenderer
rules={{
paragraph: (node, children) => (
// eslint-disable-next-line
<Text key={node.key} style={styles.paragraph}>
<Text key={node.key} style={styles.paragraph} numberOfLines={numberOfLines}>
{children}
{edited ? <Text style={styles.edited}> (edited)</Text> : null}
</Text>
@ -111,5 +114,6 @@ Markdown.propTypes = {
customEmojis: PropTypes.object.isRequired,
style: PropTypes.any,
rules: PropTypes.object,
edited: PropTypes.bool
edited: PropTypes.bool,
numberOfLines: PropTypes.number
};

View File

@ -5,10 +5,8 @@ import {
} from 'react-native';
import moment from 'moment';
import { KeyboardUtils } from 'react-native-keyboard-input';
import {
BorderlessButton
} from 'react-native-gesture-handler';
import Touchable from 'react-native-platform-touchable';
import { emojify } from 'react-emojione';
import Image from './Image';
import User from './User';
@ -164,6 +162,11 @@ export default class Message extends PureComponent {
onPress = () => {
KeyboardUtils.dismiss();
const { onThreadPress, tlm, tmid } = this.props;
if ((tlm || tmid) && onThreadPress) {
onThreadPress();
}
}
onLongPress = () => {
@ -269,10 +272,25 @@ export default class Message extends PureComponent {
if (this.isInfoMessage()) {
return <Text style={styles.textInfo}>{getInfoMessage({ ...this.props })}</Text>;
}
const {
customEmojis, msg, baseUrl, user, edited
customEmojis, msg, baseUrl, user, edited, tmid
} = this.props;
return <Markdown msg={msg} customEmojis={customEmojis} baseUrl={baseUrl} username={user.username} edited={edited} />;
if (tmid && !msg) {
return <Text style={styles.text}>{I18n.t('Sent_an_attachment')}</Text>;
}
return (
<Markdown
msg={msg}
customEmojis={customEmojis}
baseUrl={baseUrl}
username={user.username}
edited={edited}
numberOfLines={tmid ? 1 : 0}
/>
);
}
renderAttachment() {
@ -316,9 +334,9 @@ export default class Message extends PureComponent {
}
const { onErrorPress } = this.props;
return (
<BorderlessButton onPress={onErrorPress} style={styles.errorButton}>
<Touchable onPress={onErrorPress} style={styles.errorButton}>
<CustomIcon name='circle-cross' color={COLOR_DANGER} size={20} />
</BorderlessButton>
</Touchable>
);
}
@ -457,7 +475,7 @@ export default class Message extends PureComponent {
renderRepliedThread = () => {
const {
tmid, tmsg, header, onThreadPress, fetchThreadName
tmid, tmsg, header, fetchThreadName
} = this.props;
if (!tmid || !header || this.isTemp()) {
return null;
@ -468,15 +486,18 @@ export default class Message extends PureComponent {
return null;
}
const msg = emojify(tmsg, { output: 'unicode' });
return (
<Text style={styles.repliedThread} numberOfLines={3} testID={`message-thread-replied-on-${ tmsg }`}>
{I18n.t('Replied_on')} <Text style={styles.repliedThreadName} onPress={onThreadPress}>{tmsg}</Text>
</Text>
<View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}>
<CustomIcon name='thread' size={20} style={[styles.buttonIcon, styles.repliedThreadIcon]} />
<Text style={styles.repliedThreadName} numberOfLines={1}>{msg}</Text>
</View>
);
}
renderInner = () => {
const { type } = this.props;
const { type, tmid } = this.props;
if (type === 'discussion-created') {
return (
<React.Fragment>
@ -485,10 +506,18 @@ export default class Message extends PureComponent {
</React.Fragment>
);
}
if (tmid) {
return (
<React.Fragment>
{this.renderUsername()}
{this.renderRepliedThread()}
{this.renderContent()}
</React.Fragment>
);
}
return (
<React.Fragment>
{this.renderUsername()}
{this.renderRepliedThread()}
{this.renderContent()}
{this.renderAttachment()}
{this.renderUrl()}

View File

@ -76,7 +76,7 @@ export default class MessageContainer extends React.Component {
shouldComponentUpdate(nextProps, nextState) {
const { reactionsModal } = this.state;
const {
status, editingMessage, item, _updatedAt
status, editingMessage, item, _updatedAt, navigation
} = this.props;
if (reactionsModal !== nextState.reactionsModal) {
@ -89,7 +89,7 @@ export default class MessageContainer extends React.Component {
return true;
}
if (!equal(editingMessage, nextProps.editingMessage)) {
if (navigation.isFocused() && !equal(editingMessage, nextProps.editingMessage)) {
if (nextProps.editingMessage && nextProps.editingMessage._id === item._id) {
return true;
} else if (!nextProps.editingMessage._id !== item._id && editingMessage._id === item._id) {

View File

@ -216,13 +216,16 @@ export default StyleSheet.create({
fontWeight: '300'
},
repliedThread: {
fontSize: 16,
marginBottom: 6,
...sharedStyles.textColorDescription,
...sharedStyles.textRegular
flexDirection: 'row',
flex: 1
},
repliedThreadIcon: {
color: COLOR_PRIMARY
},
repliedThreadName: {
fontSize: 16,
fontStyle: 'normal',
flex: 1,
color: COLOR_PRIMARY,
...sharedStyles.textSemibold
}

View File

@ -293,6 +293,7 @@ export default {
Send: 'Send',
Send_audio_message: 'Send audio message',
Send_message: 'Send message',
Sent_an_attachment: 'Sent an attachment',
Server: 'Server',
Servers: 'Servers',
Set_username_subtitle: 'The username is used to allow others to mention you in messages',

View File

@ -295,6 +295,7 @@ export default {
Send: 'Enviar',
Send_audio_message: 'Enviar mensagem de áudio',
Send_message: 'Enviar mensagem',
Sent_an_attachment: 'Enviou um anexo',
Server: 'Servidor',
Set_username_subtitle: 'O usuário é utilizado para permitir que você seja mencionado em mensagens',
Settings: 'Configurações',

View File

@ -284,6 +284,7 @@ export default {
Send: 'Enviar',
Send_audio_message: 'Enviar mensagem de áudio',
Send_message: 'Enviar mensagem',
Sent_an_attachment: 'Enviou um ficheiro',
Server: 'Servidor',
Servers: 'Servidores',
Set_username_subtitle: 'O nome de utilizador é usado para permitir que outros mencionem você em mensagens',

View File

@ -145,7 +145,7 @@ const ProfileStack = createStackNavigator({
defaultNavigationOptions: defaultHeader
});
ProfileView.navigationOptions = ({ navigation }) => {
ProfileStack.navigationOptions = ({ navigation }) => {
let drawerLockMode = 'unlocked';
if (navigation.state.index > 0) {
drawerLockMode = 'locked-closed';

View File

@ -46,6 +46,11 @@ export default function loadMessagesForRoom(...args) {
if (message.tlm) {
database.create('threads', message, true);
}
// if it belongs to a thread
if (message.tmid) {
message.rid = message.tmid;
database.create('threadMessages', message, true);
}
} catch (e) {
log('loadMessagesForRoom -> create messages', e);
}

View File

@ -40,11 +40,14 @@ export default function loadMissedMessages(...args) {
if (message.tlm) {
database.create('threads', message, true);
}
if (message.tmid) {
message.rid = message.tmid;
database.create('threadMessages', message, true);
}
} catch (e) {
log('loadMissedMessages -> create messages', e);
}
}));
resolve(updated);
});
}
if (data.deleted && data.deleted.length) {
@ -55,6 +58,10 @@ export default function loadMissedMessages(...args) {
deleted.forEach((m) => {
const message = database.objects('messages').filtered('_id = $0', m._id);
database.delete(message);
const thread = database.objects('threads').filtered('_id = $0', m._id);
database.delete(thread);
const threadMessage = database.objects('threadMessages').filtered('_id = $0', m._id);
database.delete(threadMessage);
});
});
} catch (e) {
@ -63,7 +70,7 @@ export default function loadMissedMessages(...args) {
});
}
}
resolve([]);
resolve();
} catch (e) {
log('loadMissedMessages', e);
reject(e);

View File

@ -5,24 +5,26 @@ import buildMessage from './helpers/buildMessage';
import database from '../realm';
import log from '../../utils/log';
async function load({ tmid, skip }) {
async function load({ tmid, offset }) {
try {
// RC 1.0
const data = await this.sdk.methodCall('getThreadMessages', { tmid, limit: 50, skip });
if (!data || data.status === 'error') {
const result = await this.sdk.get('chat.getThreadMessages', {
tmid, count: 50, offset, sort: { ts: -1 }
});
if (!result || !result.success) {
return [];
}
return data;
return result.messages;
} catch (error) {
console.log(error);
return [];
}
}
export default function loadThreadMessages({ tmid, skip }) {
export default function loadThreadMessages({ tmid, offset = 0 }) {
return new Promise(async(resolve, reject) => {
try {
const data = await load.call(this, { tmid, skip });
const data = await load.call(this, { tmid, offset });
if (data && data.length) {
InteractionManager.runAfterInteractions(() => {

View File

@ -29,7 +29,7 @@ export async function cancelUpload(path) {
}
}
export async function sendFileMessage(rid, fileInfo) {
export async function sendFileMessage(rid, fileInfo, tmid) {
try {
const data = await RNFetchBlob.wrap(fileInfo.path);
if (!fileInfo.size) {
@ -86,6 +86,8 @@ export async function sendFileMessage(rid, fileInfo) {
name: completeResult.name,
description: completeResult.description,
url: completeResult.path
}, {
tmid
});
database.write(() => {

View File

@ -104,7 +104,8 @@ const subscriptionSchema = {
muted: { type: 'list', objectType: 'usersMuted' },
broadcast: { type: 'bool', optional: true },
prid: { type: 'string', optional: true },
draftMessage: { type: 'string', optional: true }
draftMessage: { type: 'string', optional: true },
lastThreadSync: 'date?'
}
};
@ -259,7 +260,8 @@ const threadsSchema = {
tmid: { type: 'string', optional: true },
tcount: { type: 'int', optional: true },
tlm: { type: 'date', optional: true },
replies: 'string[]'
replies: 'string[]',
draftMessage: 'string?'
}
};
@ -387,9 +389,9 @@ class DB {
schema: [
serversSchema
],
schemaVersion: 4,
schemaVersion: 5,
migration: (oldRealm, newRealm) => {
if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 3) {
if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 5) {
const newServers = newRealm.objects('servers');
// eslint-disable-next-line no-plusplus
@ -441,15 +443,12 @@ class DB {
setActiveDB(database = '') {
const path = database.replace(/(^\w+:|^)\/\//, '');
if (this.database) {
this.database.close();
}
return this.databases.activeDB = new Realm({
path: `${ path }.realm`,
schema,
schemaVersion: 6,
schemaVersion: 8,
migration: (oldRealm, newRealm) => {
if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 6) {
if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 8) {
const newSubs = newRealm.objects('subscriptions');
// eslint-disable-next-line no-plusplus
@ -459,6 +458,10 @@ class DB {
}
const newMessages = newRealm.objects('messages');
newRealm.delete(newMessages);
const newThreads = newRealm.objects('threads');
newRealm.delete(newThreads);
const newThreadMessages = newRealm.objects('threadMessages');
newRealm.delete(newThreadMessages);
}
}
});

View File

@ -416,8 +416,6 @@ const RocketChat = {
data = data.filtered('t != $0', 'd');
}
data = data.slice(0, 7);
const array = Array.from(data);
data = JSON.parse(JSON.stringify(array));
const usernames = data.map(sub => sub.name);
try {
@ -782,9 +780,17 @@ const RocketChat = {
}
return this.sdk.methodCall('unfollowMessage', { mid });
},
getThreadsList({ rid, limit, skip }) {
getThreadsList({ rid, count, offset }) {
// RC 1.0
return this.sdk.methodCall('getThreadsList', { rid, limit, skip });
return this.sdk.get('chat.getThreadsList', {
rid, count, offset, sort: { ts: -1 }
});
},
getSyncThreadsList({ rid, updatedSince }) {
// RC 1.0
return this.sdk.get('chat.syncThreadsList', {
rid, updatedSince
});
}
};

View File

@ -41,7 +41,7 @@ const formatMsg = ({
const arePropsEqual = (oldProps, newProps) => _.isEqual(oldProps, newProps);
const LastMessage = React.memo(({
lastMessage, type, showLastMessage, username
lastMessage, type, showLastMessage, username, alert
}) => (
<Text style={[styles.markdownText, alert && styles.markdownTextAlert]} numberOfLines={2}>
{formatMsg({
@ -54,7 +54,8 @@ LastMessage.propTypes = {
lastMessage: PropTypes.object,
type: PropTypes.string,
showLastMessage: PropTypes.bool,
username: PropTypes.string
username: PropTypes.string,
alert: PropTypes.bool
};
export default LastMessage;

View File

@ -14,8 +14,8 @@ const UnreadBadge = React.memo(({ unread, userMentions, type }) => {
const mentioned = userMentions > 0 && type !== 'd';
return (
<View style={[styles.unreadNumberContainer, mentioned && styles.unreadMentioned]}>
<Text style={styles.unreadNumberText}>{ unread }</Text>
<View style={[styles.unreadNumberContainer, mentioned && styles.unreadMentionedContainer]}>
<Text style={[styles.unreadText, mentioned && styles.unreadMentionedText]}>{ unread }</Text>
</View>
);
});

View File

@ -109,7 +109,7 @@ export default class RoomItem extends React.Component {
{_updatedAt ? <Text style={[styles.date, alert && styles.updateAlert]} ellipsizeMode='tail' numberOfLines={1}>{ date }</Text> : null}
</View>
<View style={styles.row}>
<LastMessage lastMessage={lastMessage} type={type} showLastMessage={showLastMessage} username={username} />
<LastMessage lastMessage={lastMessage} type={type} showLastMessage={showLastMessage} username={username} alert={alert} />
<UnreadBadge unread={unread} userMentions={userMentions} type={type} />
</View>
</View>

View File

@ -2,7 +2,7 @@ import { StyleSheet, PixelRatio } from 'react-native';
import sharedStyles from '../../views/Styles';
import {
COLOR_SEPARATOR, COLOR_PRIMARY, COLOR_WHITE, COLOR_TEXT
COLOR_SEPARATOR, COLOR_PRIMARY, COLOR_WHITE, COLOR_UNREAD, COLOR_TEXT
} from '../../constants/colors';
export const ROW_HEIGHT = 75 * PixelRatio.getFontScale();
@ -53,27 +53,30 @@ export default StyleSheet.create({
...sharedStyles.textSemibold
},
unreadNumberContainer: {
minWidth: 22,
height: 22,
minWidth: 21,
height: 21,
paddingVertical: 3,
paddingHorizontal: 5,
borderRadius: 14,
backgroundColor: COLOR_TEXT,
borderRadius: 10.5,
backgroundColor: COLOR_UNREAD,
alignItems: 'center',
justifyContent: 'center',
marginLeft: 10
},
unreadMentioned: {
unreadMentionedContainer: {
backgroundColor: COLOR_PRIMARY
},
unreadNumberText: {
color: COLOR_WHITE,
unreadText: {
color: COLOR_TEXT,
overflow: 'hidden',
fontSize: 13,
...sharedStyles.textRegular,
...sharedStyles.textMedium,
letterSpacing: 0.56,
textAlign: 'center'
},
unreadMentionedText: {
color: COLOR_WHITE
},
status: {
marginRight: 7,
marginTop: 3

View File

@ -4,6 +4,7 @@ const initialState = {
message: {},
actionMessage: {},
replyMessage: {},
replying: false,
editing: false,
showActions: false,
showErrorActions: false,
@ -64,12 +65,14 @@ export default function messages(state = initialState, action) {
replyMessage: {
...action.message,
mention: action.mention
}
},
replying: true
};
case types.MESSAGES.REPLY_CANCEL:
return {
...state,
replyMessage: {}
replyMessage: {},
replying: false
};
case types.MESSAGES.SET_INPUT:
return {

View File

@ -10,10 +10,14 @@ import log from '../../../utils/log';
const styles = StyleSheet.create({
more: {
marginHorizontal: 0, marginLeft: 0, marginRight: 5
marginHorizontal: 0,
marginLeft: 0,
marginRight: 5
},
thread: {
marginHorizontal: 0, marginLeft: 0, marginRight: 10
marginHorizontal: 0,
marginLeft: 0,
marginRight: 15
}
});
@ -34,6 +38,7 @@ class RightButtonsContainer extends React.PureComponent {
constructor(props) {
super(props);
if (props.tmid) {
// FIXME: it may be empty if the thread header isn't fetched yet
this.thread = database.objectForPrimaryKey('messages', props.tmid);
safeAddListener(this.thread, this.updateThread);
}

View File

@ -1,7 +1,6 @@
import React from 'react';
import { ActivityIndicator, FlatList, InteractionManager } from 'react-native';
import PropTypes from 'prop-types';
import { emojify } from 'react-emojione';
import debounce from 'lodash/debounce';
import styles from './styles';
@ -30,7 +29,7 @@ export class List extends React.PureComponent {
.objects('threadMessages')
.filtered('rid = $0', props.tmid)
.sorted('ts', true);
this.threads = [];
this.threads = database.objects('threads').filtered('_id = $0', props.tmid);
} else {
this.data = database
.objects('messages')
@ -83,7 +82,7 @@ export class List extends React.PureComponent {
});
}, 300, { leading: true });
onEndReached = async() => {
onEndReached = debounce(async() => {
const {
loading, end, messages
} = this.state;
@ -96,17 +95,17 @@ export class List extends React.PureComponent {
try {
let result;
if (tmid) {
result = await RocketChat.loadThreadMessages({ tmid, skip: messages.length });
result = await RocketChat.loadThreadMessages({ tmid, offset: messages.length });
} else {
result = await RocketChat.loadMessagesForRoom({ rid, t, latest: messages[messages.length - 1].ts });
}
this.setState({ end: result.length < 50 });
this.setState({ end: result.length < 50, loading: false });
} catch (e) {
this.setState({ loading: false });
log('ListView.onEndReached', e);
}
}
}, 300)
renderFooter = () => {
const { loading } = this.state;
@ -122,10 +121,7 @@ export class List extends React.PureComponent {
if (item.tmid) {
const thread = threads.find(t => t._id === item.tmid);
if (thread) {
let tmsg = thread.msg || (thread.attachments && thread.attachments.length && thread.attachments[0].title);
if (tmsg) {
tmsg = emojify(tmsg, { output: 'unicode' });
}
const tmsg = thread.msg || (thread.attachments && thread.attachments.length && thread.attachments[0].title);
item = { ...item, tmsg };
}
}
@ -134,15 +130,24 @@ export class List extends React.PureComponent {
render() {
console.count(`${ this.constructor.name }.render calls`);
const { messages } = this.state;
const { messages, threads } = this.state;
const { tmid } = this.props;
let data = [];
if (tmid) {
const thread = { ...threads[0] };
thread.tlm = null;
data = [...messages, thread];
} else {
data = messages;
}
return (
<React.Fragment>
<EmptyRoom length={messages.length} />
<EmptyRoom length={data.length} />
<FlatList
testID='room-view-messages'
ref={ref => this.list = ref}
keyExtractor={item => item._id}
data={messages}
data={data}
extraData={this.state}
renderItem={this.renderItem}
contentContainerStyle={styles.contentContainer}

View File

@ -46,6 +46,8 @@ import buildMessage from '../../lib/methods/helpers/buildMessage';
token: state.login.user && state.login.user.token
},
actionMessage: state.messages.actionMessage,
editing: state.messages.editing,
replying: state.messages.replying,
showActions: state.messages.showActions,
showErrorActions: state.messages.showErrorActions,
appState: state.app.ready && state.app.foreground ? 'foreground' : 'background',
@ -87,6 +89,8 @@ export default class RoomView extends LoggedView {
appState: PropTypes.string,
useRealName: PropTypes.bool,
isAuthenticated: PropTypes.bool,
editing: PropTypes.bool,
replying: PropTypes.bool,
toggleReactionPicker: PropTypes.func.isRequired,
actionsShow: PropTypes.func,
editCancel: PropTypes.func,
@ -176,12 +180,18 @@ export default class RoomView extends LoggedView {
}
componentWillUnmount() {
if (this.messagebox && this.messagebox.current && this.messagebox.current.text) {
const { editing, replying } = this.props;
if (!editing && this.messagebox && this.messagebox.current && this.messagebox.current.text) {
const { text } = this.messagebox.current;
const [room] = this.rooms;
if (room) {
let obj;
if (this.tmid) {
obj = database.objectForPrimaryKey('threads', this.tmid);
} else {
[obj] = this.rooms;
}
if (obj) {
database.write(() => {
room.draftMessage = text;
obj.draftMessage = text;
});
}
}
@ -192,9 +202,14 @@ export default class RoomView extends LoggedView {
if (this.beginAnimatingTimeout) {
clearTimeout(this.beginAnimatingTimeout);
}
const { editCancel, replyCancel } = this.props;
editCancel();
replyCancel();
if (editing) {
const { editCancel } = this.props;
editCancel();
}
if (replying) {
const { replyCancel } = this.props;
replyCancel();
}
if (this.didMountInteraction && this.didMountInteraction.cancel) {
this.didMountInteraction.cancel();
}
@ -217,7 +232,7 @@ export default class RoomView extends LoggedView {
this.initInteraction = InteractionManager.runAfterInteractions(async() => {
const { room } = this.state;
if (this.tmid) {
RocketChat.loadThreadMessages({ tmid: this.tmid, t: this.t });
await this.getThreadMessages();
} else {
await this.getMessages(room);
@ -241,7 +256,7 @@ export default class RoomView extends LoggedView {
onMessageLongPress = (message) => {
const { actionsShow } = this.props;
actionsShow(message);
actionsShow({ ...message, rid: this.rid });
}
onReactionPress = (shortname, messageId) => {
@ -311,6 +326,15 @@ export default class RoomView extends LoggedView {
}
}
getThreadMessages = () => {
try {
return RocketChat.loadThreadMessages({ tmid: this.tmid });
} catch (e) {
console.log('TCL: getThreadMessages -> e', e);
log('getThreadMessages', e);
}
}
setLastOpen = lastOpen => this.setState({ lastOpen });
joinRoom = async() => {
@ -420,6 +444,7 @@ export default class RoomView extends LoggedView {
renderFooter = () => {
const { joined, room } = this.state;
const { navigation } = this.props;
if (!joined && !this.tmid) {
return (
@ -450,7 +475,16 @@ export default class RoomView extends LoggedView {
</View>
);
}
return <MessageBox ref={this.messagebox} onSubmit={this.sendMessage} rid={this.rid} roomType={room.t} />;
return (
<MessageBox
ref={this.messagebox}
onSubmit={this.sendMessage}
rid={this.rid}
tmid={this.tmid}
roomType={room.t}
isFocused={navigation.isFocused()}
/>
);
};
renderActions = () => {

View File

@ -175,45 +175,6 @@ export default class RoomsListView extends LoggedView {
return true;
}
const { showUnread, showFavorites, groupByType } = this.props;
if (showUnread) {
const { unread } = this.state;
if (!isEqual(nextState.unread, unread)) {
return true;
}
}
if (showFavorites) {
const { favorites } = this.state;
if (!isEqual(nextState.favorites, favorites)) {
return true;
}
}
if (groupByType) {
const {
dicussions, channels, privateGroup, direct, livechat
} = this.state;
if (!isEqual(nextState.dicussions, dicussions)) {
return true;
}
if (!isEqual(nextState.channels, channels)) {
return true;
}
if (!isEqual(nextState.privateGroup, privateGroup)) {
return true;
}
if (!isEqual(nextState.direct, direct)) {
return true;
}
if (!isEqual(nextState.livechat, livechat)) {
return true;
}
} else {
const { chats } = this.state;
if (!isEqual(nextState.chats, chats)) {
return true;
}
}
const { search } = this.state;
if (!isEqual(nextState.search, search)) {
return true;
@ -311,27 +272,20 @@ export default class RoomsListView extends LoggedView {
updateState = debounce(() => {
this.updateStateInteraction = InteractionManager.runAfterInteractions(() => {
this.internalSetState({
chats: this.getSnapshot(this.chats),
unread: this.getSnapshot(this.unread),
favorites: this.getSnapshot(this.favorites),
discussions: this.getSnapshot(this.discussions),
channels: this.getSnapshot(this.channels),
privateGroup: this.getSnapshot(this.privateGroup),
direct: this.getSnapshot(this.direct),
livechat: this.getSnapshot(this.livechat),
chats: this.chats,
unread: this.unread,
favorites: this.favorites,
discussions: this.discussions,
channels: this.channels,
privateGroup: this.privateGroup,
direct: this.direct,
livechat: this.livechat,
loading: false
});
this.forceUpdate();
});
}, 300);
getSnapshot = (data) => {
if (data && data.length) {
const array = Array.from(data);
return JSON.parse(JSON.stringify(array));
}
return [];
}
initSearchingAndroid = () => {
const { openSearchHeader, navigation } = this.props;
this.setState({ searching: true });
@ -441,26 +395,29 @@ export default class RoomsListView extends LoggedView {
} = this.props;
const id = item.rid.replace(userId, '').trim();
return (
<RoomItem
alert={item.alert}
unread={item.unread}
userMentions={item.userMentions}
favorite={item.f}
lastMessage={item.lastMessage}
name={this.getRoomTitle(item)}
_updatedAt={item.roomUpdatedAt}
key={item._id}
id={id}
type={item.t}
baseUrl={baseUrl}
prid={item.prid}
showLastMessage={StoreLastMessage}
onPress={() => this._onPressItem(item)}
testID={`rooms-list-view-item-${ item.name }`}
height={ROW_HEIGHT}
/>
);
if (item.search || (item.isValid && item.isValid())) {
return (
<RoomItem
alert={item.alert}
unread={item.unread}
userMentions={item.userMentions}
favorite={item.f}
lastMessage={JSON.parse(JSON.stringify(item.lastMessage))}
name={this.getRoomTitle(item)}
_updatedAt={item.roomUpdatedAt}
key={item._id}
id={id}
type={item.t}
baseUrl={baseUrl}
prid={item.prid}
showLastMessage={StoreLastMessage}
onPress={() => this._onPressItem(item)}
testID={`rooms-list-view-item-${ item.name }`}
height={ROW_HEIGHT}
/>
);
}
return null;
}
renderSectionHeader = header => (
@ -481,11 +438,10 @@ export default class RoomsListView extends LoggedView {
} else if (header === 'Chats' && groupByType) {
return null;
}
if (data.length > 0) {
if (data && data.length > 0) {
return (
<FlatList
data={data}
extraData={data}
keyExtractor={keyExtractor}
style={styles.list}
renderItem={this.renderItem}
@ -553,7 +509,6 @@ export default class RoomsListView extends LoggedView {
<FlatList
ref={this.getScrollRef}
data={search.length ? search : chats}
extraData={search.length ? search : chats}
contentOffset={isIOS ? { x: 0, y: SCROLL_OFFSET } : {}}
keyExtractor={keyExtractor}
style={styles.list}

View File

@ -5,8 +5,6 @@ import {
} from 'react-native';
import { connect } from 'react-redux';
import { SafeAreaView } from 'react-navigation';
import equal from 'deep-equal';
import EJSON from 'ejson';
import moment from 'moment';
import LoggedView from '../View';
@ -22,6 +20,7 @@ import log from '../../utils/log';
import debounce from '../../utils/debounce';
const Separator = React.memo(() => <View style={styles.separator} />);
const API_FETCH_COUNT = 50;
@connect(state => ({
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
@ -47,72 +46,136 @@ export default class ThreadMessagesView extends LoggedView {
super('ThreadMessagesView', props);
this.rid = props.navigation.getParam('rid');
this.t = props.navigation.getParam('t');
this.messages = database.objects('threads').filtered('rid = $0', this.rid);
this.rooms = database.objects('subscriptions').filtered('rid = $0', this.rid);
this.messages = database.objects('threads').filtered('rid = $0', this.rid).sorted('ts', true);
safeAddListener(this.messages, this.updateMessages);
this.state = {
loading: false,
messages: this.messages.slice(),
end: false,
total: 0
messages: this.messages
};
this.mounted = false;
}
componentDidMount() {
this.load();
this.mountInteraction = InteractionManager.runAfterInteractions(() => {
this.init();
this.mounted = true;
});
}
shouldComponentUpdate(nextProps, nextState) {
const { loading, messages, end } = this.state;
if (nextState.loading !== loading) {
return true;
componentWillUnmount() {
this.messages.removeAllListeners();
if (this.mountInteraction && this.mountInteraction.cancel) {
this.mountInteraction.cancel();
}
if (!equal(nextState.messages, messages)) {
return true;
if (this.loadInteraction && this.loadInteraction.cancel) {
this.loadInteraction.cancel();
}
if (!equal(nextState.end, end)) {
return true;
if (this.syncInteraction && this.syncInteraction.cancel) {
this.syncInteraction.cancel();
}
return false;
}
updateMessages = () => {
this.setState({ messages: this.messages.slice() });
// eslint-disable-next-line react/sort-comp
updateMessages = debounce(() => {
this.setState({ messages: this.messages });
}, 300)
init = () => {
const [room] = this.rooms;
const lastThreadSync = new Date();
if (room.lastThreadSync) {
this.sync(room.lastThreadSync);
} else {
this.load();
}
database.write(() => {
room.lastThreadSync = lastThreadSync;
});
}
// eslint-disable-next-line react/sort-comp
load = debounce(async() => {
const {
loading, end, total
} = this.state;
if (end || loading) {
const { loading, end } = this.state;
if (end || loading || !this.mounted) {
return;
}
this.setState({ loading: true });
try {
const result = await RocketChat.getThreadsList({ rid: this.rid, limit: 50, skip: total });
database.write(() => result.forEach((message) => {
try {
database.create('threads', buildMessage(EJSON.fromJSONValue(message)), true);
} catch (e) {
log('ThreadMessagesView -> load -> create', e);
}
}));
InteractionManager.runAfterInteractions(() => {
this.setState(prevState => ({
loading: false,
end: result.length < 50,
total: prevState.total + result.length
}));
const result = await RocketChat.getThreadsList({
rid: this.rid, count: API_FETCH_COUNT, offset: this.messages.length
});
if (result.success) {
this.loadInteraction = InteractionManager.runAfterInteractions(() => {
database.write(() => result.threads.forEach((message) => {
try {
database.create('threads', buildMessage(message), true);
} catch (e) {
log('ThreadMessagesView -> load -> create', e);
}
}));
this.setState({
loading: false,
end: result.count < API_FETCH_COUNT
});
});
}
} catch (error) {
console.log('ThreadMessagesView -> catch -> error', error);
console.log('ThreadMessagesView -> load -> error', error);
this.setState({ loading: false, end: true });
}
}, 300, true)
}, 300)
// eslint-disable-next-line react/sort-comp
sync = async(updatedSince) => {
this.setState({ loading: true });
try {
const result = await RocketChat.getSyncThreadsList({
rid: this.rid, updatedSince: updatedSince.toISOString()
});
if (result.success && result.threads) {
this.syncInteraction = InteractionManager.runAfterInteractions(() => {
const { update, remove } = result.threads;
database.write(() => {
if (update && update.length) {
update.forEach((message) => {
try {
database.create('threads', buildMessage(message), true);
} catch (e) {
log('ThreadMessagesView -> sync -> update', e);
}
});
}
if (remove && remove.length) {
remove.forEach((message) => {
const oldMessage = database.objectForPrimaryKey('threads', message._id);
if (oldMessage) {
try {
database.delete(oldMessage);
} catch (e) {
log('ThreadMessagesView -> sync -> delete', e);
}
}
});
}
});
this.setState({
loading: false
});
});
}
} catch (error) {
console.log('ThreadMessagesView -> sync -> error', error);
this.setState({ loading: false });
}
}
formatMessage = lm => (
lm ? moment(lm).calendar(null, {
@ -133,28 +196,31 @@ export default class ThreadMessagesView extends LoggedView {
renderItem = ({ item }) => {
const { user, navigation } = this.props;
return (
<Message
key={item._id}
item={item}
user={user}
archived={false}
broadcast={false}
status={item.status}
_updatedAt={item._updatedAt}
navigation={navigation}
customTimeFormat='MMM D'
customThreadTimeFormat='MMM Do YYYY, h:mm:ss a'
fetchThreadName={this.fetchThreadName}
onDiscussionPress={this.onDiscussionPress}
/>
);
if (item.isValid && item.isValid()) {
return (
<Message
key={item._id}
item={item}
user={user}
archived={false}
broadcast={false}
status={item.status}
_updatedAt={item._updatedAt}
navigation={navigation}
customTimeFormat='MMM D'
customThreadTimeFormat='MMM Do YYYY, h:mm:ss a'
fetchThreadName={this.fetchThreadName}
onDiscussionPress={this.onDiscussionPress}
/>
);
}
return null;
}
render() {
const { messages, loading } = this.state;
const { loading, messages } = this.state;
if (!loading && messages.length === 0) {
if (!loading && this.messages.length === 0) {
return this.renderEmpty();
}
@ -163,6 +229,7 @@ export default class ThreadMessagesView extends LoggedView {
<StatusBar />
<FlatList
data={messages}
extraData={this.state}
renderItem={this.renderItem}
style={styles.list}
contentContainerStyle={styles.contentContainer}

View File

@ -284,10 +284,10 @@ describe('Room screen', () => {
await element(by.text('Reply')).tap();
await element(by.id('messagebox-input')).typeText('replied');
await element(by.id('messagebox-send-message')).tap();
await waitFor(element(by.id(`message-thread-button-${ thread }`))).toBeVisible().withTimeout(5000);
await expect(element(by.id(`message-thread-button-${ thread }`))).toBeVisible();
await waitFor(element(by.id(`message-thread-replied-on-${ thread }`))).toBeVisible().withTimeout(5000);
await expect(element(by.id(`message-thread-replied-on-${ thread }`))).toBeVisible();
await waitFor(element(by.id(`message-thread-button-${ thread }`))).toExist().withTimeout(5000);
await expect(element(by.id(`message-thread-button-${ thread }`))).toExist();
await waitFor(element(by.id(`message-thread-replied-on-${ thread }`))).toExist().withTimeout(5000);
await expect(element(by.id(`message-thread-replied-on-${ thread }`))).toExist();
});
it('should navigate to thread from button', async() => {

View File

@ -263,7 +263,6 @@ export default (
header={false}
/>
{/* Legacy thread */}
<Separator title='Message with reply' />
<Message
msg="I'm fine!"
@ -290,6 +289,11 @@ export default (
tcount={1}
tlm={date}
/>
<Message
msg='How are you?'
tcount={9999}
tlm={date}
/>
<Message
msg="I'm fine!"
tmid='1'
@ -316,15 +320,15 @@ export default (
tmsg={longText}
/>
<Message
msg='How are you?'
tcount={0}
tlm={date}
/>
<Message
msg='How are you?'
tcount={9999}
tlm={date}
tmid='1'
tmsg='Thread with attachment'
attachments={[{
title: 'This is a title',
description: 'This is a description',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]}
/>
{/* <Message
msg='How are you?'
tcount={9999}