Message render performance (#880)

- Refactored Message component to use React.memo and re-render only what's necessary
- Added a test mode to toggle markdown parse by long press drawer (it'll be removed in the next release)
This commit is contained in:
Diego Mello 2019-05-20 17:43:50 -03:00 committed by GitHub
parent 31cf0e5f2f
commit 60418b75a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 18182 additions and 18052 deletions

File diff suppressed because it is too large Load Diff

View File

@ -40,13 +40,6 @@ export function setAllSettings(settings) {
}; };
} }
export function setCustomEmojis(emojis) {
return {
type: types.SET_CUSTOM_EMOJIS,
payload: emojis
};
}
export function login() { export function login() {
return { return {
type: 'LOGIN' type: 'LOGIN'

121
app/containers/FileModal.js Normal file
View File

@ -0,0 +1,121 @@
import React from 'react';
import {
View, Text, TouchableWithoutFeedback, ActivityIndicator, StyleSheet, SafeAreaView
} from 'react-native';
import FastImage from 'react-native-fast-image';
import PropTypes from 'prop-types';
import Modal from 'react-native-modal';
import ImageViewer from 'react-native-image-zoom-viewer';
import VideoPlayer from 'react-native-video-controls';
import sharedStyles from '../views/Styles';
import { COLOR_WHITE } from '../constants/colors';
import { formatAttachmentUrl } from '../lib/utils';
const styles = StyleSheet.create({
safeArea: {
flex: 1
},
modal: {
margin: 0
},
titleContainer: {
width: '100%',
alignItems: 'center',
marginVertical: 10
},
title: {
color: COLOR_WHITE,
textAlign: 'center',
fontSize: 16,
...sharedStyles.textSemibold
},
description: {
color: COLOR_WHITE,
textAlign: 'center',
fontSize: 14,
...sharedStyles.textMedium
},
indicator: {
flex: 1
}
});
const Indicator = React.memo(() => (
<ActivityIndicator style={styles.indicator} />
));
const ModalContent = React.memo(({
attachment, onClose, user, baseUrl
}) => {
if (attachment && attachment.image_url) {
const url = formatAttachmentUrl(attachment.image_url, user.id, user.token, baseUrl);
return (
<SafeAreaView style={styles.safeArea}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.titleContainer}>
<Text style={styles.title}>{attachment.title}</Text>
{attachment.description ? <Text style={styles.description}>{attachment.description}</Text> : null}
</View>
</TouchableWithoutFeedback>
<ImageViewer
imageUrls={[{ url }]}
onClick={onClose}
backgroundColor='transparent'
enableSwipeDown
onSwipeDown={onClose}
renderIndicator={() => null}
renderImage={props => <FastImage {...props} />}
loadingRender={() => <Indicator />}
/>
</SafeAreaView>
);
}
if (attachment && attachment.video_url) {
const uri = formatAttachmentUrl(attachment.video_url, user.id, user.token, baseUrl);
return (
<SafeAreaView style={styles.safeArea}>
<VideoPlayer
source={{ uri }}
onBack={onClose}
disableVolume
/>
</SafeAreaView>
);
}
return null;
});
const FileModal = React.memo(({
isVisible, onClose, attachment, user, baseUrl
}) => (
<Modal
style={styles.modal}
isVisible={isVisible}
onBackdropPress={onClose}
onBackButtonPress={onClose}
onSwipeComplete={onClose}
swipeDirection={['up', 'left', 'right', 'down']}
>
<ModalContent attachment={attachment} onClose={onClose} user={user} baseUrl={baseUrl} />
</Modal>
), (prevProps, nextProps) => prevProps.isVisible === nextProps.isVisible);
FileModal.propTypes = {
isVisible: PropTypes.bool,
attachment: PropTypes.object,
user: PropTypes.object,
baseUrl: PropTypes.string,
onClose: PropTypes.func
};
FileModal.displayName = 'FileModal';
ModalContent.propTypes = {
attachment: PropTypes.object,
user: PropTypes.object,
baseUrl: PropTypes.string,
onClose: PropTypes.func
};
ModalContent.displayName = 'FileModalContent';
export default FileModal;

View File

@ -20,9 +20,9 @@ export const CustomHeaderButtons = React.memo(props => (
/> />
)); ));
export const DrawerButton = React.memo(({ navigation, testID }) => ( export const DrawerButton = React.memo(({ navigation, testID, ...otherProps }) => (
<CustomHeaderButtons left> <CustomHeaderButtons left>
<Item title='drawer' iconName='customize' onPress={navigation.toggleDrawer} testID={testID} /> <Item title='drawer' iconName='customize' onPress={navigation.toggleDrawer} testID={testID} {...otherProps} />
</CustomHeaderButtons> </CustomHeaderButtons>
)); ));

View File

@ -5,6 +5,7 @@ import moment from 'moment';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Markdown from '../message/Markdown'; import Markdown from '../message/Markdown';
import { getCustomEmoji } from '../message/utils';
import { CustomIcon } from '../../lib/Icons'; import { CustomIcon } from '../../lib/Icons';
import sharedStyles from '../../views/Styles'; import sharedStyles from '../../views/Styles';
import { import {
@ -49,7 +50,6 @@ const styles = StyleSheet.create({
@connect(state => ({ @connect(state => ({
Message_TimeFormat: state.settings.Message_TimeFormat, Message_TimeFormat: state.settings.Message_TimeFormat,
customEmojis: state.customEmojis,
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '' baseUrl: state.settings.Site_Url || state.server ? state.server.server : ''
})) }))
export default class ReplyPreview extends Component { export default class ReplyPreview extends Component {
@ -57,7 +57,6 @@ export default class ReplyPreview extends Component {
message: PropTypes.object.isRequired, message: PropTypes.object.isRequired,
Message_TimeFormat: PropTypes.string.isRequired, Message_TimeFormat: PropTypes.string.isRequired,
close: PropTypes.func.isRequired, close: PropTypes.func.isRequired,
customEmojis: PropTypes.object.isRequired,
baseUrl: PropTypes.string.isRequired, baseUrl: PropTypes.string.isRequired,
username: PropTypes.string.isRequired username: PropTypes.string.isRequired
} }
@ -73,7 +72,7 @@ export default class ReplyPreview extends Component {
render() { render() {
const { const {
message, Message_TimeFormat, customEmojis, baseUrl, username message, Message_TimeFormat, baseUrl, username
} = this.props; } = this.props;
const time = moment(message.ts).format(Message_TimeFormat); const time = moment(message.ts).format(Message_TimeFormat);
return ( return (
@ -83,7 +82,7 @@ export default class ReplyPreview extends Component {
<Text style={styles.username}>{message.u.username}</Text> <Text style={styles.username}>{message.u.username}</Text>
<Text style={styles.time}>{time}</Text> <Text style={styles.time}>{time}</Text>
</View> </View>
<Markdown msg={message.msg} customEmojis={customEmojis} baseUrl={baseUrl} username={username} /> <Markdown msg={message.msg} baseUrl={baseUrl} username={username} getCustomEmoji={getCustomEmoji} />
</View> </View>
<CustomIcon name='cross' color={COLOR_TEXT_DESCRIPTION} size={20} style={styles.close} onPress={this.close} /> <CustomIcon name='cross' color={COLOR_TEXT_DESCRIPTION} size={20} style={styles.close} onPress={this.close} />
</View> </View>

View File

@ -179,6 +179,7 @@ export default class UploadModal extends Component {
animationOut='fadeOut' animationOut='fadeOut'
useNativeDriver useNativeDriver
hideModalContentWhileAnimating hideModalContentWhileAnimating
avoidKeyboard
> >
<View style={[styles.container, { width: width - 32 }]}> <View style={[styles.container, { width: width - 32 }]}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>

View File

@ -0,0 +1,151 @@
import React from 'react';
import {
View, Text, FlatList, StyleSheet, SafeAreaView
} from 'react-native';
import PropTypes from 'prop-types';
import Modal from 'react-native-modal';
import Touchable from 'react-native-platform-touchable';
import Emoji from './message/Emoji';
import I18n from '../i18n';
import { CustomIcon } from '../lib/Icons';
import sharedStyles from '../views/Styles';
import { COLOR_WHITE } from '../constants/colors';
const styles = StyleSheet.create({
titleContainer: {
alignItems: 'center',
paddingVertical: 10
},
title: {
color: COLOR_WHITE,
textAlign: 'center',
fontSize: 16,
...sharedStyles.textSemibold
},
reactCount: {
color: COLOR_WHITE,
fontSize: 13,
...sharedStyles.textRegular
},
peopleReacted: {
color: COLOR_WHITE,
fontSize: 14,
...sharedStyles.textMedium
},
peopleItemContainer: {
flex: 1,
flexDirection: 'column',
justifyContent: 'center'
},
emojiContainer: {
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center'
},
itemContainer: {
height: 50,
flexDirection: 'row'
},
listContainer: {
flex: 1
},
closeButton: {
position: 'absolute',
left: 0,
top: 10,
color: COLOR_WHITE
}
});
const standardEmojiStyle = { fontSize: 20 };
const customEmojiStyle = { width: 20, height: 20 };
const Item = React.memo(({ item, user, baseUrl }) => {
const count = item.usernames.length;
let usernames = item.usernames.slice(0, 3)
.map(username => (username === user.username ? I18n.t('you') : username)).join(', ');
if (count > 3) {
usernames = `${ usernames } ${ I18n.t('and_more') } ${ count - 3 }`;
} else {
usernames = usernames.replace(/,(?=[^,]*$)/, ` ${ I18n.t('and') }`);
}
return (
<View style={styles.itemContainer}>
<View style={styles.emojiContainer}>
<Emoji
content={item.emoji}
standardEmojiStyle={standardEmojiStyle}
customEmojiStyle={customEmojiStyle}
baseUrl={baseUrl}
/>
</View>
<View style={styles.peopleItemContainer}>
<Text style={styles.reactCount}>
{count === 1 ? I18n.t('1_person_reacted') : I18n.t('N_people_reacted', { n: count })}
</Text>
<Text style={styles.peopleReacted}>{ usernames }</Text>
</View>
</View>
);
});
const ModalContent = React.memo(({ message, onClose, ...props }) => {
if (message && message.reactions) {
return (
<SafeAreaView style={{ flex: 1 }}>
<Touchable onPress={onClose}>
<View style={styles.titleContainer}>
<CustomIcon
style={styles.closeButton}
name='cross'
size={20}
/>
<Text style={styles.title}>{I18n.t('Reactions')}</Text>
</View>
</Touchable>
<FlatList
style={styles.listContainer}
data={message.reactions}
renderItem={({ item }) => <Item item={item} {...props} />}
keyExtractor={item => item.emoji}
/>
</SafeAreaView>
);
}
return null;
});
const ReactionsModal = React.memo(({ isVisible, onClose, ...props }) => (
<Modal
isVisible={isVisible}
onBackdropPress={onClose}
onBackButtonPress={onClose}
backdropOpacity={0.8}
onSwipeComplete={onClose}
swipeDirection={['up', 'left', 'right', 'down']}
>
<ModalContent onClose={onClose} {...props} />
</Modal>
), (prevProps, nextProps) => prevProps.isVisible === nextProps.isVisible);
ReactionsModal.propTypes = {
isVisible: PropTypes.bool,
onClose: PropTypes.func
};
ReactionsModal.displayName = 'ReactionsModal';
ModalContent.propTypes = {
message: PropTypes.object,
onClose: PropTypes.func
};
ModalContent.displayName = 'ReactionsModalContent';
Item.propTypes = {
item: PropTypes.object,
user: PropTypes.object,
baseUrl: PropTypes.string
};
Item.displayName = 'ReactionsModalItem';
export default ReactionsModal;

View File

@ -0,0 +1,44 @@
import React from 'react';
import isEqual from 'lodash/isEqual';
import PropTypes from 'prop-types';
import Image from './Image';
import Audio from './Audio';
import Video from './Video';
import Reply from './Reply';
const Attachments = React.memo(({
attachments, timeFormat, user, baseUrl, useMarkdown, onOpenFileModal, getCustomEmoji
}) => {
if (!attachments || attachments.length === 0) {
return null;
}
return attachments.map((file, index) => {
if (file.image_url) {
return <Image key={file.image_url} file={file} user={user} baseUrl={baseUrl} onOpenFileModal={onOpenFileModal} getCustomEmoji={getCustomEmoji} useMarkdown={useMarkdown} />;
}
if (file.audio_url) {
return <Audio key={file.audio_url} file={file} user={user} baseUrl={baseUrl} getCustomEmoji={getCustomEmoji} useMarkdown={useMarkdown} />;
}
if (file.video_url) {
return <Video key={file.video_url} file={file} user={user} baseUrl={baseUrl} onOpenFileModal={onOpenFileModal} getCustomEmoji={getCustomEmoji} useMarkdown={useMarkdown} />;
}
// eslint-disable-next-line react/no-array-index-key
return <Reply key={index} index={index} attachment={file} timeFormat={timeFormat} user={user} baseUrl={baseUrl} getCustomEmoji={getCustomEmoji} useMarkdown={useMarkdown} />;
});
}, (prevProps, nextProps) => isEqual(prevProps.attachments, nextProps.attachments));
Attachments.propTypes = {
attachments: PropTypes.array,
timeFormat: PropTypes.string,
user: PropTypes.object,
baseUrl: PropTypes.string,
useMarkdown: PropTypes.bool,
onOpenFileModal: PropTypes.func,
getCustomEmoji: PropTypes.func
};
Attachments.displayName = 'MessageAttachments';
export default Attachments;

View File

@ -56,20 +56,40 @@ const formatTime = seconds => moment.utc(seconds * 1000).format('mm:ss');
const BUTTON_HIT_SLOP = { const BUTTON_HIT_SLOP = {
top: 12, right: 12, bottom: 12, left: 12 top: 12, right: 12, bottom: 12, left: 12
}; };
const sliderAnimationConfig = {
duration: 250,
easing: Easing.linear,
delay: 0
};
const Button = React.memo(({ paused, onPress }) => (
<Touchable
style={styles.playPauseButton}
onPress={onPress}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
<CustomIcon name={paused ? 'play' : 'pause'} size={36} style={styles.playPauseImage} />
</Touchable>
));
Button.propTypes = {
paused: PropTypes.bool,
onPress: PropTypes.func
};
Button.displayName = 'MessageAudioButton';
export default class Audio extends React.Component { export default class Audio extends React.Component {
static propTypes = { static propTypes = {
file: PropTypes.object.isRequired, file: PropTypes.object.isRequired,
baseUrl: PropTypes.string.isRequired, baseUrl: PropTypes.string.isRequired,
user: PropTypes.object.isRequired, user: PropTypes.object.isRequired,
customEmojis: PropTypes.object.isRequired useMarkdown: PropTypes.bool,
getCustomEmoji: PropTypes.func
} }
constructor(props) { constructor(props) {
super(props); super(props);
this.onLoad = this.onLoad.bind(this);
this.onProgress = this.onProgress.bind(this);
this.onEnd = this.onEnd.bind(this);
const { baseUrl, file, user } = props; const { baseUrl, file, user } = props;
this.state = { this.state = {
currentTime: 0, currentTime: 0,
@ -120,22 +140,26 @@ export default class Audio extends React.Component {
}); });
} }
getDuration = () => { get duration() {
const { duration } = this.state; const { duration } = this.state;
return formatTime(duration); return formatTime(duration);
} }
setRef = ref => this.player = ref;
togglePlayPause = () => { togglePlayPause = () => {
const { paused } = this.state; const { paused } = this.state;
this.setState({ paused: !paused }); this.setState({ paused: !paused });
} }
onValueChange = value => this.setState({ currentTime: value });
render() { render() {
const { const {
uri, paused, currentTime, duration uri, paused, currentTime, duration
} = this.state; } = this.state;
const { const {
user, baseUrl, customEmojis, file user, baseUrl, file, getCustomEmoji, useMarkdown
} = this.props; } = this.props;
const { description } = file; const { description } = file;
@ -144,12 +168,10 @@ export default class Audio extends React.Component {
} }
return ( return (
[ <React.Fragment>
<View key='audio' style={styles.audioContainer}> <View style={styles.audioContainer}>
<Video <Video
ref={(ref) => { ref={this.setRef}
this.player = ref;
}}
source={{ uri }} source={{ uri }}
onLoad={this.onLoad} onLoad={this.onLoad}
onProgress={this.onProgress} onProgress={this.onProgress}
@ -157,39 +179,24 @@ export default class Audio extends React.Component {
paused={paused} paused={paused}
repeat={false} repeat={false}
/> />
<Touchable <Button paused={paused} onPress={this.togglePlayPause} />
style={styles.playPauseButton}
onPress={this.togglePlayPause}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
{
paused
? <CustomIcon name='play' size={36} style={styles.playPauseImage} />
: <CustomIcon name='pause' size={36} style={styles.playPauseImage} />
}
</Touchable>
<Slider <Slider
style={styles.slider} style={styles.slider}
value={currentTime} value={currentTime}
maximumValue={duration} maximumValue={duration}
minimumValue={0} minimumValue={0}
animateTransitions animateTransitions
animationConfig={{ animationConfig={sliderAnimationConfig}
duration: 250,
easing: Easing.linear,
delay: 0
}}
thumbTintColor={COLOR_PRIMARY} thumbTintColor={COLOR_PRIMARY}
minimumTrackTintColor={COLOR_PRIMARY} minimumTrackTintColor={COLOR_PRIMARY}
onValueChange={value => this.setState({ currentTime: value })} onValueChange={this.onValueChange}
thumbStyle={styles.thumbStyle} thumbStyle={styles.thumbStyle}
trackStyle={styles.trackStyle} trackStyle={styles.trackStyle}
/> />
<Text style={styles.duration}>{this.getDuration()}</Text> <Text style={styles.duration}>{this.duration}</Text>
</View>, </View>
<Markdown key='description' msg={description} baseUrl={baseUrl} customEmojis={customEmojis} username={user.username} /> <Markdown msg={description} baseUrl={baseUrl} username={user.username} getCustomEmoji={getCustomEmoji} useMarkdown={useMarkdown} />
] </React.Fragment>
); );
} }
} }

View File

@ -0,0 +1,43 @@
import React from 'react';
import { View, Text } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import PropTypes from 'prop-types';
import { CustomIcon } from '../../lib/Icons';
import styles from './styles';
import { BUTTON_HIT_SLOP } from './utils';
import I18n from '../../i18n';
const Broadcast = React.memo(({
author, user, broadcast, replyBroadcast
}) => {
const isOwn = author._id === user.id;
if (broadcast && !isOwn) {
return (
<View style={styles.buttonContainer}>
<Touchable
onPress={replyBroadcast}
background={Touchable.Ripple('#fff')}
style={styles.button}
hitSlop={BUTTON_HIT_SLOP}
>
<React.Fragment>
<CustomIcon name='back' size={20} style={styles.buttonIcon} />
<Text style={styles.buttonText}>{I18n.t('Reply')}</Text>
</React.Fragment>
</Touchable>
</View>
);
}
return null;
}, () => true);
Broadcast.propTypes = {
author: PropTypes.object,
user: PropTypes.object,
broadcast: PropTypes.bool,
replyBroadcast: PropTypes.func
};
Broadcast.displayName = 'MessageBroadcast';
export default Broadcast;

View File

@ -0,0 +1,48 @@
import React from 'react';
import { Text } from 'react-native';
import PropTypes from 'prop-types';
import I18n from '../../i18n';
import styles from './styles';
import Markdown from './Markdown';
import { getInfoMessage } from './utils';
const Content = React.memo((props) => {
if (props.isInfo) {
return <Text style={styles.textInfo}>{getInfoMessage({ ...props })}</Text>;
}
if (props.tmid && !props.msg) {
return <Text style={styles.text}>{I18n.t('Sent_an_attachment')}</Text>;
}
return (
<Markdown
msg={props.msg}
baseUrl={props.baseUrl}
username={props.user.username}
isEdited={props.isEdited}
mentions={props.mentions}
channels={props.channels}
numberOfLines={props.tmid ? 1 : 0}
getCustomEmoji={props.getCustomEmoji}
useMarkdown={props.useMarkdown}
/>
);
}, (prevProps, nextProps) => prevProps.msg === nextProps.msg);
Content.propTypes = {
isInfo: PropTypes.bool,
isEdited: PropTypes.bool,
useMarkdown: PropTypes.bool,
tmid: PropTypes.string,
msg: PropTypes.string,
baseUrl: PropTypes.string,
user: PropTypes.object,
mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
getCustomEmoji: PropTypes.func
};
Content.displayName = 'MessageContent';
export default Content;

View File

@ -0,0 +1,58 @@
import React from 'react';
import { View, Text } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import PropTypes from 'prop-types';
import { formatLastMessage, formatMessageCount, BUTTON_HIT_SLOP } from './utils';
import styles from './styles';
import I18n from '../../i18n';
import { CustomIcon } from '../../lib/Icons';
import { DISCUSSION } from './constants';
const Discussion = React.memo(({
msg, dcount, dlm, onDiscussionPress
}) => {
const time = formatLastMessage(dlm);
const buttonText = formatMessageCount(dcount, DISCUSSION);
return (
<React.Fragment>
<Text style={styles.startedDiscussion}>{I18n.t('Started_discussion')}</Text>
<Text style={styles.text}>{msg}</Text>
<View style={styles.buttonContainer}>
<Touchable
onPress={onDiscussionPress}
background={Touchable.Ripple('#fff')}
style={[styles.button, styles.smallButton]}
hitSlop={BUTTON_HIT_SLOP}
>
<React.Fragment>
<CustomIcon name='chat' size={20} style={styles.buttonIcon} />
<Text style={styles.buttonText}>{buttonText}</Text>
</React.Fragment>
</Touchable>
<Text style={styles.time}>{time}</Text>
</View>
</React.Fragment>
);
}, (prevProps, nextProps) => {
if (prevProps.msg !== nextProps.msg) {
return false;
}
if (prevProps.dcount !== nextProps.dcount) {
return false;
}
if (prevProps.dlm !== nextProps.dlm) {
return false;
}
return true;
});
Discussion.propTypes = {
msg: PropTypes.string,
dcount: PropTypes.number,
dlm: PropTypes.string,
onDiscussionPress: PropTypes.func
};
Discussion.displayName = 'MessageDiscussion';
export default Discussion;

View File

@ -1,31 +1,28 @@
import React from 'react'; import React from 'react';
import { Text, ViewPropTypes } from 'react-native'; import { Text } from 'react-native';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { emojify } from 'react-emojione'; import { emojify } from 'react-emojione';
import CustomEmoji from '../EmojiPicker/CustomEmoji'; import CustomEmoji from '../EmojiPicker/CustomEmoji';
export default class Emoji extends React.PureComponent { const Emoji = React.memo(({
static propTypes = { content, standardEmojiStyle, customEmojiStyle, baseUrl, getCustomEmoji
content: PropTypes.string.isRequired, }) => {
baseUrl: PropTypes.string.isRequired, const parsedContent = content.replace(/^:|:$/g, '');
standardEmojiStyle: Text.propTypes.style, const emoji = getCustomEmoji(parsedContent);
customEmojiStyle: ViewPropTypes.style, if (emoji) {
customEmojis: PropTypes.oneOfType([ return <CustomEmoji key={content} baseUrl={baseUrl} style={customEmojiStyle} emoji={emoji} />;
PropTypes.array,
PropTypes.object
])
} }
return <Text style={standardEmojiStyle}>{ emojify(content, { output: 'unicode' }) }</Text>;
}, () => true);
render() { Emoji.propTypes = {
const { content: PropTypes.string,
content, standardEmojiStyle, customEmojiStyle, customEmojis, baseUrl standardEmojiStyle: PropTypes.object,
} = this.props; customEmojiStyle: PropTypes.object,
const parsedContent = content.replace(/^:|:$/g, ''); baseUrl: PropTypes.string,
const emojiExtension = customEmojis[parsedContent]; getCustomEmoji: PropTypes.func
if (emojiExtension) { };
const emoji = { extension: emojiExtension, content: parsedContent }; Emoji.displayName = 'MessageEmoji';
return <CustomEmoji key={content} baseUrl={baseUrl} style={customEmojiStyle} emoji={emoji} />;
} export default Emoji;
return <Text style={standardEmojiStyle}>{ emojify(`${ content }`, { output: 'unicode' }) }</Text>;
}
}

View File

@ -1,95 +1,79 @@
import React, { Component } from 'react'; import React from 'react';
import { View } from 'react-native';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import FastImage from 'react-native-fast-image'; import FastImage from 'react-native-fast-image';
import equal from 'deep-equal'; import equal from 'deep-equal';
import Touchable from 'react-native-platform-touchable'; import Touchable from 'react-native-platform-touchable';
import PhotoModal from './PhotoModal';
import Markdown from './Markdown'; import Markdown from './Markdown';
import styles from './styles'; import styles from './styles';
import { formatAttachmentUrl } from '../../lib/utils';
export default class extends Component { const Button = React.memo(({ children, onPress }) => (
static propTypes = { <Touchable
file: PropTypes.object.isRequired, onPress={onPress}
baseUrl: PropTypes.string.isRequired, style={styles.imageContainer}
user: PropTypes.object.isRequired, background={Touchable.Ripple('#fff')}
customEmojis: PropTypes.oneOfType([ >
PropTypes.array, {children}
PropTypes.object </Touchable>
]) ));
const Image = React.memo(({ img }) => (
<FastImage
style={styles.image}
source={{ uri: encodeURI(img) }}
resizeMode={FastImage.resizeMode.cover}
/>
));
const ImageContainer = React.memo(({
file, baseUrl, user, useMarkdown, onOpenFileModal, getCustomEmoji
}) => {
const img = formatAttachmentUrl(file.image_url, user.id, user.token, baseUrl);
if (!img) {
return null;
} }
state = { modalVisible: false, isPressed: false }; const onPress = () => onOpenFileModal(file);
shouldComponentUpdate(nextProps, nextState) {
const { modalVisible, isPressed } = this.state;
const { file } = this.props;
if (nextState.modalVisible !== modalVisible) {
return true;
}
if (nextState.isPressed !== isPressed) {
return true;
}
if (!equal(nextProps.file, file)) {
return true;
}
return false;
}
onPressButton = () => {
this.setState({
modalVisible: true
});
}
getDescription() {
const {
file, customEmojis, baseUrl, user
} = this.props;
if (file.description) {
return <Markdown msg={file.description} customEmojis={customEmojis} baseUrl={baseUrl} username={user.username} />;
}
}
isPressed = (state) => {
this.setState({ isPressed: state });
}
render() {
const { modalVisible, isPressed } = this.state;
const { baseUrl, file, user } = this.props;
const img = file.image_url.includes('http') ? file.image_url : `${ baseUrl }${ file.image_url }?rc_uid=${ user.id }&rc_token=${ user.token }`;
if (!img) {
return null;
}
if (file.description) {
return ( return (
[ <Button onPress={onPress}>
<Touchable <View>
key='image' <Image img={img} />
onPress={this.onPressButton} <Markdown msg={file.description} baseUrl={baseUrl} username={user.username} getCustomEmoji={getCustomEmoji} useMarkdown={useMarkdown} />
style={styles.imageContainer} </View>
background={Touchable.Ripple('#fff')} </Button>
>
<React.Fragment>
<FastImage
style={[styles.image, isPressed && { opacity: 0.5 }]}
source={{ uri: encodeURI(img) }}
resizeMode={FastImage.resizeMode.cover}
/>
{this.getDescription()}
</React.Fragment>
</Touchable>,
<PhotoModal
key='modal'
title={file.title}
description={file.description}
image={img}
isVisible={modalVisible}
onClose={() => this.setState({ modalVisible: false })}
/>
]
); );
} }
}
return (
<Button onPress={onPress}>
<Image img={img} />
</Button>
);
}, (prevProps, nextProps) => equal(prevProps.file, nextProps.file));
ImageContainer.propTypes = {
file: PropTypes.object,
baseUrl: PropTypes.string,
user: PropTypes.object,
useMarkdown: PropTypes.bool,
onOpenFileModal: PropTypes.func,
getCustomEmoji: PropTypes.func
};
ImageContainer.displayName = 'MessageImageContainer';
Image.propTypes = {
img: PropTypes.string
};
ImageContainer.displayName = 'MessageImage';
Button.propTypes = {
children: PropTypes.node,
onPress: PropTypes.func
};
ImageContainer.displayName = 'MessageButton';
export default ImageContainer;

View File

@ -4,9 +4,15 @@ import PropTypes from 'prop-types';
import { emojify } from 'react-emojione'; import { emojify } from 'react-emojione';
import MarkdownRenderer, { PluginContainer } from 'react-native-markdown-renderer'; import MarkdownRenderer, { PluginContainer } from 'react-native-markdown-renderer';
import MarkdownFlowdock from 'markdown-it-flowdock'; import MarkdownFlowdock from 'markdown-it-flowdock';
import styles from './styles'; import styles from './styles';
import CustomEmoji from '../EmojiPicker/CustomEmoji'; import CustomEmoji from '../EmojiPicker/CustomEmoji';
import MarkdownEmojiPlugin from './MarkdownEmojiPlugin'; import MarkdownEmojiPlugin from './MarkdownEmojiPlugin';
import I18n from '../../i18n';
const EmojiPlugin = new PluginContainer(MarkdownEmojiPlugin);
const MentionsPlugin = new PluginContainer(MarkdownFlowdock);
const plugins = [EmojiPlugin, MentionsPlugin];
// Support <http://link|Text> // Support <http://link|Text>
const formatText = text => text.replace( const formatText = text => text.replace(
@ -15,7 +21,7 @@ const formatText = text => text.replace(
); );
const Markdown = React.memo(({ const Markdown = React.memo(({
msg, customEmojis, style, rules, baseUrl, username, edited, numberOfLines msg, style, rules, baseUrl, username, isEdited, numberOfLines, mentions, channels, getCustomEmoji, useMarkdown = true
}) => { }) => {
if (!msg) { if (!msg) {
return null; return null;
@ -28,14 +34,18 @@ const Markdown = React.memo(({
if (numberOfLines > 0) { if (numberOfLines > 0) {
m = m.replace(/[\n]+/g, '\n').trim(); m = m.replace(/[\n]+/g, '\n').trim();
} }
if (!useMarkdown) {
return <Text style={styles.text}>{m}</Text>;
}
return ( return (
<MarkdownRenderer <MarkdownRenderer
rules={{ rules={{
paragraph: (node, children) => ( paragraph: (node, children) => (
// eslint-disable-next-line
<Text key={node.key} style={styles.paragraph} numberOfLines={numberOfLines}> <Text key={node.key} style={styles.paragraph} numberOfLines={numberOfLines}>
{children} {children}
{edited ? <Text style={styles.edited}> (edited)</Text> : null} {isEdited ? <Text style={styles.edited}> ({I18n.t('edited')})</Text> : null}
</Text> </Text>
), ),
mention: (node) => { mention: (node) => {
@ -52,23 +62,31 @@ const Markdown = React.memo(({
...styles.mentionLoggedUser ...styles.mentionLoggedUser
}; };
} }
return ( if (mentions && mentions.length && mentions.findIndex(mention => mention.username === content) !== -1) {
<Text style={mentionStyle} key={key}> return (
&nbsp;{content}&nbsp; <Text style={mentionStyle} key={key}>
</Text> &nbsp;{content}&nbsp;
); </Text>
);
}
return `@${ content }`;
},
hashtag: (node) => {
const { content, key } = node;
if (channels && channels.length && channels.findIndex(channel => channel.name === content) !== -1) {
return (
<Text key={key} style={styles.mention}>
&nbsp;#{content}&nbsp;
</Text>
);
}
return `#${ content }`;
}, },
hashtag: node => (
<Text key={node.key} style={styles.mention}>
&nbsp;#{node.content}&nbsp;
</Text>
),
emoji: (node) => { emoji: (node) => {
if (node.children && node.children.length && node.children[0].content) { if (node.children && node.children.length && node.children[0].content) {
const { content } = node.children[0]; const { content } = node.children[0];
const emojiExtension = customEmojis[content]; const emoji = getCustomEmoji && getCustomEmoji(content);
if (emojiExtension) { if (emoji) {
const emoji = { extension: emojiExtension, content };
return <CustomEmoji key={node.key} baseUrl={baseUrl} style={styles.customEmoji} emoji={emoji} />; return <CustomEmoji key={node.key} baseUrl={baseUrl} style={styles.customEmoji} emoji={emoji} />;
} }
return <Text key={node.key}>:{content}:</Text>; return <Text key={node.key}>:{content}:</Text>;
@ -90,10 +108,7 @@ const Markdown = React.memo(({
link: styles.link, link: styles.link,
...style ...style
}} }}
plugins={[ plugins={plugins}
new PluginContainer(MarkdownFlowdock),
new PluginContainer(MarkdownEmojiPlugin)
]}
>{m} >{m}
</MarkdownRenderer> </MarkdownRenderer>
); );
@ -101,13 +116,17 @@ const Markdown = React.memo(({
Markdown.propTypes = { Markdown.propTypes = {
msg: PropTypes.string, msg: PropTypes.string,
username: PropTypes.string.isRequired, username: PropTypes.string,
baseUrl: PropTypes.string.isRequired, baseUrl: PropTypes.string,
customEmojis: PropTypes.object.isRequired,
style: PropTypes.any, style: PropTypes.any,
rules: PropTypes.object, rules: PropTypes.object,
edited: PropTypes.bool, isEdited: PropTypes.bool,
numberOfLines: PropTypes.number numberOfLines: PropTypes.number,
useMarkdown: PropTypes.bool,
mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
getCustomEmoji: PropTypes.func
}; };
Markdown.displayName = 'MessageMarkdown';
export default Markdown; export default Markdown;

View File

@ -1,609 +1,133 @@
import React, { PureComponent } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import { View } from 'react-native';
View, Text, ViewPropTypes, TouchableWithoutFeedback
} from 'react-native';
import moment from 'moment';
import { KeyboardUtils } from 'react-native-keyboard-input';
import Touchable from 'react-native-platform-touchable'; import Touchable from 'react-native-platform-touchable';
import { emojify } from 'react-emojione';
import removeMarkdown from 'remove-markdown';
import Image from './Image';
import User from './User'; import User from './User';
import Avatar from '../Avatar'; import MessageError from './MessageError';
import Audio from './Audio';
import Video from './Video';
import Markdown from './Markdown';
import Url from './Url';
import Reply from './Reply';
import ReactionsModal from './ReactionsModal';
import Emoji from './Emoji';
import styles from './styles'; import styles from './styles';
import I18n from '../../i18n';
import messagesStatus from '../../constants/messagesStatus';
import { CustomIcon } from '../../lib/Icons';
import { COLOR_DANGER } from '../../constants/colors';
import debounce from '../../utils/debounce';
import DisclosureIndicator from '../DisclosureIndicator';
import sharedStyles from '../../views/Styles'; import sharedStyles from '../../views/Styles';
import RepliedThread from './RepliedThread';
import MessageAvatar from './MessageAvatar';
import Attachments from './Attachments';
import Urls from './Urls';
import Thread from './Thread';
import Reactions from './Reactions';
import Broadcast from './Broadcast';
import Discussion from './Discussion';
import Content from './Content';
const SYSTEM_MESSAGES = [ const MessageInner = React.memo((props) => {
'r', if (props.type === 'discussion-created') {
'au',
'ru',
'ul',
'uj',
'ut',
'rm',
'user-muted',
'user-unmuted',
'message_pinned',
'subscription-role-added',
'subscription-role-removed',
'room_changed_description',
'room_changed_announcement',
'room_changed_topic',
'room_changed_privacy',
'message_snippeted',
'thread-created'
];
const getInfoMessage = ({
type, role, msg, author
}) => {
const { username } = author;
if (type === 'rm') {
return I18n.t('Message_removed');
} else if (type === 'uj') {
return I18n.t('Has_joined_the_channel');
} else if (type === 'ut') {
return I18n.t('Has_joined_the_conversation');
} else if (type === 'r') {
return I18n.t('Room_name_changed', { name: msg, userBy: username });
} else if (type === 'message_pinned') {
return I18n.t('Message_pinned');
} else if (type === 'ul') {
return I18n.t('Has_left_the_channel');
} else if (type === 'ru') {
return I18n.t('User_removed_by', { userRemoved: msg, userBy: username });
} else if (type === 'au') {
return I18n.t('User_added_by', { userAdded: msg, userBy: username });
} else if (type === 'user-muted') {
return I18n.t('User_muted_by', { userMuted: msg, userBy: username });
} else if (type === 'user-unmuted') {
return I18n.t('User_unmuted_by', { userUnmuted: msg, userBy: username });
} else if (type === 'subscription-role-added') {
return `${ msg } was set ${ role } by ${ username }`;
} else if (type === 'subscription-role-removed') {
return `${ msg } is no longer ${ role } by ${ username }`;
} else if (type === 'room_changed_description') {
return I18n.t('Room_changed_description', { description: msg, userBy: username });
} else if (type === 'room_changed_announcement') {
return I18n.t('Room_changed_announcement', { announcement: msg, userBy: username });
} else if (type === 'room_changed_topic') {
return I18n.t('Room_changed_topic', { topic: msg, userBy: username });
} else if (type === 'room_changed_privacy') {
return I18n.t('Room_changed_privacy', { type: msg, userBy: username });
} else if (type === 'message_snippeted') {
return I18n.t('Created_snippet');
}
return '';
};
const BUTTON_HIT_SLOP = {
top: 4, right: 4, bottom: 4, left: 4
};
export default class Message extends PureComponent {
static propTypes = {
baseUrl: PropTypes.string.isRequired,
customEmojis: PropTypes.object.isRequired,
timeFormat: PropTypes.string.isRequired,
customThreadTimeFormat: PropTypes.string,
msg: PropTypes.string,
user: PropTypes.shape({
id: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
token: PropTypes.string.isRequired
}),
author: PropTypes.shape({
_id: PropTypes.string.isRequired,
username: PropTypes.string.isRequired,
name: PropTypes.string
}),
status: PropTypes.any,
reactions: PropTypes.any,
editing: PropTypes.bool,
style: ViewPropTypes.style,
archived: PropTypes.bool,
broadcast: PropTypes.bool,
reactionsModal: PropTypes.bool,
type: PropTypes.string,
header: PropTypes.bool,
isThreadReply: PropTypes.bool,
isThreadSequential: PropTypes.bool,
avatar: PropTypes.string,
alias: PropTypes.string,
ts: PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string
]),
edited: PropTypes.bool,
attachments: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object
]),
urls: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object
]),
useRealName: PropTypes.bool,
dcount: PropTypes.number,
dlm: PropTypes.instanceOf(Date),
tmid: PropTypes.string,
tcount: PropTypes.number,
tlm: PropTypes.instanceOf(Date),
tmsg: PropTypes.string,
// methods
closeReactions: PropTypes.func,
onErrorPress: PropTypes.func,
onLongPress: PropTypes.func,
onReactionLongPress: PropTypes.func,
onReactionPress: PropTypes.func,
onDiscussionPress: PropTypes.func,
onThreadPress: PropTypes.func,
replyBroadcast: PropTypes.func,
toggleReactionPicker: PropTypes.func,
fetchThreadName: PropTypes.func
}
static defaultProps = {
archived: false,
broadcast: false,
attachments: [],
urls: [],
reactions: [],
onLongPress: () => {}
}
onPress = debounce(() => {
KeyboardUtils.dismiss();
const { onThreadPress, tlm, tmid } = this.props;
if ((tlm || tmid) && onThreadPress) {
onThreadPress();
}
}, 300, true)
onLongPress = () => {
const { archived, onLongPress } = this.props;
if (this.isInfoMessage() || this.hasError() || archived) {
return;
}
onLongPress();
}
formatLastMessage = (lm) => {
const { customThreadTimeFormat } = this.props;
if (customThreadTimeFormat) {
return moment(lm).format(customThreadTimeFormat);
}
return lm ? moment(lm).calendar(null, {
lastDay: `[${ I18n.t('Yesterday') }]`,
sameDay: 'h:mm A',
lastWeek: 'dddd',
sameElse: 'MMM D'
}) : null;
}
formatMessageCount = (count, type) => {
const discussion = type === 'discussion';
let text = discussion ? I18n.t('No_messages_yet') : null;
if (count === 1) {
text = `${ count } ${ discussion ? I18n.t('message') : I18n.t('reply') }`;
} else if (count > 1 && count < 1000) {
text = `${ count } ${ discussion ? I18n.t('messages') : I18n.t('replies') }`;
} else if (count > 999) {
text = `+999 ${ discussion ? I18n.t('messages') : I18n.t('replies') }`;
}
return text;
}
isInfoMessage = () => {
const { type } = this.props;
return SYSTEM_MESSAGES.includes(type);
}
isOwn = () => {
const { author, user } = this.props;
return author._id === user.id;
}
isDeleted() {
const { type } = this.props;
return type === 'rm';
}
isTemp() {
const { status } = this.props;
return status === messagesStatus.TEMP || status === messagesStatus.ERROR;
}
hasError() {
const { status } = this.props;
return status === messagesStatus.ERROR;
}
renderAvatar = (small = false) => {
const {
header, avatar, author, baseUrl, user
} = this.props;
if (header) {
return (
<Avatar
style={small ? styles.avatarSmall : styles.avatar}
text={avatar ? '' : author.username}
size={small ? 20 : 36}
borderRadius={small ? 2 : 4}
avatar={avatar}
baseUrl={baseUrl}
userId={user.id}
token={user.token}
/>
);
}
return null;
}
renderUsername = () => {
const {
header, timeFormat, author, alias, ts, useRealName
} = this.props;
if (header) {
return (
<User
onPress={this.onPress}
timeFormat={timeFormat}
username={(useRealName && author.name) || author.username}
alias={alias}
ts={ts}
temp={this.isTemp()}
/>
);
}
return null;
}
renderContent() {
if (this.isInfoMessage()) {
return <Text style={styles.textInfo}>{getInfoMessage({ ...this.props })}</Text>;
}
const {
customEmojis, msg, baseUrl, user, edited, tmid
} = this.props;
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() {
const { attachments, timeFormat } = this.props;
if (attachments.length === 0) {
return null;
}
return attachments.map((file, index) => {
const { user, baseUrl, customEmojis } = this.props;
if (file.image_url) {
return <Image key={file.image_url} file={file} user={user} baseUrl={baseUrl} customEmojis={customEmojis} />;
}
if (file.audio_url) {
return <Audio key={file.audio_url} file={file} user={user} baseUrl={baseUrl} customEmojis={customEmojis} />;
}
if (file.video_url) {
return <Video key={file.video_url} file={file} user={user} baseUrl={baseUrl} customEmojis={customEmojis} />;
}
// eslint-disable-next-line react/no-array-index-key
return <Reply key={index} index={index} attachment={file} timeFormat={timeFormat} user={user} baseUrl={baseUrl} customEmojis={customEmojis} />;
});
}
renderUrl = () => {
const { urls, user, baseUrl } = this.props;
if (urls.length === 0) {
return null;
}
return urls.map((url, index) => (
<Url url={url} key={url.url} index={index} user={user} baseUrl={baseUrl} />
));
}
renderError = () => {
if (!this.hasError()) {
return null;
}
const { onErrorPress } = this.props;
return (
<Touchable onPress={onErrorPress} style={styles.errorButton}>
<CustomIcon name='circle-cross' color={COLOR_DANGER} size={20} />
</Touchable>
);
}
renderReaction = (reaction) => {
const {
user, onReactionLongPress, onReactionPress, customEmojis, baseUrl
} = this.props;
const reacted = reaction.usernames.findIndex(item => item.value === user.username) !== -1;
return (
<Touchable
onPress={() => onReactionPress(reaction.emoji)}
onLongPress={onReactionLongPress}
key={reaction.emoji}
testID={`message-reaction-${ reaction.emoji }`}
style={[styles.reactionButton, reacted && styles.reactionButtonReacted]}
background={Touchable.Ripple('#fff')}
hitSlop={BUTTON_HIT_SLOP}
>
<View style={[styles.reactionContainer, reacted && styles.reactedContainer]}>
<Emoji
content={reaction.emoji}
customEmojis={customEmojis}
standardEmojiStyle={styles.reactionEmoji}
customEmojiStyle={styles.reactionCustomEmoji}
baseUrl={baseUrl}
/>
<Text style={styles.reactionCount}>{ reaction.usernames.length }</Text>
</View>
</Touchable>
);
}
renderReactions() {
const { reactions, toggleReactionPicker } = this.props;
if (reactions.length === 0) {
return null;
}
return (
<View style={styles.reactionsContainer}>
{reactions.map(this.renderReaction)}
<Touchable
onPress={toggleReactionPicker}
key='message-add-reaction'
testID='message-add-reaction'
style={styles.reactionButton}
background={Touchable.Ripple('#fff')}
hitSlop={BUTTON_HIT_SLOP}
>
<View style={styles.reactionContainer}>
<CustomIcon name='add-reaction' size={21} style={styles.addReaction} />
</View>
</Touchable>
</View>
);
}
renderBroadcastReply() {
const { broadcast, replyBroadcast } = this.props;
if (broadcast && !this.isOwn()) {
return (
<View style={styles.buttonContainer}>
<Touchable
onPress={replyBroadcast}
background={Touchable.Ripple('#fff')}
style={styles.button}
hitSlop={BUTTON_HIT_SLOP}
>
<React.Fragment>
<CustomIcon name='back' size={20} style={styles.buttonIcon} />
<Text style={styles.buttonText}>{I18n.t('Reply')}</Text>
</React.Fragment>
</Touchable>
</View>
);
}
return null;
}
renderDiscussion = () => {
const {
msg, dcount, dlm, onDiscussionPress
} = this.props;
const time = this.formatLastMessage(dlm);
const buttonText = this.formatMessageCount(dcount, 'discussion');
return ( return (
<React.Fragment> <React.Fragment>
<Text style={styles.startedDiscussion}>{I18n.t('Started_discussion')}</Text> <User {...props} />
<Text style={styles.text}>{msg}</Text> <Discussion {...props} />
<View style={styles.buttonContainer}>
<Touchable
onPress={onDiscussionPress}
background={Touchable.Ripple('#fff')}
style={[styles.button, styles.smallButton]}
hitSlop={BUTTON_HIT_SLOP}
>
<React.Fragment>
<CustomIcon name='chat' size={20} style={styles.buttonIcon} />
<Text style={styles.buttonText}>{buttonText}</Text>
</React.Fragment>
</Touchable>
<Text style={styles.time}>{time}</Text>
</View>
</React.Fragment> </React.Fragment>
); );
} }
return (
<React.Fragment>
<User {...props} />
<Content {...props} />
<Attachments {...props} />
<Urls {...props} />
<Thread {...props} />
<Reactions {...props} />
<Broadcast {...props} />
</React.Fragment>
);
});
MessageInner.displayName = 'MessageInner';
renderThread = () => { const Message = React.memo((props) => {
const { if (props.isThreadReply || props.isThreadSequential || props.isInfo) {
tcount, tlm, onThreadPress, msg const thread = props.isThreadReply ? <RepliedThread isTemp={props.isTemp} {...props} /> : null;
} = this.props;
if (!tlm) {
return null;
}
const time = this.formatLastMessage(tlm);
const buttonText = this.formatMessageCount(tcount, 'thread');
return ( return (
<View style={styles.buttonContainer}> <View style={[styles.container, props.style, props.isTemp && styles.temp]}>
<Touchable {thread}
onPress={onThreadPress} <View style={[styles.flex, sharedStyles.alignItemsCenter]}>
background={Touchable.Ripple('#fff')} <MessageAvatar small {...props} />
style={[styles.button, styles.smallButton]} <View
hitSlop={BUTTON_HIT_SLOP} style={[
testID={`message-thread-button-${ msg }`} styles.messageContent,
> props.isHeader && styles.messageContentWithHeader,
<React.Fragment> props.hasError && props.isHeader && styles.messageContentWithHeader,
<CustomIcon name='thread' size={20} style={styles.buttonIcon} /> props.hasError && !props.isHeader && styles.messageContentWithError
<Text style={styles.buttonText}>{buttonText}</Text> ]}
</React.Fragment> >
</Touchable> <Content {...props} />
<Text style={styles.time}>{time}</Text>
</View>
);
}
renderRepliedThread = () => {
const {
tmid, tmsg, header, fetchThreadName
} = this.props;
if (!tmid || !header || this.isTemp()) {
return null;
}
if (!tmsg) {
fetchThreadName(tmid);
return null;
}
let msg = emojify(tmsg, { output: 'unicode' });
msg = removeMarkdown(msg);
return (
<View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}>
<CustomIcon name='thread' size={20} style={styles.repliedThreadIcon} />
<Text style={styles.repliedThreadName} numberOfLines={1}>{msg}</Text>
<DisclosureIndicator />
</View>
);
}
renderInner = () => {
const { type } = this.props;
if (type === 'discussion-created') {
return (
<React.Fragment>
{this.renderUsername()}
{this.renderDiscussion()}
</React.Fragment>
);
}
return (
<React.Fragment>
{this.renderUsername()}
{this.renderContent()}
{this.renderAttachment()}
{this.renderUrl()}
{this.renderThread()}
{this.renderReactions()}
{this.renderBroadcastReply()}
</React.Fragment>
);
}
renderMessage = () => {
const { header, isThreadReply, isThreadSequential } = this.props;
if (isThreadReply || isThreadSequential || this.isInfoMessage()) {
const thread = isThreadReply ? this.renderRepliedThread() : null;
return (
<React.Fragment>
{thread}
<View style={[styles.flex, sharedStyles.alignItemsCenter]}>
{this.renderAvatar(true)}
<View
style={[
styles.messageContent,
header && styles.messageContentWithHeader,
this.hasError() && header && styles.messageContentWithHeader,
this.hasError() && !header && styles.messageContentWithError,
this.isTemp() && styles.temp
]}
>
{this.renderContent()}
</View>
</View> </View>
</React.Fragment> </View>
); </View>
} );
return ( }
return (
<View style={[styles.container, props.style, props.isTemp && styles.temp]}>
<View style={styles.flex}> <View style={styles.flex}>
{this.renderAvatar()} <MessageAvatar {...props} />
<View <View
style={[ style={[
styles.messageContent, styles.messageContent,
header && styles.messageContentWithHeader, props.isHeader && styles.messageContentWithHeader,
this.hasError() && header && styles.messageContentWithHeader, props.hasError && props.isHeader && styles.messageContentWithHeader,
this.hasError() && !header && styles.messageContentWithError, props.hasError && !props.isHeader && styles.messageContentWithError
this.isTemp() && styles.temp
]} ]}
> >
{this.renderInner()} <MessageInner {...props} />
</View> </View>
</View> </View>
); </View>
} );
});
render() { Message.displayName = 'Message';
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 });
const MessageTouchable = React.memo((props) => {
if (props.hasError) {
return ( return (
<View style={styles.root}> <View style={styles.root}>
{this.renderError()} <MessageError {...props} />
<TouchableWithoutFeedback <Message {...props} />
onLongPress={this.onLongPress}
onPress={this.onPress}
>
<View
style={[styles.container, editing && styles.editing, style]}
accessibilityLabel={accessibilityLabel}
>
{this.renderMessage()}
{reactionsModal
? (
<ReactionsModal
isVisible={reactionsModal}
reactions={reactions}
user={user}
customEmojis={customEmojis}
baseUrl={baseUrl}
close={closeReactions}
/>
)
: null
}
</View>
</TouchableWithoutFeedback>
</View> </View>
); );
} }
} return (
<Touchable
onLongPress={props.onLongPress}
onPress={props.onPress}
disabled={props.isInfo || props.archived || props.isTemp}
>
<View>
<Message {...props} />
</View>
</Touchable>
);
});
MessageTouchable.displayName = 'MessageTouchable';
MessageTouchable.propTypes = {
hasError: PropTypes.bool,
isInfo: PropTypes.bool,
isTemp: PropTypes.bool,
archived: PropTypes.bool,
onLongPress: PropTypes.func,
onPress: PropTypes.func
};
Message.propTypes = {
isThreadReply: PropTypes.bool,
isThreadSequential: PropTypes.bool,
isInfo: PropTypes.bool,
isTemp: PropTypes.bool,
isHeader: PropTypes.bool,
hasError: PropTypes.bool,
style: PropTypes.any,
onLongPress: PropTypes.func,
onPress: PropTypes.func
};
MessageInner.propTypes = {
type: PropTypes.string
};
export default MessageTouchable;

View File

@ -0,0 +1,37 @@
import React from 'react';
import PropTypes from 'prop-types';
import Avatar from '../Avatar';
import styles from './styles';
const MessageAvatar = React.memo(({
isHeader, avatar, author, baseUrl, user, small
}) => {
if (isHeader) {
return (
<Avatar
style={small ? styles.avatarSmall : styles.avatar}
text={avatar ? '' : author.username}
size={small ? 20 : 36}
borderRadius={small ? 2 : 4}
avatar={avatar}
baseUrl={baseUrl}
userId={user.id}
token={user.token}
/>
);
}
return null;
}, () => true);
MessageAvatar.propTypes = {
isHeader: PropTypes.bool,
avatar: PropTypes.string,
author: PropTypes.obj,
baseUrl: PropTypes.string,
user: PropTypes.obj,
small: PropTypes.bool
};
MessageAvatar.displayName = 'MessageAvatar';
export default MessageAvatar;

View File

@ -0,0 +1,26 @@
import React from 'react';
import Touchable from 'react-native-platform-touchable';
import PropTypes from 'prop-types';
import { CustomIcon } from '../../lib/Icons';
import { COLOR_DANGER } from '../../constants/colors';
import styles from './styles';
const MessageError = React.memo(({ hasError, onErrorPress }) => {
if (!hasError) {
return null;
}
return (
<Touchable onPress={onErrorPress} style={styles.errorButton}>
<CustomIcon name='circle-cross' color={COLOR_DANGER} size={20} />
</Touchable>
);
}, (prevProps, nextProps) => prevProps.hasError === nextProps.hasError);
MessageError.propTypes = {
hasError: PropTypes.bool,
onErrorPress: PropTypes.func
};
MessageError.displayName = 'MessageError';
export default MessageError;

View File

@ -1,94 +0,0 @@
import React from 'react';
import {
View, Text, TouchableWithoutFeedback, ActivityIndicator, StyleSheet
} from 'react-native';
import FastImage from 'react-native-fast-image';
import PropTypes from 'prop-types';
import Modal from 'react-native-modal';
import ImageViewer from 'react-native-image-zoom-viewer';
import { responsive } from 'react-native-responsive-ui';
import sharedStyles from '../../views/Styles';
import { COLOR_WHITE } from '../../constants/colors';
const styles = StyleSheet.create({
imageWrapper: {
flex: 1
},
titleContainer: {
width: '100%',
alignItems: 'center',
marginVertical: 10
},
title: {
color: COLOR_WHITE,
textAlign: 'center',
fontSize: 16,
...sharedStyles.textSemibold
},
description: {
color: COLOR_WHITE,
textAlign: 'center',
fontSize: 14,
...sharedStyles.textMedium
},
indicatorContainer: {
alignItems: 'center',
justifyContent: 'center'
}
});
const margin = 40;
@responsive
export default class PhotoModal extends React.PureComponent {
static propTypes = {
title: PropTypes.string.isRequired,
description: PropTypes.string,
image: PropTypes.string.isRequired,
isVisible: PropTypes.bool,
onClose: PropTypes.func.isRequired,
window: PropTypes.object
}
render() {
const {
image, isVisible, onClose, title, description, window: { width, height }
} = this.props;
return (
<Modal
isVisible={isVisible}
style={{ alignItems: 'center' }}
onBackdropPress={onClose}
onBackButtonPress={onClose}
animationIn='fadeIn'
animationOut='fadeOut'
>
<View style={{ width: width - margin, height: height - margin }}>
<TouchableWithoutFeedback onPress={onClose}>
<View style={styles.titleContainer}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>{description}</Text>
</View>
</TouchableWithoutFeedback>
<View style={styles.imageWrapper}>
<ImageViewer
imageUrls={[{ url: encodeURI(image) }]}
onClick={onClose}
backgroundColor='transparent'
enableSwipeDown
onSwipeDown={onClose}
renderIndicator={() => {}}
renderImage={props => <FastImage {...props} />}
loadingRender={() => (
<View style={[styles.indicatorContainer, { width, height }]}>
<ActivityIndicator />
</View>
)}
/>
</View>
</View>
</Modal>
);
}
}

View File

@ -0,0 +1,105 @@
import React from 'react';
import { View, Text } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import PropTypes from 'prop-types';
import { CustomIcon } from '../../lib/Icons';
import styles from './styles';
import Emoji from './Emoji';
import { BUTTON_HIT_SLOP } from './utils';
const AddReaction = React.memo(({ toggleReactionPicker }) => (
<Touchable
onPress={toggleReactionPicker}
key='message-add-reaction'
testID='message-add-reaction'
style={styles.reactionButton}
background={Touchable.Ripple('#fff')}
hitSlop={BUTTON_HIT_SLOP}
>
<View style={styles.reactionContainer}>
<CustomIcon name='add-reaction' size={21} style={styles.addReaction} />
</View>
</Touchable>
));
const Reaction = React.memo(({
reaction, user, onReactionLongPress, onReactionPress, baseUrl, getCustomEmoji
}) => {
const reacted = reaction.usernames.findIndex(item => item === user.username) !== -1;
return (
<Touchable
onPress={() => onReactionPress(reaction.emoji)}
onLongPress={onReactionLongPress}
key={reaction.emoji}
testID={`message-reaction-${ reaction.emoji }`}
style={[styles.reactionButton, reacted && styles.reactionButtonReacted]}
background={Touchable.Ripple('#fff')}
hitSlop={BUTTON_HIT_SLOP}
>
<View style={[styles.reactionContainer, reacted && styles.reactedContainer]}>
<Emoji
content={reaction.emoji}
standardEmojiStyle={styles.reactionEmoji}
customEmojiStyle={styles.reactionCustomEmoji}
baseUrl={baseUrl}
getCustomEmoji={getCustomEmoji}
/>
<Text style={styles.reactionCount}>{ reaction.usernames.length }</Text>
</View>
</Touchable>
);
}, () => true);
const Reactions = React.memo(({
reactions, user, baseUrl, onReactionPress, toggleReactionPicker, onReactionLongPress, getCustomEmoji
}) => {
if (!reactions || reactions.length === 0) {
return null;
}
return (
<View style={styles.reactionsContainer}>
{reactions.map(reaction => (
<Reaction
key={reaction.emoji}
reaction={reaction}
user={user}
baseUrl={baseUrl}
onReactionLongPress={onReactionLongPress}
onReactionPress={onReactionPress}
getCustomEmoji={getCustomEmoji}
/>
))}
<AddReaction toggleReactionPicker={toggleReactionPicker} />
</View>
);
});
// FIXME: can't compare because it's a Realm object (it may be fixed by JSON.parse(JSON.stringify(reactions)))
Reaction.propTypes = {
reaction: PropTypes.object,
user: PropTypes.object,
baseUrl: PropTypes.string,
onReactionPress: PropTypes.func,
onReactionLongPress: PropTypes.func,
getCustomEmoji: PropTypes.func
};
Reaction.displayName = 'MessageReaction';
Reactions.propTypes = {
reactions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
user: PropTypes.object,
baseUrl: PropTypes.string,
onReactionPress: PropTypes.func,
toggleReactionPicker: PropTypes.func,
onReactionLongPress: PropTypes.func,
getCustomEmoji: PropTypes.func
};
Reactions.displayName = 'MessageReactions';
AddReaction.propTypes = {
toggleReactionPicker: PropTypes.func
};
AddReaction.displayName = 'MessageAddReaction';
export default Reactions;

View File

@ -1,140 +0,0 @@
import React from 'react';
import {
View, Text, TouchableWithoutFeedback, FlatList, StyleSheet
} from 'react-native';
import PropTypes from 'prop-types';
import Modal from 'react-native-modal';
import Emoji from './Emoji';
import I18n from '../../i18n';
import { CustomIcon } from '../../lib/Icons';
import sharedStyles from '../../views/Styles';
import { COLOR_WHITE } from '../../constants/colors';
const styles = StyleSheet.create({
titleContainer: {
width: '100%',
alignItems: 'center',
paddingVertical: 10
},
title: {
color: COLOR_WHITE,
textAlign: 'center',
fontSize: 16,
...sharedStyles.textSemibold
},
reactCount: {
color: COLOR_WHITE,
fontSize: 13,
...sharedStyles.textRegular
},
peopleReacted: {
color: COLOR_WHITE,
fontSize: 14,
...sharedStyles.textMedium
},
peopleItemContainer: {
flex: 1,
flexDirection: 'column',
justifyContent: 'center'
},
emojiContainer: {
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center'
},
itemContainer: {
height: 50,
flexDirection: 'row'
},
listContainer: {
flex: 1
},
closeButton: {
position: 'absolute',
left: 0,
top: 10,
color: COLOR_WHITE
}
});
const standardEmojiStyle = { fontSize: 20 };
const customEmojiStyle = { width: 20, height: 20 };
export default class ReactionsModal extends React.PureComponent {
static propTypes = {
isVisible: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
reactions: PropTypes.object.isRequired,
user: PropTypes.object.isRequired,
baseUrl: PropTypes.string.isRequired,
customEmojis: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object
])
}
renderItem = (item) => {
const { user, customEmojis, baseUrl } = this.props;
const count = item.usernames.length;
let usernames = item.usernames.slice(0, 3)
.map(username => (username.value === user.username ? I18n.t('you') : username.value)).join(', ');
if (count > 3) {
usernames = `${ usernames } ${ I18n.t('and_more') } ${ count - 3 }`;
} else {
usernames = usernames.replace(/,(?=[^,]*$)/, ` ${ I18n.t('and') }`);
}
return (
<View style={styles.itemContainer}>
<View style={styles.emojiContainer}>
<Emoji
content={item.emoji}
standardEmojiStyle={standardEmojiStyle}
customEmojiStyle={customEmojiStyle}
customEmojis={customEmojis}
baseUrl={baseUrl}
/>
</View>
<View style={styles.peopleItemContainer}>
<Text style={styles.reactCount}>
{count === 1 ? I18n.t('1_person_reacted') : I18n.t('N_people_reacted', { n: count })}
</Text>
<Text style={styles.peopleReacted}>{ usernames }</Text>
</View>
</View>
);
}
render() {
const {
isVisible, close, reactions
} = this.props;
return (
<Modal
isVisible={isVisible}
onBackdropPress={close}
onBackButtonPress={close}
backdropOpacity={0.9}
>
<TouchableWithoutFeedback onPress={close}>
<View style={styles.titleContainer}>
<CustomIcon
style={styles.closeButton}
name='cross'
size={20}
onPress={close}
/>
<Text style={styles.title}>{I18n.t('Reactions')}</Text>
</View>
</TouchableWithoutFeedback>
<View style={styles.listContainer}>
<FlatList
data={reactions}
renderItem={({ item }) => this.renderItem(item)}
keyExtractor={item => item.emoji}
/>
</View>
</Modal>
);
}
}

View File

@ -0,0 +1,58 @@
import React from 'react';
import { View, Text } from 'react-native';
import removeMarkdown from 'remove-markdown';
import { emojify } from 'react-emojione';
import PropTypes from 'prop-types';
import { CustomIcon } from '../../lib/Icons';
import DisclosureIndicator from '../DisclosureIndicator';
import styles from './styles';
const RepliedThread = React.memo(({
tmid, tmsg, isHeader, isTemp, fetchThreadName
}) => {
if (!tmid || !isHeader || isTemp) {
return null;
}
if (!tmsg) {
fetchThreadName(tmid);
return null;
}
let msg = emojify(tmsg, { output: 'unicode' });
msg = removeMarkdown(msg);
return (
<View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}>
<CustomIcon name='thread' size={20} style={styles.repliedThreadIcon} />
<Text style={styles.repliedThreadName} numberOfLines={1}>{msg}</Text>
<DisclosureIndicator />
</View>
);
}, (prevProps, nextProps) => {
if (prevProps.tmid !== nextProps.tmid) {
return false;
}
if (prevProps.tmsg !== nextProps.tmsg) {
return false;
}
if (prevProps.isHeader !== nextProps.isHeader) {
return false;
}
if (prevProps.isTemp !== nextProps.isTemp) {
return false;
}
return true;
});
RepliedThread.propTypes = {
tmid: PropTypes.string,
tmsg: PropTypes.string,
isHeader: PropTypes.bool,
isTemp: PropTypes.bool,
fetchThreadName: PropTypes.func
};
RepliedThread.displayName = 'MessageRepliedThread';
export default RepliedThread;

View File

@ -3,6 +3,7 @@ import { View, Text, StyleSheet } from 'react-native';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import moment from 'moment'; import moment from 'moment';
import Touchable from 'react-native-platform-touchable'; import Touchable from 'react-native-platform-touchable';
import isEqual from 'deep-equal';
import Markdown from './Markdown'; import Markdown from './Markdown';
import openLink from '../../utils/openLink'; import openLink from '../../utils/openLink';
@ -69,98 +70,130 @@ const styles = StyleSheet.create({
} }
}); });
const onPress = (attachment, baseUrl, user) => { const Title = React.memo(({ attachment, timeFormat }) => {
let url = attachment.title_link || attachment.author_link; if (!attachment.author_name) {
if (!url) { return null;
return;
} }
if (attachment.type === 'file') { const time = attachment.ts ? moment(attachment.ts).format(timeFormat) : null;
url = `${ baseUrl }${ url }?rc_uid=${ user.id }&rc_token=${ user.token }`; return (
} <View style={styles.authorContainer}>
openLink(url); {attachment.author_name ? <Text style={styles.author}>{attachment.author_name}</Text> : null}
}; {time ? <Text style={styles.time}>{ time }</Text> : null}
</View>
);
}, () => true);
const Reply = ({ const Description = React.memo(({
attachment, timeFormat, baseUrl, customEmojis, user, index attachment, baseUrl, user, getCustomEmoji, useMarkdown
}) => {
const text = attachment.text || attachment.title;
if (!text) {
return null;
}
return (
<Markdown
msg={text}
baseUrl={baseUrl}
username={user.username}
getCustomEmoji={getCustomEmoji}
useMarkdown={useMarkdown}
/>
);
}, (prevProps, nextProps) => {
if (prevProps.attachment.text !== nextProps.attachment.text) {
return false;
}
if (prevProps.attachment.title !== nextProps.attachment.title) {
return false;
}
return true;
});
const Fields = React.memo(({ attachment }) => {
if (!attachment.fields) {
return null;
}
return (
<View style={styles.fieldsContainer}>
{attachment.fields.map(field => (
<View key={field.title} style={[styles.fieldContainer, { width: field.short ? '50%' : '100%' }]}>
<Text style={styles.fieldTitle}>{field.title}</Text>
<Text style={styles.fieldValue}>{field.value}</Text>
</View>
))}
</View>
);
}, (prevProps, nextProps) => isEqual(prevProps.attachment.fields, nextProps.attachment.fields));
const Reply = React.memo(({
attachment, timeFormat, baseUrl, user, index, getCustomEmoji, useMarkdown
}) => { }) => {
if (!attachment) { if (!attachment) {
return null; return null;
} }
const renderAuthor = () => ( const onPress = () => {
attachment.author_name ? <Text style={styles.author}>{attachment.author_name}</Text> : null let url = attachment.title_link || attachment.author_link;
); if (!url) {
return;
const renderTime = () => {
const time = attachment.ts ? moment(attachment.ts).format(timeFormat) : null;
return time ? <Text style={styles.time}>{ time }</Text> : null;
};
const renderTitle = () => {
if (!attachment.author_name) {
return null;
} }
return ( if (attachment.type === 'file') {
<View style={styles.authorContainer}> url = `${ baseUrl }${ url }?rc_uid=${ user.id }&rc_token=${ user.token }`;
{renderAuthor()}
{renderTime()}
</View>
);
};
const renderText = () => {
const text = attachment.text || attachment.title;
if (text) {
return (
<Markdown
msg={text}
customEmojis={customEmojis}
baseUrl={baseUrl}
username={user.username}
/>
);
} }
}; openLink(url);
const renderFields = () => {
if (!attachment.fields) {
return null;
}
return (
<View style={styles.fieldsContainer}>
{attachment.fields.map(field => (
<View key={field.title} style={[styles.fieldContainer, { width: field.short ? '50%' : '100%' }]}>
<Text style={styles.fieldTitle}>{field.title}</Text>
<Text style={styles.fieldValue}>{field.value}</Text>
</View>
))}
</View>
);
}; };
return ( return (
<Touchable <Touchable
onPress={() => onPress(attachment, baseUrl, user)} onPress={onPress}
style={[styles.button, index > 0 && styles.marginTop]} style={[styles.button, index > 0 && styles.marginTop]}
background={Touchable.Ripple('#fff')} background={Touchable.Ripple('#fff')}
> >
<View style={styles.attachmentContainer}> <View style={styles.attachmentContainer}>
{renderTitle()} <Title attachment={attachment} timeFormat={timeFormat} />
{renderText()} <Description
{renderFields()} attachment={attachment}
timeFormat={timeFormat}
baseUrl={baseUrl}
user={user}
getCustomEmoji={getCustomEmoji}
useMarkdown={useMarkdown}
/>
<Fields attachment={attachment} />
</View> </View>
</Touchable> </Touchable>
); );
}; }, (prevProps, nextProps) => isEqual(prevProps.attachment, nextProps.attachment));
Reply.propTypes = { Reply.propTypes = {
attachment: PropTypes.object.isRequired, attachment: PropTypes.object,
timeFormat: PropTypes.string.isRequired, timeFormat: PropTypes.string,
baseUrl: PropTypes.string.isRequired, baseUrl: PropTypes.string,
customEmojis: PropTypes.object.isRequired, user: PropTypes.object,
user: PropTypes.object.isRequired, index: PropTypes.number,
index: PropTypes.number useMarkdown: PropTypes.bool,
getCustomEmoji: PropTypes.func
}; };
Reply.displayName = 'MessageReply';
Title.propTypes = {
attachment: PropTypes.object,
timeFormat: PropTypes.string
};
Title.displayName = 'MessageReplyTitle';
Description.propTypes = {
attachment: PropTypes.object,
baseUrl: PropTypes.string,
user: PropTypes.object,
useMarkdown: PropTypes.bool,
getCustomEmoji: PropTypes.func
};
Description.displayName = 'MessageReplyDescription';
Fields.propTypes = {
attachment: PropTypes.object
};
Fields.displayName = 'MessageReplyFields';
export default Reply; export default Reply;

View File

@ -0,0 +1,46 @@
import React from 'react';
import { View, Text } from 'react-native';
import PropTypes from 'prop-types';
import { formatLastMessage, formatMessageCount } from './utils';
import styles from './styles';
import { CustomIcon } from '../../lib/Icons';
import { THREAD } from './constants';
const Thread = React.memo(({
msg, tcount, tlm, customThreadTimeFormat
}) => {
if (!tlm) {
return null;
}
const time = formatLastMessage(tlm, customThreadTimeFormat);
const buttonText = formatMessageCount(tcount, THREAD);
return (
<View style={styles.buttonContainer}>
<View
style={[styles.button, styles.smallButton]}
testID={`message-thread-button-${ msg }`}
>
<CustomIcon name='thread' size={20} style={styles.buttonIcon} />
<Text style={styles.buttonText}>{buttonText}</Text>
</View>
<Text style={styles.time}>{time}</Text>
</View>
);
}, (prevProps, nextProps) => {
if (prevProps.tcount !== nextProps.tcount) {
return false;
}
return true;
});
Thread.propTypes = {
msg: PropTypes.string,
tcount: PropTypes.string,
tlm: PropTypes.string,
customThreadTimeFormat: PropTypes.string
};
Thread.displayName = 'MessageThread';
export default Thread;

View File

@ -57,14 +57,22 @@ const UrlImage = React.memo(({ image, user, baseUrl }) => {
} }
image = image.includes('http') ? image : `${ baseUrl }/${ image }?rc_uid=${ user.id }&rc_token=${ user.token }`; image = image.includes('http') ? image : `${ baseUrl }/${ image }?rc_uid=${ user.id }&rc_token=${ user.token }`;
return <FastImage source={{ uri: image }} style={styles.image} resizeMode={FastImage.resizeMode.cover} />; return <FastImage source={{ uri: image }} style={styles.image} resizeMode={FastImage.resizeMode.cover} />;
}); }, (prevProps, nextProps) => prevProps.image === nextProps.image);
const UrlContent = React.memo(({ title, description }) => ( const UrlContent = React.memo(({ title, description }) => (
<View style={styles.textContainer}> <View style={styles.textContainer}>
{title ? <Text style={styles.title} numberOfLines={2}>{title}</Text> : null} {title ? <Text style={styles.title} numberOfLines={2}>{title}</Text> : null}
{description ? <Text style={styles.description} numberOfLines={2}>{description}</Text> : null} {description ? <Text style={styles.description} numberOfLines={2}>{description}</Text> : null}
</View> </View>
)); ), (prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) {
return false;
}
if (prevProps.description !== nextProps.description) {
return false;
}
return true;
});
const Url = React.memo(({ const Url = React.memo(({
url, index, user, baseUrl url, index, user, baseUrl
@ -89,16 +97,28 @@ const Url = React.memo(({
); );
}, (oldProps, newProps) => isEqual(oldProps.url, newProps.url)); }, (oldProps, newProps) => isEqual(oldProps.url, newProps.url));
const Urls = React.memo(({ urls, user, baseUrl }) => {
if (!urls || urls.length === 0) {
return null;
}
return urls.map((url, index) => (
<Url url={url} key={url.url} index={index} user={user} baseUrl={baseUrl} />
));
}, (oldProps, newProps) => isEqual(oldProps.urls, newProps.urls));
UrlImage.propTypes = { UrlImage.propTypes = {
image: PropTypes.string, image: PropTypes.string,
user: PropTypes.object, user: PropTypes.object,
baseUrl: PropTypes.string baseUrl: PropTypes.string
}; };
UrlImage.displayName = 'MessageUrlImage';
UrlContent.propTypes = { UrlContent.propTypes = {
title: PropTypes.string, title: PropTypes.string,
description: PropTypes.string description: PropTypes.string
}; };
UrlContent.displayName = 'MessageUrlContent';
Url.propTypes = { Url.propTypes = {
url: PropTypes.object.isRequired, url: PropTypes.object.isRequired,
@ -106,5 +126,13 @@ Url.propTypes = {
user: PropTypes.object, user: PropTypes.object,
baseUrl: PropTypes.string baseUrl: PropTypes.string
}; };
Url.displayName = 'MessageUrl';
export default Url; Urls.propTypes = {
urls: PropTypes.array,
user: PropTypes.object,
baseUrl: PropTypes.string
};
Urls.displayName = 'MessageUrls';
export default Urls;

View File

@ -30,28 +30,11 @@ const styles = StyleSheet.create({
} }
}); });
export default class User extends React.PureComponent { const User = React.memo(({
static propTypes = { isHeader, useRealName, author, alias, ts, timeFormat
timeFormat: PropTypes.string.isRequired, }) => {
username: PropTypes.string, if (isHeader) {
alias: PropTypes.string, const username = (useRealName && author.name) || author.username;
ts: PropTypes.oneOfType([
PropTypes.instanceOf(Date),
PropTypes.string
]),
temp: PropTypes.bool
}
render() {
const {
username, alias, ts, temp, timeFormat
} = this.props;
const extraStyle = {};
if (temp) {
extraStyle.opacity = 0.3;
}
const aliasUsername = alias ? (<Text style={styles.alias}> @{username}</Text>) : null; const aliasUsername = alias ? (<Text style={styles.alias}> @{username}</Text>) : null;
const time = moment(ts).format(timeFormat); const time = moment(ts).format(timeFormat);
@ -67,4 +50,17 @@ export default class User extends React.PureComponent {
</View> </View>
); );
} }
} return null;
});
User.propTypes = {
isHeader: PropTypes.bool,
useRealName: PropTypes.bool,
author: PropTypes.object,
alias: PropTypes.string,
ts: PropTypes.instanceOf(Date),
timeFormat: PropTypes.string
};
User.displayName = 'MessageUser';
export default User;

View File

@ -1,14 +1,14 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { StyleSheet, View } from 'react-native'; import { StyleSheet } from 'react-native';
import Modal from 'react-native-modal';
import VideoPlayer from 'react-native-video-controls';
import Touchable from 'react-native-platform-touchable'; import Touchable from 'react-native-platform-touchable';
import isEqual from 'deep-equal';
import Markdown from './Markdown'; import Markdown from './Markdown';
import openLink from '../../utils/openLink'; import openLink from '../../utils/openLink';
import { isIOS } from '../../utils/deviceInfo'; import { isIOS } from '../../utils/deviceInfo';
import { CustomIcon } from '../../lib/Icons'; import { CustomIcon } from '../../lib/Icons';
import { formatAttachmentUrl } from '../../lib/utils';
const SUPPORTED_TYPES = ['video/quicktime', 'video/mp4', ...(isIOS ? [] : ['video/webm', 'video/3gp', 'video/mkv'])]; const SUPPORTED_TYPES = ['video/quicktime', 'video/mp4', ...(isIOS ? [] : ['video/webm', 'video/3gp', 'video/mkv'])];
const isTypeSupported = type => SUPPORTED_TYPES.indexOf(type) !== -1; const isTypeSupported = type => SUPPORTED_TYPES.indexOf(type) !== -1;
@ -32,77 +32,46 @@ const styles = StyleSheet.create({
} }
}); });
export default class Video extends React.PureComponent { const Video = React.memo(({
static propTypes = { file, baseUrl, user, useMarkdown, onOpenFileModal, getCustomEmoji
file: PropTypes.object.isRequired, }) => {
baseUrl: PropTypes.string.isRequired, if (!baseUrl) {
user: PropTypes.object.isRequired, return null;
customEmojis: PropTypes.object.isRequired
} }
state = { isVisible: false }; const onPress = () => {
get uri() {
const { baseUrl, user, file } = this.props;
const { video_url } = file;
return `${ baseUrl }${ video_url }?rc_uid=${ user.id }&rc_token=${ user.token }`;
}
toggleModal = () => {
this.setState(prevState => ({
isVisible: !prevState.isVisible
}));
}
open = () => {
const { file } = this.props;
if (isTypeSupported(file.video_type)) { if (isTypeSupported(file.video_type)) {
return this.toggleModal(); return onOpenFileModal(file);
} }
openLink(this.uri); const uri = formatAttachmentUrl(file.video_url, user.id, user.token, baseUrl);
} openLink(uri);
};
render() { return (
const { isVisible } = this.state; <React.Fragment>
const { <Touchable
baseUrl, user, customEmojis, file onPress={onPress}
} = this.props; style={styles.button}
const { description } = file; background={Touchable.Ripple('#fff')}
>
<CustomIcon
name='play'
size={54}
style={styles.image}
/>
</Touchable>
<Markdown msg={file.description} baseUrl={baseUrl} username={user.username} getCustomEmoji={getCustomEmoji} useMarkdown={useMarkdown} />
</React.Fragment>
);
}, (prevProps, nextProps) => isEqual(prevProps.file, nextProps.file));
if (!baseUrl) { Video.propTypes = {
return null; file: PropTypes.object,
} baseUrl: PropTypes.string,
user: PropTypes.object,
useMarkdown: PropTypes.bool,
onOpenFileModal: PropTypes.func,
getCustomEmoji: PropTypes.func
};
return ( export default Video;
[
<View key='button'>
<Touchable
onPress={this.open}
style={styles.button}
background={Touchable.Ripple('#fff')}
>
<CustomIcon
name='play'
size={54}
style={styles.image}
/>
</Touchable>
<Markdown msg={description} customEmojis={customEmojis} baseUrl={baseUrl} username={user.username} />
</View>,
<Modal
key='modal'
isVisible={isVisible}
style={styles.modal}
supportedOrientations={['portrait', 'landscape']}
onBackButtonPress={() => this.toggleModal()}
>
<VideoPlayer
source={{ uri: this.uri }}
onBack={this.toggleModal}
disableVolume
/>
</Modal>
]
);
}
}

View File

@ -0,0 +1,2 @@
export const DISCUSSION = 'discussion';
export const THREAD = 'thread';

View File

@ -1,30 +1,13 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { ViewPropTypes } from 'react-native'; import { ViewPropTypes } from 'react-native';
import { connect } from 'react-redux'; import { KeyboardUtils } from 'react-native-keyboard-input';
import equal from 'deep-equal';
import Message from './Message'; import Message from './Message';
import {
errorActionsShow as errorActionsShowAction,
toggleReactionPicker as toggleReactionPickerAction,
replyBroadcast as replyBroadcastAction
} from '../../actions/messages';
import { vibrate } from '../../utils/vibration';
import debounce from '../../utils/debounce'; import debounce from '../../utils/debounce';
import { SYSTEM_MESSAGES, getCustomEmoji } from './utils';
import messagesStatus from '../../constants/messagesStatus';
@connect(state => ({
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
customEmojis: state.customEmojis,
Message_GroupingPeriod: state.settings.Message_GroupingPeriod,
Message_TimeFormat: state.settings.Message_TimeFormat,
editingMessage: state.messages.message,
useRealName: state.settings.UI_Use_Real_Name
}), dispatch => ({
errorActionsShow: actionMessage => dispatch(errorActionsShowAction(actionMessage)),
replyBroadcast: message => dispatch(replyBroadcastAction(message)),
toggleReactionPicker: message => dispatch(toggleReactionPickerAction(message))
}))
export default class MessageContainer extends React.Component { export default class MessageContainer extends React.Component {
static propTypes = { static propTypes = {
item: PropTypes.object.isRequired, item: PropTypes.object.isRequired,
@ -33,31 +16,28 @@ export default class MessageContainer extends React.Component {
username: PropTypes.string.isRequired, username: PropTypes.string.isRequired,
token: PropTypes.string.isRequired token: PropTypes.string.isRequired
}), }),
customTimeFormat: PropTypes.string, timeFormat: PropTypes.string,
customThreadTimeFormat: PropTypes.string, customThreadTimeFormat: PropTypes.string,
style: ViewPropTypes.style, style: ViewPropTypes.style,
archived: PropTypes.bool, archived: PropTypes.bool,
broadcast: PropTypes.bool, broadcast: PropTypes.bool,
previousItem: PropTypes.object, previousItem: PropTypes.object,
_updatedAt: PropTypes.instanceOf(Date), _updatedAt: PropTypes.instanceOf(Date),
// redux
baseUrl: PropTypes.string, baseUrl: PropTypes.string,
customEmojis: PropTypes.object,
Message_GroupingPeriod: PropTypes.number, Message_GroupingPeriod: PropTypes.number,
Message_TimeFormat: PropTypes.string,
editingMessage: PropTypes.object,
useRealName: PropTypes.bool, useRealName: PropTypes.bool,
useMarkdown: PropTypes.bool,
status: PropTypes.number, status: PropTypes.number,
navigation: PropTypes.object,
// methods - props
onLongPress: PropTypes.func, onLongPress: PropTypes.func,
onReactionPress: PropTypes.func, onReactionPress: PropTypes.func,
onDiscussionPress: PropTypes.func, onDiscussionPress: PropTypes.func,
// methods - redux onThreadPress: PropTypes.func,
errorActionsShow: PropTypes.func, errorActionsShow: PropTypes.func,
replyBroadcast: PropTypes.func, replyBroadcast: PropTypes.func,
toggleReactionPicker: PropTypes.func, toggleReactionPicker: PropTypes.func,
fetchThreadName: PropTypes.func fetchThreadName: PropTypes.func,
onOpenFileModal: PropTypes.func,
onReactionLongPress: PropTypes.func
} }
static defaultProps = { static defaultProps = {
@ -67,21 +47,11 @@ export default class MessageContainer extends React.Component {
broadcast: false broadcast: false
} }
constructor(props) { shouldComponentUpdate(nextProps) {
super(props);
this.state = { reactionsModal: false };
this.closeReactions = this.closeReactions.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
const { reactionsModal } = this.state;
const { const {
status, editingMessage, item, _updatedAt, navigation status, item, _updatedAt, previousItem
} = this.props; } = this.props;
if (reactionsModal !== nextState.reactionsModal) {
return true;
}
if (status !== nextProps.status) { if (status !== nextProps.status) {
return true; return true;
} }
@ -89,65 +59,68 @@ export default class MessageContainer extends React.Component {
return true; return true;
} }
if (navigation.isFocused() && !equal(editingMessage, nextProps.editingMessage)) { if (!previousItem && !!nextProps.previousItem) {
if (nextProps.editingMessage && nextProps.editingMessage._id === item._id) { return true;
return true;
} else if (!nextProps.editingMessage._id !== item._id && editingMessage._id === item._id) {
return true;
}
} }
return _updatedAt.toISOString() !== nextProps._updatedAt.toISOString(); return _updatedAt.toISOString() !== nextProps._updatedAt.toISOString();
} }
onPress = debounce(() => {
const { item } = this.props;
KeyboardUtils.dismiss();
if ((item.tlm || item.tmid)) {
this.onThreadPress();
}
}, 300, true);
onLongPress = () => { onLongPress = () => {
const { onLongPress } = this.props; const { archived, onLongPress } = this.props;
onLongPress(this.parseMessage()); if (this.isInfo || this.hasError || archived) {
return;
}
if (onLongPress) {
onLongPress(this.parseMessage());
}
} }
onErrorPress = () => { onErrorPress = () => {
const { errorActionsShow } = this.props; const { errorActionsShow } = this.props;
errorActionsShow(this.parseMessage()); if (errorActionsShow) {
errorActionsShow(this.parseMessage());
}
} }
onReactionPress = (emoji) => { onReactionPress = (emoji) => {
const { onReactionPress, item } = this.props; const { onReactionPress, item } = this.props;
onReactionPress(emoji, item._id); if (onReactionPress) {
onReactionPress(emoji, item._id);
}
} }
onReactionLongPress = () => { onReactionLongPress = () => {
this.setState({ reactionsModal: true }); const { onReactionLongPress, item } = this.props;
vibrate(); if (onReactionLongPress) {
onReactionLongPress(item);
}
} }
onDiscussionPress = () => { onDiscussionPress = () => {
const { onDiscussionPress, item } = this.props; const { onDiscussionPress, item } = this.props;
onDiscussionPress(item); if (onDiscussionPress) {
} onDiscussionPress(item);
onThreadPress = debounce(() => {
const { navigation, item } = this.props;
if (item.tmid) {
navigation.push('RoomView', {
rid: item.rid, tmid: item.tmid, name: item.tmsg, t: 'thread'
});
} else if (item.tlm) {
const title = item.msg || (item.attachments && item.attachments.length && item.attachments[0].title);
navigation.push('RoomView', {
rid: item.rid, tmid: item._id, name: title, t: 'thread'
});
} }
}, 1000, true)
get timeFormat() {
const { customTimeFormat, Message_TimeFormat } = this.props;
return customTimeFormat || Message_TimeFormat;
} }
closeReactions = () => { onThreadPress = () => {
this.setState({ reactionsModal: false }); const { onThreadPress, item } = this.props;
if (onThreadPress) {
onThreadPress(item);
}
} }
isHeader = () => { get isHeader() {
const { const {
item, previousItem, broadcast, Message_GroupingPeriod item, previousItem, broadcast, Message_GroupingPeriod
} = this.props; } = this.props;
@ -163,7 +136,7 @@ export default class MessageContainer extends React.Component {
return true; return true;
} }
isThreadReply = () => { get isThreadReply() {
const { const {
item, previousItem item, previousItem
} = this.props; } = this.props;
@ -173,7 +146,7 @@ export default class MessageContainer extends React.Component {
return false; return false;
} }
isThreadSequential = () => { get isThreadSequential() {
const { const {
item, previousItem item, previousItem
} = this.props; } = this.props;
@ -183,6 +156,21 @@ export default class MessageContainer extends React.Component {
return false; return false;
} }
get isInfo() {
const { item } = this.props;
return SYSTEM_MESSAGES.includes(item.t);
}
get isTemp() {
const { item } = this.props;
return item.status === messagesStatus.TEMP || item.status === messagesStatus.ERROR;
}
get hasError() {
const { item } = this.props;
return item.status === messagesStatus.ERROR;
}
parseMessage = () => { parseMessage = () => {
const { item } = this.props; const { item } = this.props;
return JSON.parse(JSON.stringify(item)); return JSON.parse(JSON.stringify(item));
@ -190,23 +178,26 @@ export default class MessageContainer extends React.Component {
toggleReactionPicker = () => { toggleReactionPicker = () => {
const { toggleReactionPicker } = this.props; const { toggleReactionPicker } = this.props;
toggleReactionPicker(this.parseMessage()); if (toggleReactionPicker) {
toggleReactionPicker(this.parseMessage());
}
} }
replyBroadcast = () => { replyBroadcast = () => {
const { replyBroadcast } = this.props; const { replyBroadcast } = this.props;
replyBroadcast(this.parseMessage()); if (replyBroadcast) {
replyBroadcast(this.parseMessage());
}
} }
render() { render() {
const { reactionsModal } = this.state;
const { const {
item, editingMessage, user, style, archived, baseUrl, customEmojis, useRealName, broadcast, fetchThreadName, customThreadTimeFormat item, user, style, archived, baseUrl, useRealName, broadcast, fetchThreadName, customThreadTimeFormat, onOpenFileModal, timeFormat, useMarkdown
} = this.props; } = this.props;
const { const {
_id, msg, ts, attachments, urls, reactions, t, status, avatar, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg _id, msg, ts, attachments, urls, reactions, t, avatar, u, alias, editedBy, role, drid, dcount, dlm, tmid, tcount, tlm, tmsg, mentions, channels
} = item; } = item;
const isEditing = editingMessage._id === item._id;
return ( return (
<Message <Message
id={_id} id={_id}
@ -214,26 +205,18 @@ export default class MessageContainer extends React.Component {
author={u} author={u}
ts={ts} ts={ts}
type={t} type={t}
status={status}
attachments={attachments} attachments={attachments}
urls={urls} urls={urls}
reactions={reactions} reactions={reactions}
alias={alias} alias={alias}
editing={isEditing}
header={this.isHeader()}
isThreadReply={this.isThreadReply()}
isThreadSequential={this.isThreadSequential()}
avatar={avatar} avatar={avatar}
user={user} user={user}
edited={editedBy && !!editedBy.username} timeFormat={timeFormat}
timeFormat={this.timeFormat}
customThreadTimeFormat={customThreadTimeFormat} customThreadTimeFormat={customThreadTimeFormat}
style={style} style={style}
archived={archived} archived={archived}
broadcast={broadcast} broadcast={broadcast}
baseUrl={baseUrl} baseUrl={baseUrl}
customEmojis={customEmojis}
reactionsModal={reactionsModal}
useRealName={useRealName} useRealName={useRealName}
role={role} role={role}
drid={drid} drid={drid}
@ -243,16 +226,27 @@ export default class MessageContainer extends React.Component {
tcount={tcount} tcount={tcount}
tlm={tlm} tlm={tlm}
tmsg={tmsg} tmsg={tmsg}
useMarkdown={useMarkdown}
fetchThreadName={fetchThreadName} fetchThreadName={fetchThreadName}
closeReactions={this.closeReactions} mentions={mentions}
channels={channels}
isEdited={editedBy && !!editedBy.username}
isHeader={this.isHeader}
isThreadReply={this.isThreadReply}
isThreadSequential={this.isThreadSequential}
isInfo={this.isInfo}
isTemp={this.isTemp}
hasError={this.hasError}
onErrorPress={this.onErrorPress} onErrorPress={this.onErrorPress}
onPress={this.onPress}
onLongPress={this.onLongPress} onLongPress={this.onLongPress}
onReactionLongPress={this.onReactionLongPress} onReactionLongPress={this.onReactionLongPress}
onReactionPress={this.onReactionPress} onReactionPress={this.onReactionPress}
replyBroadcast={this.replyBroadcast} replyBroadcast={this.replyBroadcast}
toggleReactionPicker={this.toggleReactionPicker} toggleReactionPicker={this.toggleReactionPicker}
onDiscussionPress={this.onDiscussionPress} onDiscussionPress={this.onDiscussionPress}
onThreadPress={this.onThreadPress} onOpenFileModal={onOpenFileModal}
getCustomEmoji={getCustomEmoji}
/> />
); );
} }

View File

@ -18,8 +18,7 @@ export default StyleSheet.create({
paddingVertical: 4, paddingVertical: 4,
width: '100%', width: '100%',
paddingHorizontal: 14, paddingHorizontal: 14,
flexDirection: 'column', flexDirection: 'column'
flex: 1
}, },
messageContent: { messageContent: {
flex: 1, flex: 1,
@ -32,8 +31,8 @@ export default StyleSheet.create({
marginLeft: 0 marginLeft: 0
}, },
flex: { flex: {
flexDirection: 'row', flexDirection: 'row'
flex: 1 // flex: 1
}, },
text: { text: {
fontSize: 16, fontSize: 16,
@ -46,9 +45,6 @@ export default StyleSheet.create({
...sharedStyles.textColorDescription, ...sharedStyles.textColorDescription,
...sharedStyles.textRegular ...sharedStyles.textRegular
}, },
editing: {
backgroundColor: '#fff5df'
},
customEmoji: { customEmoji: {
width: 20, width: 20,
height: 20 height: 20
@ -161,7 +157,7 @@ export default StyleSheet.create({
justifyContent: 'flex-start' justifyContent: 'flex-start'
}, },
imageContainer: { imageContainer: {
flex: 1, // flex: 1,
flexDirection: 'column', flexDirection: 'column',
borderRadius: 4 borderRadius: 4
}, },
@ -173,6 +169,9 @@ export default StyleSheet.create({
borderColor: COLOR_BORDER, borderColor: COLOR_BORDER,
borderWidth: 1 borderWidth: 1
}, },
imagePressed: {
opacity: 0.5
},
inlineImage: { inlineImage: {
width: 300, width: 300,
height: 300, height: 300,
@ -220,7 +219,7 @@ export default StyleSheet.create({
}, },
repliedThread: { repliedThread: {
flexDirection: 'row', flexDirection: 'row',
flex: 1, // flex: 1,
alignItems: 'center', alignItems: 'center',
marginTop: 6, marginTop: 6,
marginBottom: 12 marginBottom: 12

View File

@ -0,0 +1,116 @@
import moment from 'moment';
import I18n from '../../i18n';
import database from '../../lib/realm';
import { DISCUSSION } from './constants';
export const formatLastMessage = (lm, customFormat) => {
if (customFormat) {
return moment(lm).format(customFormat);
}
return lm ? moment(lm).calendar(null, {
lastDay: `[${ I18n.t('Yesterday') }]`,
sameDay: 'h:mm A',
lastWeek: 'dddd',
sameElse: 'MMM D'
}) : null;
};
export const formatMessageCount = (count, type) => {
const discussion = type === DISCUSSION;
let text = discussion ? I18n.t('No_messages_yet') : null;
if (count === 1) {
text = `${ count } ${ discussion ? I18n.t('message') : I18n.t('reply') }`;
} else if (count > 1 && count < 1000) {
text = `${ count } ${ discussion ? I18n.t('messages') : I18n.t('replies') }`;
} else if (count > 999) {
text = `+999 ${ discussion ? I18n.t('messages') : I18n.t('replies') }`;
}
return text;
};
export const BUTTON_HIT_SLOP = {
top: 4, right: 4, bottom: 4, left: 4
};
export const SYSTEM_MESSAGES = [
'r',
'au',
'ru',
'ul',
'uj',
'ut',
'rm',
'user-muted',
'user-unmuted',
'message_pinned',
'subscription-role-added',
'subscription-role-removed',
'room_changed_description',
'room_changed_announcement',
'room_changed_topic',
'room_changed_privacy',
'message_snippeted',
'thread-created'
];
export const getInfoMessage = ({
type, role, msg, author
}) => {
const { username } = author;
if (type === 'rm') {
return I18n.t('Message_removed');
} else if (type === 'uj') {
return I18n.t('Has_joined_the_channel');
} else if (type === 'ut') {
return I18n.t('Has_joined_the_conversation');
} else if (type === 'r') {
return I18n.t('Room_name_changed', { name: msg, userBy: username });
} else if (type === 'message_pinned') {
return I18n.t('Message_pinned');
} else if (type === 'ul') {
return I18n.t('Has_left_the_channel');
} else if (type === 'ru') {
return I18n.t('User_removed_by', { userRemoved: msg, userBy: username });
} else if (type === 'au') {
return I18n.t('User_added_by', { userAdded: msg, userBy: username });
} else if (type === 'user-muted') {
return I18n.t('User_muted_by', { userMuted: msg, userBy: username });
} else if (type === 'user-unmuted') {
return I18n.t('User_unmuted_by', { userUnmuted: msg, userBy: username });
} else if (type === 'subscription-role-added') {
return `${ msg } was set ${ role } by ${ username }`;
} else if (type === 'subscription-role-removed') {
return `${ msg } is no longer ${ role } by ${ username }`;
} else if (type === 'room_changed_description') {
return I18n.t('Room_changed_description', { description: msg, userBy: username });
} else if (type === 'room_changed_announcement') {
return I18n.t('Room_changed_announcement', { announcement: msg, userBy: username });
} else if (type === 'room_changed_topic') {
return I18n.t('Room_changed_topic', { topic: msg, userBy: username });
} else if (type === 'room_changed_privacy') {
return I18n.t('Room_changed_privacy', { type: msg, userBy: username });
} else if (type === 'message_snippeted') {
return I18n.t('Created_snippet');
}
return '';
};
export const getCustomEmoji = (content) => {
// search by name
const data = database.objects('customEmojis').filtered('name == $0', content);
if (data.length) {
return data[0];
}
// searches by alias
// RealmJS doesn't support IN operator: https://github.com/realm/realm-js/issues/450
const emojis = database.objects('customEmojis');
const findByAlias = emojis.find((emoji) => {
if (emoji.aliases.length && emoji.aliases.findIndex(alias => alias === content) !== -1) {
return true;
}
return false;
});
return findByAlias;
};

View File

@ -148,13 +148,14 @@ export default {
Dont_Have_An_Account: 'Don\'t have an account?', Dont_Have_An_Account: 'Don\'t have an account?',
Do_you_really_want_to_key_this_room_question_mark: 'Do you really want to {{key}} this room?', Do_you_really_want_to_key_this_room_question_mark: 'Do you really want to {{key}} this room?',
edit: 'edit', edit: 'edit',
erasing_room: 'erasing room', edited: 'edited',
Edit: 'Edit', Edit: 'Edit',
Email_or_password_field_is_empty: 'Email or password field is empty', Email_or_password_field_is_empty: 'Email or password field is empty',
Email: 'Email', Email: 'Email',
email: 'e-mail', email: 'e-mail',
Enable_notifications: 'Enable notifications', Enable_notifications: 'Enable notifications',
Everyone_can_access_this_channel: 'Everyone can access this channel', Everyone_can_access_this_channel: 'Everyone can access this channel',
erasing_room: 'erasing room',
Error_uploading: 'Error uploading', Error_uploading: 'Error uploading',
Favorites: 'Favorites', Favorites: 'Favorites',
Files: 'Files', Files: 'Files',

View File

@ -154,6 +154,7 @@ export default {
Dont_Have_An_Account: 'Não tem uma conta?', Dont_Have_An_Account: 'Não tem uma conta?',
Do_you_really_want_to_key_this_room_question_mark: 'Você quer realmente {{key}} esta sala?', Do_you_really_want_to_key_this_room_question_mark: 'Você quer realmente {{key}} esta sala?',
edit: 'editar', edit: 'editar',
edited: 'editado',
erasing_room: 'apagando sala', erasing_room: 'apagando sala',
Edit: 'Editar', Edit: 'Editar',
Email_or_password_field_is_empty: 'Email ou senha estão vazios', Email_or_password_field_is_empty: 'Email ou senha estão vazios',

View File

@ -3,7 +3,6 @@ import semver from 'semver';
import reduxStore from '../createStore'; import reduxStore from '../createStore';
import database from '../realm'; import database from '../realm';
import * as actions from '../../actions';
import log from '../../utils/log'; import log from '../../utils/log';
const getUpdatedSince = () => { const getUpdatedSince = () => {
@ -17,7 +16,7 @@ const create = (customEmojis) => {
try { try {
database.create('customEmojis', emoji, true); database.create('customEmojis', emoji, true);
} catch (e) { } catch (e) {
log('getEmojis create', e); // log('getEmojis create', e);
} }
}); });
} }
@ -40,7 +39,6 @@ export default async function() {
database.write(() => { database.write(() => {
create(emojis); create(emojis);
}); });
reduxStore.dispatch(actions.setCustomEmojis(this.parseEmojis(result.emojis)));
}); });
} else { } else {
const params = {}; const params = {};
@ -72,9 +70,6 @@ export default async function() {
} }
}); });
} }
const allEmojis = database.objects('customEmojis');
reduxStore.dispatch(actions.setCustomEmojis(this.parseEmojis(allEmojis)));
}) })
); );
} }

View File

@ -27,9 +27,8 @@ export const merge = (subscription, room) => {
if (!subscription.roles || !subscription.roles.length) { if (!subscription.roles || !subscription.roles.length) {
subscription.roles = []; subscription.roles = [];
} }
if (room.muted && room.muted.length) { if (room.muted && room.muted.length) {
subscription.muted = room.muted.filter(user => user).map(user => ({ value: user })); subscription.muted = room.muted.filter(muted => !!muted);
} else { } else {
subscription.muted = []; subscription.muted = [];
} }

View File

@ -33,7 +33,7 @@ export default (msg) => {
// msg.reactions = Object.keys(msg.reactions).map(key => ({ emoji: key, usernames: msg.reactions[key].usernames.map(username => ({ value: username })) })); // msg.reactions = Object.keys(msg.reactions).map(key => ({ emoji: key, usernames: msg.reactions[key].usernames.map(username => ({ value: username })) }));
// } // }
if (!Array.isArray(msg.reactions)) { if (!Array.isArray(msg.reactions)) {
msg.reactions = Object.keys(msg.reactions).map(key => ({ _id: `${ msg._id }${ key }`, emoji: key, usernames: msg.reactions[key].usernames.map(username => ({ value: username })) })); msg.reactions = Object.keys(msg.reactions).map(key => ({ _id: `${ msg._id }${ key }`, emoji: key, usernames: msg.reactions[key].usernames }));
} }
msg.urls = msg.urls ? parseUrls(msg.urls) : []; msg.urls = msg.urls ? parseUrls(msg.urls) : [];
msg._updatedAt = new Date(); msg._updatedAt = new Date();

View File

@ -125,7 +125,7 @@ export default function subscribeRoom({ rid }) {
const read = debounce(() => { const read = debounce(() => {
const [room] = database.objects('subscriptions').filtered('rid = $0', rid); const [room] = database.objects('subscriptions').filtered('rid = $0', rid);
if (room._id) { if (room && room._id) {
this.readMessages(rid); this.readMessages(rid);
} }
}, 300); }, 300);

View File

@ -43,18 +43,11 @@ const roomsSchema = {
primaryKey: '_id', primaryKey: '_id',
properties: { properties: {
_id: 'string', _id: 'string',
name: 'string?',
broadcast: { type: 'bool', optional: true } broadcast: { type: 'bool', optional: true }
} }
}; };
const userMutedInRoomSchema = {
name: 'usersMuted',
primaryKey: 'value',
properties: {
value: 'string'
}
};
const subscriptionSchema = { const subscriptionSchema = {
name: 'subscriptions', name: 'subscriptions',
primaryKey: '_id', primaryKey: '_id',
@ -85,7 +78,7 @@ const subscriptionSchema = {
archived: { type: 'bool', optional: true }, archived: { type: 'bool', optional: true },
joinCodeRequired: { type: 'bool', optional: true }, joinCodeRequired: { type: 'bool', optional: true },
notifications: { type: 'bool', optional: true }, notifications: { type: 'bool', optional: true },
muted: { type: 'list', objectType: 'usersMuted' }, muted: 'string[]',
broadcast: { type: 'bool', optional: true }, broadcast: { type: 'bool', optional: true },
prid: { type: 'string', optional: true }, prid: { type: 'string', optional: true },
draftMessage: { type: 'string', optional: true }, draftMessage: { type: 'string', optional: true },
@ -99,8 +92,7 @@ const usersSchema = {
properties: { properties: {
_id: 'string', _id: 'string',
username: 'string', username: 'string',
name: { type: 'string', optional: true }, name: { type: 'string', optional: true }
avatarVersion: { type: 'int', optional: true }
} }
}; };
@ -155,21 +147,13 @@ const url = {
} }
}; };
const messagesReactionsUsernamesSchema = {
name: 'messagesReactionsUsernames',
primaryKey: 'value',
properties: {
value: 'string'
}
};
const messagesReactionsSchema = { const messagesReactionsSchema = {
name: 'messagesReactions', name: 'messagesReactions',
primaryKey: '_id', primaryKey: '_id',
properties: { properties: {
_id: 'string', _id: 'string',
emoji: 'string', emoji: 'string',
usernames: { type: 'list', objectType: 'messagesReactionsUsernames' } usernames: 'string[]'
} }
}; };
@ -211,7 +195,9 @@ const messagesSchema = {
tmid: { type: 'string', optional: true }, tmid: { type: 'string', optional: true },
tcount: { type: 'int', optional: true }, tcount: { type: 'int', optional: true },
tlm: { type: 'date', optional: true }, tlm: { type: 'date', optional: true },
replies: 'string[]' replies: 'string[]',
mentions: { type: 'list', objectType: 'users' },
channels: { type: 'list', objectType: 'rooms' }
} }
}; };
@ -359,9 +345,7 @@ const schema = [
frequentlyUsedEmojiSchema, frequentlyUsedEmojiSchema,
customEmojisSchema, customEmojisSchema,
messagesReactionsSchema, messagesReactionsSchema,
messagesReactionsUsernamesSchema,
rolesSchema, rolesSchema,
userMutedInRoomSchema,
uploadsSchema uploadsSchema
]; ];
@ -374,9 +358,9 @@ class DB {
schema: [ schema: [
serversSchema serversSchema
], ],
schemaVersion: 6, schemaVersion: 8,
migration: (oldRealm, newRealm) => { migration: (oldRealm, newRealm) => {
if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 6) { if (oldRealm.schemaVersion >= 1 && newRealm.schemaVersion <= 8) {
const newServers = newRealm.objects('servers'); const newServers = newRealm.objects('servers');
// eslint-disable-next-line no-plusplus // eslint-disable-next-line no-plusplus
@ -431,16 +415,11 @@ class DB {
return this.databases.activeDB = new Realm({ return this.databases.activeDB = new Realm({
path: `${ path }.realm`, path: `${ path }.realm`,
schema, schema,
schemaVersion: 9, schemaVersion: 11,
migration: (oldRealm, newRealm) => { migration: (oldRealm, newRealm) => {
if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 8) { if (oldRealm.schemaVersion >= 3 && newRealm.schemaVersion <= 11) {
const newSubs = newRealm.objects('subscriptions'); const newSubs = newRealm.objects('subscriptions');
newRealm.delete(newSubs);
// eslint-disable-next-line no-plusplus
for (let i = 0; i < newSubs.length; i++) {
newSubs[i].lastOpen = null;
newSubs[i].ls = null;
}
const newMessages = newRealm.objects('messages'); const newMessages = newRealm.objects('messages');
newRealm.delete(newMessages); newRealm.delete(newMessages);
const newThreads = newRealm.objects('threads'); const newThreads = newRealm.objects('threads');
@ -449,8 +428,6 @@ class DB {
newRealm.delete(newThreadMessages); newRealm.delete(newThreadMessages);
} }
if (newRealm.schemaVersion === 9) { if (newRealm.schemaVersion === 9) {
const newSubs = newRealm.objects('subscriptions');
newRealm.delete(newSubs);
const newEmojis = newRealm.objects('customEmojis'); const newEmojis = newRealm.objects('customEmojis');
newRealm.delete(newEmojis); newRealm.delete(newEmojis);
const newSettings = newRealm.objects('settings'); const newSettings = newRealm.objects('settings');

View File

@ -472,19 +472,6 @@ const RocketChat = {
return setting; return setting;
}); });
}, },
parseEmojis: emojis => emojis.reduce((ret, item) => {
ret[item.name] = item.extension;
item.aliases.forEach((alias) => {
ret[alias.value] = item.extension;
});
return ret;
}, {}),
_prepareEmojis(emojis) {
emojis.forEach((emoji) => {
emoji.aliases = emoji.aliases.map(alias => ({ value: alias }));
});
return emojis;
},
deleteMessage(message) { deleteMessage(message) {
const { _id, rid } = message; const { _id, rid } = message;
// RC 0.48.0 // RC 0.48.0

3
app/lib/utils.js Normal file
View File

@ -0,0 +1,3 @@
export const formatAttachmentUrl = (attachmentUrl, userId, token, server) => (
encodeURI(attachmentUrl.includes('http') ? attachmentUrl : `${ server }${ attachmentUrl }?rc_uid=${ userId }&rc_token=${ token }`)
);

View File

@ -1,17 +0,0 @@
import * as types from '../constants/types';
const initialState = {
customEmojis: {}
};
export default function customEmojis(state = initialState.customEmojis, action) {
if (action.type === types.SET_CUSTOM_EMOJIS) {
return {
...state,
...action.payload
};
}
return state;
}

View File

@ -8,7 +8,6 @@ import server from './server';
import selectedUsers from './selectedUsers'; import selectedUsers from './selectedUsers';
import createChannel from './createChannel'; import createChannel from './createChannel';
import app from './app'; import app from './app';
import customEmojis from './customEmojis';
import sortPreferences from './sortPreferences'; import sortPreferences from './sortPreferences';
export default combineReducers({ export default combineReducers({
@ -21,6 +20,5 @@ export default combineReducers({
createChannel, createChannel,
app, app,
rooms, rooms,
customEmojis,
sortPreferences sortPreferences
}); });

View File

@ -19,7 +19,13 @@ const handleRoomsRequest = function* handleRoomsRequest() {
const { subscriptions } = mergeSubscriptionsRooms(subscriptionsResult, roomsResult); const { subscriptions } = mergeSubscriptionsRooms(subscriptionsResult, roomsResult);
database.write(() => { database.write(() => {
subscriptions.forEach(subscription => database.create('subscriptions', subscription, true)); subscriptions.forEach((subscription) => {
try {
database.create('subscriptions', subscription, true);
} catch (error) {
log('handleRoomsRequest create sub', error);
}
});
}); });
database.databases.serversDB.write(() => { database.databases.serversDB.write(() => {
try { try {

View File

@ -51,8 +51,6 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
const settings = database.objects('settings'); const settings = database.objects('settings');
yield put(actions.setAllSettings(RocketChat.parseSettings(settings.slice(0, settings.length)))); yield put(actions.setAllSettings(RocketChat.parseSettings(settings.slice(0, settings.length))));
const emojis = database.objects('customEmojis');
yield put(actions.setCustomEmojis(RocketChat.parseEmojis(emojis.slice(0, emojis.length))));
yield put(selectServerSuccess(server, fetchVersion ? serverInfo && serverInfo.version : version)); yield put(selectServerSuccess(server, fetchVersion ? serverInfo && serverInfo.version : version));
} catch (e) { } catch (e) {

View File

@ -14,13 +14,13 @@ import I18n from '../../i18n';
import RocketChat from '../../lib/rocketchat'; import RocketChat from '../../lib/rocketchat';
import StatusBar from '../../containers/StatusBar'; import StatusBar from '../../containers/StatusBar';
import getFileUrlFromMessage from '../../lib/methods/helpers/getFileUrlFromMessage'; import getFileUrlFromMessage from '../../lib/methods/helpers/getFileUrlFromMessage';
import FileModal from '../../containers/FileModal';
const ACTION_INDEX = 0; const ACTION_INDEX = 0;
const CANCEL_INDEX = 1; const CANCEL_INDEX = 1;
@connect(state => ({ @connect(state => ({
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
customEmojis: state.customEmojis,
user: { user: {
id: state.login.user && state.login.user.id, id: state.login.user && state.login.user.id,
username: state.login.user && state.login.user.username, username: state.login.user && state.login.user.username,
@ -36,7 +36,6 @@ export default class MessagesView extends LoggedView {
static propTypes = { static propTypes = {
user: PropTypes.object, user: PropTypes.object,
baseUrl: PropTypes.string, baseUrl: PropTypes.string,
customEmojis: PropTypes.object,
navigation: PropTypes.object navigation: PropTypes.object
} }
@ -44,7 +43,9 @@ export default class MessagesView extends LoggedView {
super('MessagesView', props); super('MessagesView', props);
this.state = { this.state = {
loading: false, loading: false,
messages: [] messages: [],
selectedAttachment: {},
photoModalVisible: false
}; };
this.rid = props.navigation.getParam('rid'); this.rid = props.navigation.getParam('rid');
this.t = props.navigation.getParam('t'); this.t = props.navigation.getParam('t');
@ -56,10 +57,13 @@ export default class MessagesView extends LoggedView {
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const { loading, messages } = this.state; const { loading, messages, photoModalVisible } = this.state;
if (nextState.loading !== loading) { if (nextState.loading !== loading) {
return true; return true;
} }
if (nextState.photoModalVisible !== photoModalVisible) {
return true;
}
if (!equal(nextState.messages, messages)) { if (!equal(nextState.messages, messages)) {
return true; return true;
} }
@ -68,18 +72,18 @@ export default class MessagesView extends LoggedView {
defineMessagesViewContent = (name) => { defineMessagesViewContent = (name) => {
const { messages } = this.state; const { messages } = this.state;
const { user, baseUrl, customEmojis } = this.props; const { user, baseUrl } = this.props;
const renderItemCommonProps = item => ({ const renderItemCommonProps = item => ({
customEmojis,
baseUrl, baseUrl,
user, user,
author: item.u || item.user, author: item.u || item.user,
ts: item.ts || item.uploadedAt, ts: item.ts || item.uploadedAt,
timeFormat: 'MMM Do YYYY, h:mm:ss a', timeFormat: 'MMM Do YYYY, h:mm:ss a',
edited: !!item.editedAt, isEdited: !!item.editedAt,
header: true, isHeader: true,
attachments: item.attachments || [] attachments: item.attachments || [],
onOpenFileModal: this.onOpenFileModal
}); });
return ({ return ({
@ -190,6 +194,14 @@ export default class MessagesView extends LoggedView {
} }
} }
onOpenFileModal = (attachment) => {
this.setState({ selectedAttachment: attachment, photoModalVisible: true });
}
onCloseFileModal = () => {
this.setState({ selectedAttachment: {}, photoModalVisible: false });
}
onLongPress = (message) => { onLongPress = (message) => {
this.setState({ message }); this.setState({ message });
this.showActionSheet(); this.showActionSheet();
@ -232,7 +244,10 @@ export default class MessagesView extends LoggedView {
renderItem = ({ item }) => this.content.renderItem(item) renderItem = ({ item }) => this.content.renderItem(item)
render() { render() {
const { messages, loading } = this.state; const {
messages, loading, selectedAttachment, photoModalVisible
} = this.state;
const { user, baseUrl } = this.props;
if (!loading && messages.length === 0) { if (!loading && messages.length === 0) {
return this.renderEmpty(); return this.renderEmpty();
@ -249,6 +264,13 @@ export default class MessagesView extends LoggedView {
onEndReached={this.load} onEndReached={this.load}
ListFooterComponent={loading ? <RCActivityIndicator /> : null} ListFooterComponent={loading ? <RCActivityIndicator /> : null}
/> />
<FileModal
attachment={selectedAttachment}
isVisible={photoModalVisible}
onClose={this.onCloseFileModal}
user={user}
baseUrl={baseUrl}
/>
</SafeAreaView> </SafeAreaView>
); );
} }

View File

@ -8,8 +8,9 @@ import { isIOS } from '../utils/deviceInfo';
import { CloseModalButton } from '../containers/HeaderButton'; import { CloseModalButton } from '../containers/HeaderButton';
import StatusBar from '../containers/StatusBar'; import StatusBar from '../containers/StatusBar';
const userAgentAndroid = 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1'; const userAgent = isIOS
const userAgent = isIOS ? 'UserAgent' : userAgentAndroid; ? 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1'
: 'Mozilla/5.0 (Linux; Android 6.0.1; SM-G920V Build/MMB29K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.98 Mobile Safari/537.36';
@connect(state => ({ @connect(state => ({
server: state.server.server server: state.server.server
@ -62,6 +63,7 @@ export default class OAuthView extends React.PureComponent {
<React.Fragment> <React.Fragment>
<StatusBar /> <StatusBar />
<WebView <WebView
useWebKit
source={{ uri: oAuthUrl }} source={{ uri: oAuthUrl }}
userAgent={userAgent} userAgent={userAgent}
onNavigationStateChange={(webViewState) => { onNavigationStateChange={(webViewState) => {

View File

@ -158,7 +158,7 @@ export default class RoomMembersView extends LoggedView {
const { muted } = room; const { muted } = room;
this.actionSheetOptions = [I18n.t('Cancel')]; this.actionSheetOptions = [I18n.t('Cancel')];
const userIsMuted = !!muted.find(m => m.value === user.username); const userIsMuted = !!muted.find(m => m === user.username);
user.muted = userIsMuted; user.muted = userIsMuted;
if (userIsMuted) { if (userIsMuted) {
this.actionSheetOptions.push(I18n.t('Unmute')); this.actionSheetOptions.push(I18n.t('Unmute'));

View File

@ -46,7 +46,9 @@ class RightButtonsContainer extends React.PureComponent {
} }
componentDidMount() { componentDidMount() {
safeAddListener(this.thread, this.updateThread); if (this.thread) {
safeAddListener(this.thread, this.updateThread);
}
} }
componentWillUnmount() { componentWillUnmount() {

View File

@ -147,11 +147,11 @@ export class List extends React.PureComponent {
style={styles.list} style={styles.list}
inverted inverted
removeClippedSubviews removeClippedSubviews
initialNumToRender={5} initialNumToRender={7}
onEndReached={this.onEndReached} onEndReached={this.onEndReached}
onEndReachedThreshold={0.5} onEndReachedThreshold={5}
maxToRenderPerBatch={5} maxToRenderPerBatch={5}
windowSize={21} windowSize={10}
ListFooterComponent={this.renderFooter} ListFooterComponent={this.renderFooter}
{...scrollPersistTaps} {...scrollPersistTaps}
/> />

View File

@ -13,8 +13,10 @@ import EJSON from 'ejson';
import { import {
toggleReactionPicker as toggleReactionPickerAction, toggleReactionPicker as toggleReactionPickerAction,
actionsShow as actionsShowAction, actionsShow as actionsShowAction,
errorActionsShow as errorActionsShowAction,
editCancel as editCancelAction, editCancel as editCancelAction,
replyCancel as replyCancelAction replyCancel as replyCancelAction,
replyBroadcast as replyBroadcastAction
} from '../../actions/messages'; } from '../../actions/messages';
import LoggedView from '../View'; import LoggedView from '../View';
import { List } from './List'; import { List } from './List';
@ -37,6 +39,9 @@ import Separator from './Separator';
import { COLOR_WHITE } from '../../constants/colors'; import { COLOR_WHITE } from '../../constants/colors';
import debounce from '../../utils/debounce'; import debounce from '../../utils/debounce';
import buildMessage from '../../lib/methods/helpers/buildMessage'; import buildMessage from '../../lib/methods/helpers/buildMessage';
import FileModal from '../../containers/FileModal';
import { vibrate } from '../../utils/vibration';
import ReactionsModal from '../../containers/ReactionsModal';
import { Toast } from '../../utils/info'; import { Toast } from '../../utils/info';
@connect(state => ({ @connect(state => ({
@ -52,12 +57,17 @@ import { Toast } from '../../utils/info';
showErrorActions: state.messages.showErrorActions, showErrorActions: state.messages.showErrorActions,
appState: state.app.ready && state.app.foreground ? 'foreground' : 'background', appState: state.app.ready && state.app.foreground ? 'foreground' : 'background',
useRealName: state.settings.UI_Use_Real_Name, useRealName: state.settings.UI_Use_Real_Name,
isAuthenticated: state.login.isAuthenticated isAuthenticated: state.login.isAuthenticated,
Message_GroupingPeriod: state.settings.Message_GroupingPeriod,
Message_TimeFormat: state.settings.Message_TimeFormat,
baseUrl: state.settings.baseUrl || state.server ? state.server.server : ''
}), dispatch => ({ }), dispatch => ({
editCancel: () => dispatch(editCancelAction()), editCancel: () => dispatch(editCancelAction()),
replyCancel: () => dispatch(replyCancelAction()), replyCancel: () => dispatch(replyCancelAction()),
toggleReactionPicker: message => dispatch(toggleReactionPickerAction(message)), toggleReactionPicker: message => dispatch(toggleReactionPickerAction(message)),
actionsShow: actionMessage => dispatch(actionsShowAction(actionMessage)) errorActionsShow: actionMessage => dispatch(errorActionsShowAction(actionMessage)),
actionsShow: actionMessage => dispatch(actionsShowAction(actionMessage)),
replyBroadcast: message => dispatch(replyBroadcastAction(message))
})) }))
/** @extends React.Component */ /** @extends React.Component */
export default class RoomView extends LoggedView { export default class RoomView extends LoggedView {
@ -105,12 +115,17 @@ export default class RoomView extends LoggedView {
appState: PropTypes.string, appState: PropTypes.string,
useRealName: PropTypes.bool, useRealName: PropTypes.bool,
isAuthenticated: PropTypes.bool, isAuthenticated: PropTypes.bool,
Message_GroupingPeriod: PropTypes.number,
Message_TimeFormat: PropTypes.string,
editing: PropTypes.bool, editing: PropTypes.bool,
replying: PropTypes.bool, replying: PropTypes.bool,
toggleReactionPicker: PropTypes.func.isRequired, baseUrl: PropTypes.string,
toggleReactionPicker: PropTypes.func,
actionsShow: PropTypes.func, actionsShow: PropTypes.func,
editCancel: PropTypes.func, editCancel: PropTypes.func,
replyCancel: PropTypes.func replyCancel: PropTypes.func,
replyBroadcast: PropTypes.func,
errorActionsShow: PropTypes.func
}; };
constructor(props) { constructor(props) {
@ -120,16 +135,20 @@ export default class RoomView extends LoggedView {
this.rid = props.navigation.getParam('rid'); this.rid = props.navigation.getParam('rid');
this.t = props.navigation.getParam('t'); this.t = props.navigation.getParam('t');
this.tmid = props.navigation.getParam('tmid'); this.tmid = props.navigation.getParam('tmid');
this.useMarkdown = props.navigation.getParam('useMarkdown', true);
this.rooms = database.objects('subscriptions').filtered('rid = $0', this.rid); this.rooms = database.objects('subscriptions').filtered('rid = $0', this.rid);
this.state = { this.state = {
joined: this.rooms.length > 0, joined: this.rooms.length > 0,
room: this.rooms[0] || { rid: this.rid, t: this.t }, room: this.rooms[0] || { rid: this.rid, t: this.t },
lastOpen: null lastOpen: null,
photoModalVisible: false,
reactionsModalVisible: false,
selectedAttachment: {},
selectedMessage: {}
}; };
this.beginAnimating = false; this.beginAnimating = false;
this.beginAnimatingTimeout = setTimeout(() => this.beginAnimating = true, 300); this.beginAnimatingTimeout = setTimeout(() => this.beginAnimating = true, 300);
this.messagebox = React.createRef(); this.messagebox = React.createRef();
safeAddListener(this.rooms, this.updateRoom);
this.willBlurListener = props.navigation.addListener('willBlur', () => this.mounted = false); this.willBlurListener = props.navigation.addListener('willBlur', () => this.mounted = false);
this.mounted = false; this.mounted = false;
console.timeEnd(`${ this.constructor.name } init`); console.timeEnd(`${ this.constructor.name } init`);
@ -152,6 +171,7 @@ export default class RoomView extends LoggedView {
} else { } else {
EventEmitter.addEventListener('connected', this.handleConnected); EventEmitter.addEventListener('connected', this.handleConnected);
} }
safeAddListener(this.rooms, this.updateRoom);
this.mounted = true; this.mounted = true;
}); });
console.timeEnd(`${ this.constructor.name } mount`); console.timeEnd(`${ this.constructor.name } mount`);
@ -159,12 +179,16 @@ export default class RoomView extends LoggedView {
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const { const {
room, joined, lastOpen room, joined, lastOpen, photoModalVisible, reactionsModalVisible
} = this.state; } = this.state;
const { showActions, showErrorActions, appState } = this.props; const { showActions, showErrorActions, appState } = this.props;
if (lastOpen !== nextState.lastOpen) { if (lastOpen !== nextState.lastOpen) {
return true; return true;
} else if (photoModalVisible !== nextState.photoModalVisible) {
return true;
} else if (reactionsModalVisible !== nextState.reactionsModalVisible) {
return true;
} else if (room.ro !== nextState.room.ro) { } else if (room.ro !== nextState.room.ro) {
return true; return true;
} else if (room.f !== nextState.room.f) { } else if (room.f !== nextState.room.f) {
@ -285,6 +309,14 @@ export default class RoomView extends LoggedView {
actionsShow({ ...message, rid: this.rid }); actionsShow({ ...message, rid: this.rid });
} }
onOpenFileModal = (attachment) => {
this.setState({ selectedAttachment: attachment, photoModalVisible: true });
}
onCloseFileModal = () => {
this.setState({ selectedAttachment: {}, photoModalVisible: false });
}
onReactionPress = (shortname, messageId) => { onReactionPress = (shortname, messageId) => {
const { actionMessage, toggleReactionPicker } = this.props; const { actionMessage, toggleReactionPicker } = this.props;
try { try {
@ -298,6 +330,15 @@ export default class RoomView extends LoggedView {
} }
}; };
onReactionLongPress = (message) => {
this.setState({ selectedMessage: message, reactionsModalVisible: true });
vibrate();
}
onCloseReactionsModal = () => {
this.setState({ selectedMessage: {}, reactionsModalVisible: false });
}
onDiscussionPress = debounce((item) => { onDiscussionPress = debounce((item) => {
const { navigation } = this.props; const { navigation } = this.props;
navigation.push('RoomView', { navigation.push('RoomView', {
@ -305,6 +346,35 @@ export default class RoomView extends LoggedView {
}); });
}, 1000, true) }, 1000, true)
onThreadPress = debounce((item) => {
const { navigation } = this.props;
if (item.tmid) {
navigation.push('RoomView', {
rid: item.rid, tmid: item.tmid, name: item.tmsg, t: 'thread'
});
} else if (item.tlm) {
const title = item.msg || (item.attachments && item.attachments.length && item.attachments[0].title);
navigation.push('RoomView', {
rid: item.rid, tmid: item._id, name: title, t: 'thread'
});
}
}, 1000, true)
toggleReactionPicker = (message) => {
const { toggleReactionPicker } = this.props;
toggleReactionPicker(message);
}
replyBroadcast = (message) => {
const { replyBroadcast } = this.props;
replyBroadcast(message);
}
errorActionsShow = (message) => {
const { errorActionsShow } = this.props;
errorActionsShow(message);
}
handleConnected = () => { handleConnected = () => {
this.init(); this.init();
EventEmitter.removeListener('connected', this.handleConnected); EventEmitter.removeListener('connected', this.handleConnected);
@ -365,7 +435,7 @@ export default class RoomView extends LoggedView {
} }
} }
setLastOpen = lastOpen => this.internalSetState({ lastOpen }); setLastOpen = lastOpen => this.setState({ lastOpen });
joinRoom = async() => { joinRoom = async() => {
try { try {
@ -388,7 +458,7 @@ export default class RoomView extends LoggedView {
isMuted = () => { isMuted = () => {
const { room } = this.state; const { room } = this.state;
const { user } = this.props; const { user } = this.props;
return room && room.muted && !!Array.from(Object.keys(room.muted), i => room.muted[i].value).includes(user.username); return room && room.muted && room.muted.find && !!room.muted.find(m => m === user.username);
} }
isReadOnly = () => { isReadOnly = () => {
@ -433,7 +503,9 @@ export default class RoomView extends LoggedView {
renderItem = (item, previousItem) => { renderItem = (item, previousItem) => {
const { room, lastOpen } = this.state; const { room, lastOpen } = this.state;
const { user, navigation } = this.props; const {
user, Message_GroupingPeriod, Message_TimeFormat, useRealName, baseUrl
} = this.props;
let dateSeparator = null; let dateSeparator = null;
let showUnreadSeparator = false; let showUnreadSeparator = false;
@ -459,11 +531,21 @@ export default class RoomView extends LoggedView {
status={item.status} status={item.status}
_updatedAt={item._updatedAt} _updatedAt={item._updatedAt}
previousItem={previousItem} previousItem={previousItem}
navigation={navigation}
fetchThreadName={this.fetchThreadName} fetchThreadName={this.fetchThreadName}
onReactionPress={this.onReactionPress} onReactionPress={this.onReactionPress}
onReactionLongPress={this.onReactionLongPress}
onLongPress={this.onMessageLongPress} onLongPress={this.onMessageLongPress}
onDiscussionPress={this.onDiscussionPress} onDiscussionPress={this.onDiscussionPress}
onThreadPress={this.onThreadPress}
onOpenFileModal={this.onOpenFileModal}
toggleReactionPicker={this.toggleReactionPicker}
replyBroadcast={this.replyBroadcast}
errorActionsShow={this.errorActionsShow}
baseUrl={baseUrl}
Message_GroupingPeriod={Message_GroupingPeriod}
timeFormat={Message_TimeFormat}
useRealName={useRealName}
useMarkdown={this.useMarkdown}
/> />
); );
@ -548,7 +630,10 @@ export default class RoomView extends LoggedView {
render() { render() {
console.count(`${ this.constructor.name }.render calls`); console.count(`${ this.constructor.name }.render calls`);
const { room } = this.state; const {
room, photoModalVisible, reactionsModalVisible, selectedAttachment, selectedMessage
} = this.state;
const { user, baseUrl } = this.props;
const { rid, t } = room; const { rid, t } = room;
return ( return (
@ -559,6 +644,20 @@ export default class RoomView extends LoggedView {
{this.renderActions()} {this.renderActions()}
<ReactionPicker onEmojiSelected={this.onReactionPress} /> <ReactionPicker onEmojiSelected={this.onReactionPress} />
<UploadProgress rid={this.rid} /> <UploadProgress rid={this.rid} />
<FileModal
attachment={selectedAttachment}
isVisible={photoModalVisible}
onClose={this.onCloseFileModal}
user={user}
baseUrl={baseUrl}
/>
<ReactionsModal
message={selectedMessage}
isVisible={reactionsModalVisible}
onClose={this.onCloseReactionsModal}
user={user}
baseUrl={baseUrl}
/>
<Toast ref={toast => this.toast = toast} /> <Toast ref={toast => this.toast = toast} />
</SafeAreaView> </SafeAreaView>
); );

View File

@ -66,6 +66,7 @@ export default class RoomsListView extends LoggedView {
const cancelSearchingAndroid = navigation.getParam('cancelSearchingAndroid'); const cancelSearchingAndroid = navigation.getParam('cancelSearchingAndroid');
const onPressItem = navigation.getParam('onPressItem', () => {}); const onPressItem = navigation.getParam('onPressItem', () => {});
const initSearchingAndroid = navigation.getParam('initSearchingAndroid', () => {}); const initSearchingAndroid = navigation.getParam('initSearchingAndroid', () => {});
const toggleUseMarkdown = navigation.getParam('toggleUseMarkdown', () => {});
return { return {
headerLeft: ( headerLeft: (
@ -75,7 +76,7 @@ export default class RoomsListView extends LoggedView {
<Item title='cancel' iconName='cross' onPress={cancelSearchingAndroid} /> <Item title='cancel' iconName='cross' onPress={cancelSearchingAndroid} />
</CustomHeaderButtons> </CustomHeaderButtons>
) )
: <DrawerButton navigation={navigation} testID='rooms-list-view-sidebar' /> : <DrawerButton navigation={navigation} testID='rooms-list-view-sidebar' onLongPress={toggleUseMarkdown} />
), ),
headerTitle: <RoomsListHeaderView />, headerTitle: <RoomsListHeaderView />,
headerRight: ( headerRight: (
@ -124,6 +125,7 @@ export default class RoomsListView extends LoggedView {
searching: false, searching: false,
search: [], search: [],
loading: true, loading: true,
useMarkdown: true,
chats: [], chats: [],
unread: [], unread: [],
favorites: [], favorites: [],
@ -142,7 +144,10 @@ export default class RoomsListView extends LoggedView {
this.getSubscriptions(); this.getSubscriptions();
const { navigation } = this.props; const { navigation } = this.props;
navigation.setParams({ navigation.setParams({
onPressItem: this._onPressItem, initSearchingAndroid: this.initSearchingAndroid, cancelSearchingAndroid: this.cancelSearchingAndroid onPressItem: this._onPressItem,
initSearchingAndroid: this.initSearchingAndroid,
cancelSearchingAndroid: this.cancelSearchingAndroid,
toggleUseMarkdown: this.toggleUseMarkdown
}); });
console.timeEnd(`${ this.constructor.name } mount`); console.timeEnd(`${ this.constructor.name } mount`);
} }
@ -311,6 +316,15 @@ export default class RoomsListView extends LoggedView {
} }
} }
// Just for tests purposes
toggleUseMarkdown = () => {
this.setState(({ useMarkdown }) => ({ useMarkdown: !useMarkdown }),
() => {
const { useMarkdown } = this.state;
alert(`Markdown ${ useMarkdown ? 'enabled' : 'disabled' }`);
});
}
// this is necessary during development (enables Cmd + r) // this is necessary during development (enables Cmd + r)
hasActiveDB = () => database && database.databases && database.databases.activeDB; hasActiveDB = () => database && database.databases && database.databases.activeDB;
@ -341,9 +355,10 @@ export default class RoomsListView extends LoggedView {
goRoom = (item) => { goRoom = (item) => {
this.cancelSearchingAndroid(); this.cancelSearchingAndroid();
const { useMarkdown } = this.state;
const { navigation } = this.props; const { navigation } = this.props;
navigation.navigate('RoomView', { navigation.navigate('RoomView', {
rid: item.rid, name: this.getRoomTitle(item), t: item.t, prid: item.prid rid: item.rid, name: this.getRoomTitle(item), t: item.t, prid: item.prid, useMarkdown
}); });
} }

View File

@ -19,7 +19,6 @@ import StatusBar from '../../containers/StatusBar';
@connect(state => ({ @connect(state => ({
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
customEmojis: state.customEmojis,
user: { user: {
id: state.login.user && state.login.user.id, id: state.login.user && state.login.user.id,
username: state.login.user && state.login.user.username, username: state.login.user && state.login.user.username,
@ -35,8 +34,7 @@ export default class SearchMessagesView extends LoggedView {
static propTypes = { static propTypes = {
navigation: PropTypes.object, navigation: PropTypes.object,
user: PropTypes.object, user: PropTypes.object,
baseUrl: PropTypes.string, baseUrl: PropTypes.string
customEmojis: PropTypes.object
} }
constructor(props) { constructor(props) {
@ -96,10 +94,9 @@ export default class SearchMessagesView extends LoggedView {
) )
renderItem = ({ item }) => { renderItem = ({ item }) => {
const { user, customEmojis, baseUrl } = this.props; const { user, baseUrl } = this.props;
return ( return (
<Message <Message
customEmojis={customEmojis}
baseUrl={baseUrl} baseUrl={baseUrl}
user={user} user={user}
author={item.u} author={item.u}
@ -107,8 +104,9 @@ export default class SearchMessagesView extends LoggedView {
msg={item.msg} msg={item.msg}
attachments={item.attachments || []} attachments={item.attachments || []}
timeFormat='MMM Do YYYY, h:mm:ss a' timeFormat='MMM Do YYYY, h:mm:ss a'
edited={!!item.editedAt} isEdited={!!item.editedAt}
header isHeader
onOpenFileModal={() => {}}
/> />
); );
} }
@ -145,7 +143,7 @@ export default class SearchMessagesView extends LoggedView {
placeholder={I18n.t('Search_Messages')} placeholder={I18n.t('Search_Messages')}
testID='search-message-view-input' testID='search-message-view-input'
/> />
<Markdown msg={I18n.t('You_can_search_using_RegExp_eg')} username='' baseUrl='' customEmojis={{}} /> <Markdown msg={I18n.t('You_can_search_using_RegExp_eg')} username='' baseUrl='' />
<View style={styles.divider} /> <View style={styles.divider} />
</View> </View>
{this.renderList()} {this.renderList()}

View File

@ -24,12 +24,12 @@ const API_FETCH_COUNT = 50;
@connect(state => ({ @connect(state => ({
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '', baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
customEmojis: state.customEmojis,
user: { user: {
id: state.login.user && state.login.user.id, id: state.login.user && state.login.user.id,
username: state.login.user && state.login.user.username, username: state.login.user && state.login.user.username,
token: state.login.user && state.login.user.token token: state.login.user && state.login.user.token
} },
useRealName: state.settings.UI_Use_Real_Name
})) }))
/** @extends React.Component */ /** @extends React.Component */
export default class ThreadMessagesView extends LoggedView { export default class ThreadMessagesView extends LoggedView {
@ -39,7 +39,9 @@ export default class ThreadMessagesView extends LoggedView {
static propTypes = { static propTypes = {
user: PropTypes.object, user: PropTypes.object,
navigation: PropTypes.object navigation: PropTypes.object,
baseUrl: PropTypes.string,
useRealName: PropTypes.bool
} }
constructor(props) { constructor(props) {
@ -82,6 +84,7 @@ export default class ThreadMessagesView extends LoggedView {
this.setState({ messages: this.messages }); this.setState({ messages: this.messages });
}, 300) }, 300)
// eslint-disable-next-line react/sort-comp
init = () => { init = () => {
const [room] = this.rooms; const [room] = this.rooms;
const lastThreadSync = new Date(); const lastThreadSync = new Date();
@ -186,6 +189,20 @@ export default class ThreadMessagesView extends LoggedView {
}) : null }) : null
) )
onThreadPress = debounce((item) => {
const { navigation } = this.props;
if (item.tmid) {
navigation.push('RoomView', {
rid: item.rid, tmid: item.tmid, name: item.tmsg, t: 'thread'
});
} else if (item.tlm) {
const title = item.msg || (item.attachments && item.attachments.length && item.attachments[0].title);
navigation.push('RoomView', {
rid: item.rid, tmid: item._id, name: title, t: 'thread'
});
}
}, 1000, true)
renderSeparator = () => <Separator /> renderSeparator = () => <Separator />
renderEmpty = () => ( renderEmpty = () => (
@ -195,7 +212,9 @@ export default class ThreadMessagesView extends LoggedView {
) )
renderItem = ({ item }) => { renderItem = ({ item }) => {
const { user, navigation } = this.props; const {
user, navigation, baseUrl, useRealName
} = this.props;
if (item.isValid && item.isValid()) { if (item.isValid && item.isValid()) {
return ( return (
<Message <Message
@ -207,10 +226,11 @@ export default class ThreadMessagesView extends LoggedView {
status={item.status} status={item.status}
_updatedAt={item._updatedAt} _updatedAt={item._updatedAt}
navigation={navigation} navigation={navigation}
customTimeFormat='MMM D' timeFormat='MMM D'
customThreadTimeFormat='MMM Do YYYY, h:mm:ss a' customThreadTimeFormat='MMM Do YYYY, h:mm:ss a'
fetchThreadName={this.fetchThreadName} onThreadPress={this.onThreadPress}
onDiscussionPress={this.onDiscussionPress} baseUrl={baseUrl}
useRealName={useRealName}
/> />
); );
} }

View File

@ -39,10 +39,6 @@
[self.window makeKeyAndVisible]; [self.window makeKeyAndVisible];
[Fabric with:@[[Crashlytics class]]]; [Fabric with:@[[Crashlytics class]]];
NSString *newAgent = @"Mozilla/5.0 (iPhone; CPU iPhone OS 10_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko) Version/10.0 Mobile/14E304 Safari/602.1";
NSDictionary *dictionary = [[NSDictionary alloc] initWithObjectsAndKeys:newAgent, @"UserAgent", nil];
[[NSUserDefaults standardUserDefaults] registerDefaults:dictionary];
[RNSplashScreen show]; [RNSplashScreen show];
return YES; return YES;

View File

@ -59,6 +59,7 @@
"react-native-screens": "^1.0.0-alpha.22", "react-native-screens": "^1.0.0-alpha.22",
"react-native-scrollable-tab-view": "0.10.0", "react-native-scrollable-tab-view": "0.10.0",
"react-native-slider": "^0.11.0", "react-native-slider": "^0.11.0",
"react-native-slowlog": "^1.0.2",
"react-native-splash-screen": "^3.2.0", "react-native-splash-screen": "^3.2.0",
"react-native-vector-icons": "^6.4.2", "react-native-vector-icons": "^6.4.2",
"react-native-video": "^4.4.1", "react-native-video": "^4.4.1",

View File

@ -24,19 +24,27 @@ const author = {
username: 'diego.mello' username: 'diego.mello'
}; };
const baseUrl = 'https://open.rocket.chat'; const baseUrl = 'https://open.rocket.chat';
const customEmojis = { react_rocket: 'png', nyan_rocket: 'png', marioparty: 'gif' };
const date = new Date(2017, 10, 10, 10); const date = new Date(2017, 10, 10, 10);
const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.'; const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.';
const getCustomEmoji = (content) => {
const customEmoji = {
marioparty: { name: content, extension: 'gif' },
react_rocket: { name: content, extension: 'png' },
nyan_rocket: { name: content, extension: 'png' }
}[content];
return customEmoji;
};
const Message = props => ( const Message = props => (
<MessageComponent <MessageComponent
baseUrl={baseUrl} baseUrl={baseUrl}
customEmojis={customEmojis}
user={user} user={user}
author={author} author={author}
ts={date} ts={date}
timeFormat='LT' timeFormat='LT'
header isHeader
getCustomEmoji={getCustomEmoji}
{...props} {...props}
/> />
); );
@ -62,12 +70,12 @@ export default (
username: longText username: longText
}} }}
/> />
<Message msg='This is the third message' header={false} /> <Message msg='This is the third message' isHeader={false} />
<Message msg='This is the second message' header={false} /> <Message msg='This is the second message' isHeader={false} />
<Message msg='This is the first message' /> <Message msg='This is the first message' />
<Separator title='Without header' /> <Separator title='Without header' />
<Message msg='Message' header={false} /> <Message msg='Message' isHeader={false} />
<Separator title='With alias' /> <Separator title='With alias' />
<Message msg='Message' alias='Diego Mello' /> <Message msg='Message' alias='Diego Mello' />
@ -101,7 +109,21 @@ export default (
/> />
<Separator title='Mentions' /> <Separator title='Mentions' />
<Message msg='@rocket.cat @diego.mello @all @here #general' /> <Message
msg='@rocket.cat @diego.mello @all @here #general'
mentions={[{
username: 'rocket.cat'
}, {
username: 'diego.mello'
}, {
username: 'all'
}, {
username: 'here'
}]}
channels={[{
name: 'general'
}]}
/>
<Separator title='Emojis' /> <Separator title='Emojis' />
<Message msg='👊🤙👏' /> <Message msg='👊🤙👏' />
@ -194,7 +216,7 @@ export default (
...author, ...author,
username: 'rocket.cat' username: 'rocket.cat'
}} }}
header={false} isHeader={false}
/> />
<Message <Message
msg='Second message' msg='Second message'
@ -217,7 +239,7 @@ export default (
<Message <Message
attachments={[{ attachments={[{
title: 'This is a title', title: 'This is a title',
description: 'This is a description', description: 'This is a description :nyan_rocket:',
image_url: '/file-upload/sxLXBzjwuqxMnebyP/Clipboard%20-%2029%20de%20Agosto%20de%202018%20%C3%A0s%2018:10' image_url: '/file-upload/sxLXBzjwuqxMnebyP/Clipboard%20-%2029%20de%20Agosto%20de%202018%20%C3%A0s%2018:10'
}]} }]}
/> />
@ -226,7 +248,13 @@ export default (
<Message <Message
attachments={[{ attachments={[{
title: 'This is a title', title: 'This is a title',
description: 'This is a description', description: 'This is a description :nyan_rocket:',
video_url: '/file-upload/cqnKqb6kdajky5Rxj/WhatsApp%20Video%202018-08-22%20at%2019.09.55.mp4'
}]}
/>
<Message
attachments={[{
title: 'This is a title',
video_url: '/file-upload/cqnKqb6kdajky5Rxj/WhatsApp%20Video%202018-08-22%20at%2019.09.55.mp4' video_url: '/file-upload/cqnKqb6kdajky5Rxj/WhatsApp%20Video%202018-08-22%20at%2019.09.55.mp4'
}]} }]}
/> />
@ -235,32 +263,32 @@ export default (
<Message <Message
attachments={[{ attachments={[{
title: 'This is a title', title: 'This is a title',
description: 'This is a description', description: 'This is a description :nyan_rocket:',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac' audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]} }]}
/> />
<Message msg='First message' header={false} /> <Message msg='First message' isHeader={false} />
<Message <Message
attachments={[{ attachments={[{
title: 'This is a title', title: 'This is a title',
description: 'This is a description', description: 'This is a description',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac' audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]} }]}
header={false} isHeader={false}
/> />
<Message <Message
attachments={[{ attachments={[{
title: 'This is a title', title: 'This is a title',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac' audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]} }]}
header={false} isHeader={false}
/> />
<Message <Message
attachments={[{ attachments={[{
title: 'This is a title', title: 'This is a title',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac' audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]} }]}
header={false} isHeader={false}
/> />
<Separator title='Message with reply' /> <Separator title='Message with reply' />
@ -279,7 +307,7 @@ export default (
author_name: 'rocket.cat', author_name: 'rocket.cat',
ts: date, ts: date,
timeFormat: 'LT', timeFormat: 'LT',
text: 'How are you?' text: 'How are you? :nyan_rocket:'
}]} }]}
/> />
@ -335,7 +363,7 @@ export default (
tmsg='Thread with attachment' tmsg='Thread with attachment'
attachments={[{ attachments={[{
title: 'This is a title', title: 'This is a title',
description: 'This is a description', description: 'This is a description :nyan_rocket:',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac' audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]} }]}
isThreadReply isThreadReply
@ -487,6 +515,22 @@ export default (
description: 'Search the world\'s information, including webpages, images, videos and more. Google has many special features to help you find exactly what you\'re looking for.' description: 'Search the world\'s information, including webpages, images, videos and more. Google has many special features to help you find exactly what you\'re looking for.'
}]} }]}
/> />
<Message
urls={[{
url: 'https://google.com',
title: 'Google',
description: 'Search the world\'s information, including webpages, images, videos and more. Google has many special features to help you find exactly what you\'re looking for.'
}]}
msg='Message :nyan_rocket:'
/>
<Message
urls={[{
url: 'https://google.com',
title: 'Google',
description: 'Search the world\'s information, including webpages, images, videos and more. Google has many special features to help you find exactly what you\'re looking for.'
}]}
isHeader={false}
/>
<Separator title='Custom fields' /> <Separator title='Custom fields' />
<Message <Message
@ -556,28 +600,29 @@ export default (
<Message msg='This message is inside an archived room' archived /> <Message msg='This message is inside an archived room' archived />
<Separator title='Error' /> <Separator title='Error' />
<Message msg='This message has error too' status={messagesStatus.ERROR} onErrorPress={() => alert('Error pressed')} header={false} /> <Message hasError msg='This message has error' status={messagesStatus.ERROR} onErrorPress={() => alert('Error pressed')} />
<Message msg='This message has error' status={messagesStatus.ERROR} onErrorPress={() => alert('Error pressed')} /> <Message hasError msg='This message has error too' status={messagesStatus.ERROR} onErrorPress={() => alert('Error pressed')} isHeader={false} />
<Separator title='Temp' /> <Separator title='Temp' />
<Message msg='Temp message' status={messagesStatus.TEMP} /> <Message msg='Temp message' status={messagesStatus.TEMP} isTemp />
<Separator title='Editing' /> <Separator title='Editing' />
<Message msg='Message being edited' editing /> <Message msg='Message being edited' editing />
<Separator title='Removed' /> <Separator title='Removed' />
<Message type='rm' /> <Message type='rm' isInfo />
<Separator title='Joined' /> <Separator title='Joined' />
<Message type='uj' /> <Message type='uj' isInfo />
<Separator title='Room name changed' /> <Separator title='Room name changed' />
<Message msg='New name' type='r' /> <Message msg='New name' type='r' isInfo />
<Separator title='Message pinned' /> <Separator title='Message pinned' />
<Message <Message
msg='New name' msg='New name'
type='message_pinned' type='message_pinned'
isInfo
attachments={[{ attachments={[{
author_name: 'rocket.cat', author_name: 'rocket.cat',
ts: date, ts: date,
@ -587,25 +632,26 @@ export default (
/> />
<Separator title='Has left the channel' /> <Separator title='Has left the channel' />
<Message type='ul' /> <Message type='ul' isInfo />
<Separator title='User removed' /> <Separator title='User removed' />
<Message msg='rocket.cat' type='ru' /> <Message msg='rocket.cat' type='ru' isInfo />
<Separator title='User added' /> <Separator title='User added' />
<Message msg='rocket.cat' type='au' /> <Message msg='rocket.cat' type='au' isInfo />
<Separator title='User muted' /> <Separator title='User muted' />
<Message msg='rocket.cat' type='user-muted' /> <Message msg='rocket.cat' type='user-muted' isInfo />
<Separator title='User unmuted' /> <Separator title='User unmuted' />
<Message msg='rocket.cat' type='user-unmuted' /> <Message msg='rocket.cat' type='user-unmuted' isInfo />
<Separator title='Role added' /> <Separator title='Role added' />
<Message <Message
msg='rocket.cat' msg='rocket.cat'
role='admin' // eslint-disable-line role='admin' // eslint-disable-line
type='subscription-role-added' type='subscription-role-added'
isInfo
/> />
<Separator title='Role removed' /> <Separator title='Role removed' />
@ -613,19 +659,20 @@ export default (
msg='rocket.cat' msg='rocket.cat'
role='admin' // eslint-disable-line role='admin' // eslint-disable-line
type='subscription-role-removed' type='subscription-role-removed'
isInfo
/> />
<Separator title='Changed description' /> <Separator title='Changed description' />
<Message msg='new description' type='room_changed_description' /> <Message msg='new description' type='room_changed_description' isInfo />
<Separator title='Changed announcement' /> <Separator title='Changed announcement' />
<Message msg='new announcement' type='room_changed_announcement' /> <Message msg='new announcement' type='room_changed_announcement' isInfo />
<Separator title='Changed topic' /> <Separator title='Changed topic' />
<Message msg='new topic' type='room_changed_topic' /> <Message msg='new topic' type='room_changed_topic' isInfo />
<Separator title='Changed type' /> <Separator title='Changed type' />
<Message msg='public' type='room_changed_privacy' /> <Message msg='public' type='room_changed_privacy' isInfo />
<Separator title='Custom style' /> <Separator title='Custom style' />
<Message msg='Message' style={[styles.normalize, { backgroundColor: '#ddd' }]} /> <Message msg='Message' style={[styles.normalize, { backgroundColor: '#ddd' }]} />

View File

@ -10526,6 +10526,11 @@ react-native-slider@^0.11.0:
dependencies: dependencies:
prop-types "^15.5.6" prop-types "^15.5.6"
react-native-slowlog@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/react-native-slowlog/-/react-native-slowlog-1.0.2.tgz#5520979e3ef9d5273495d431ff3be34f02e35c89"
integrity sha1-VSCXnj751Sc0ldQx/zvjTwLjXIk=
react-native-splash-screen@^3.2.0: react-native-splash-screen@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/react-native-splash-screen/-/react-native-splash-screen-3.2.0.tgz#d47ec8557b1ba988ee3ea98d01463081b60fff45" resolved "https://registry.yarnpkg.com/react-native-splash-screen/-/react-native-splash-screen-3.2.0.tgz#d47ec8557b1ba988ee3ea98d01463081b60fff45"