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() {
return {
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>
<Item title='drawer' iconName='customize' onPress={navigation.toggleDrawer} testID={testID} />
<Item title='drawer' iconName='customize' onPress={navigation.toggleDrawer} testID={testID} {...otherProps} />
</CustomHeaderButtons>
));

View File

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

View File

@ -179,6 +179,7 @@ export default class UploadModal extends Component {
animationOut='fadeOut'
useNativeDriver
hideModalContentWhileAnimating
avoidKeyboard
>
<View style={[styles.container, { width: width - 32 }]}>
<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 = {
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 {
static propTypes = {
file: PropTypes.object.isRequired,
baseUrl: PropTypes.string.isRequired,
user: PropTypes.object.isRequired,
customEmojis: PropTypes.object.isRequired
useMarkdown: PropTypes.bool,
getCustomEmoji: PropTypes.func
}
constructor(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;
this.state = {
currentTime: 0,
@ -120,22 +140,26 @@ export default class Audio extends React.Component {
});
}
getDuration = () => {
get duration() {
const { duration } = this.state;
return formatTime(duration);
}
setRef = ref => this.player = ref;
togglePlayPause = () => {
const { paused } = this.state;
this.setState({ paused: !paused });
}
onValueChange = value => this.setState({ currentTime: value });
render() {
const {
uri, paused, currentTime, duration
} = this.state;
const {
user, baseUrl, customEmojis, file
user, baseUrl, file, getCustomEmoji, useMarkdown
} = this.props;
const { description } = file;
@ -144,12 +168,10 @@ export default class Audio extends React.Component {
}
return (
[
<View key='audio' style={styles.audioContainer}>
<React.Fragment>
<View style={styles.audioContainer}>
<Video
ref={(ref) => {
this.player = ref;
}}
ref={this.setRef}
source={{ uri }}
onLoad={this.onLoad}
onProgress={this.onProgress}
@ -157,39 +179,24 @@ export default class Audio extends React.Component {
paused={paused}
repeat={false}
/>
<Touchable
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>
<Button paused={paused} onPress={this.togglePlayPause} />
<Slider
style={styles.slider}
value={currentTime}
maximumValue={duration}
minimumValue={0}
animateTransitions
animationConfig={{
duration: 250,
easing: Easing.linear,
delay: 0
}}
animationConfig={sliderAnimationConfig}
thumbTintColor={COLOR_PRIMARY}
minimumTrackTintColor={COLOR_PRIMARY}
onValueChange={value => this.setState({ currentTime: value })}
onValueChange={this.onValueChange}
thumbStyle={styles.thumbStyle}
trackStyle={styles.trackStyle}
/>
<Text style={styles.duration}>{this.getDuration()}</Text>
</View>,
<Markdown key='description' msg={description} baseUrl={baseUrl} customEmojis={customEmojis} username={user.username} />
]
<Text style={styles.duration}>{this.duration}</Text>
</View>
<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 { Text, ViewPropTypes } from 'react-native';
import { Text } from 'react-native';
import PropTypes from 'prop-types';
import { emojify } from 'react-emojione';
import CustomEmoji from '../EmojiPicker/CustomEmoji';
export default class Emoji extends React.PureComponent {
static propTypes = {
content: PropTypes.string.isRequired,
baseUrl: PropTypes.string.isRequired,
standardEmojiStyle: Text.propTypes.style,
customEmojiStyle: ViewPropTypes.style,
customEmojis: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object
])
const Emoji = React.memo(({
content, standardEmojiStyle, customEmojiStyle, baseUrl, getCustomEmoji
}) => {
const parsedContent = content.replace(/^:|:$/g, '');
const emoji = getCustomEmoji(parsedContent);
if (emoji) {
return <CustomEmoji key={content} baseUrl={baseUrl} style={customEmojiStyle} emoji={emoji} />;
}
return <Text style={standardEmojiStyle}>{ emojify(content, { output: 'unicode' }) }</Text>;
}, () => true);
render() {
const {
content, standardEmojiStyle, customEmojiStyle, customEmojis, baseUrl
} = this.props;
const parsedContent = content.replace(/^:|:$/g, '');
const emojiExtension = customEmojis[parsedContent];
if (emojiExtension) {
const emoji = { extension: emojiExtension, content: parsedContent };
return <CustomEmoji key={content} baseUrl={baseUrl} style={customEmojiStyle} emoji={emoji} />;
}
return <Text style={standardEmojiStyle}>{ emojify(`${ content }`, { output: 'unicode' }) }</Text>;
}
}
Emoji.propTypes = {
content: PropTypes.string,
standardEmojiStyle: PropTypes.object,
customEmojiStyle: PropTypes.object,
baseUrl: PropTypes.string,
getCustomEmoji: PropTypes.func
};
Emoji.displayName = 'MessageEmoji';
export default Emoji;

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 FastImage from 'react-native-fast-image';
import equal from 'deep-equal';
import Touchable from 'react-native-platform-touchable';
import PhotoModal from './PhotoModal';
import Markdown from './Markdown';
import styles from './styles';
import { formatAttachmentUrl } from '../../lib/utils';
export default class extends Component {
static propTypes = {
file: PropTypes.object.isRequired,
baseUrl: PropTypes.string.isRequired,
user: PropTypes.object.isRequired,
customEmojis: PropTypes.oneOfType([
PropTypes.array,
PropTypes.object
])
const Button = React.memo(({ children, onPress }) => (
<Touchable
onPress={onPress}
style={styles.imageContainer}
background={Touchable.Ripple('#fff')}
>
{children}
</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 };
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;
}
const onPress = () => onOpenFileModal(file);
if (file.description) {
return (
[
<Touchable
key='image'
onPress={this.onPressButton}
style={styles.imageContainer}
background={Touchable.Ripple('#fff')}
>
<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 })}
/>
]
<Button onPress={onPress}>
<View>
<Image img={img} />
<Markdown msg={file.description} baseUrl={baseUrl} username={user.username} getCustomEmoji={getCustomEmoji} useMarkdown={useMarkdown} />
</View>
</Button>
);
}
}
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 MarkdownRenderer, { PluginContainer } from 'react-native-markdown-renderer';
import MarkdownFlowdock from 'markdown-it-flowdock';
import styles from './styles';
import CustomEmoji from '../EmojiPicker/CustomEmoji';
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>
const formatText = text => text.replace(
@ -15,7 +21,7 @@ const formatText = text => text.replace(
);
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) {
return null;
@ -28,14 +34,18 @@ const Markdown = React.memo(({
if (numberOfLines > 0) {
m = m.replace(/[\n]+/g, '\n').trim();
}
if (!useMarkdown) {
return <Text style={styles.text}>{m}</Text>;
}
return (
<MarkdownRenderer
rules={{
paragraph: (node, children) => (
// eslint-disable-next-line
<Text key={node.key} style={styles.paragraph} numberOfLines={numberOfLines}>
{children}
{edited ? <Text style={styles.edited}> (edited)</Text> : null}
{isEdited ? <Text style={styles.edited}> ({I18n.t('edited')})</Text> : null}
</Text>
),
mention: (node) => {
@ -52,23 +62,31 @@ const Markdown = React.memo(({
...styles.mentionLoggedUser
};
}
return (
<Text style={mentionStyle} key={key}>
&nbsp;{content}&nbsp;
</Text>
);
if (mentions && mentions.length && mentions.findIndex(mention => mention.username === content) !== -1) {
return (
<Text style={mentionStyle} key={key}>
&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) => {
if (node.children && node.children.length && node.children[0].content) {
const { content } = node.children[0];
const emojiExtension = customEmojis[content];
if (emojiExtension) {
const emoji = { extension: emojiExtension, content };
const emoji = getCustomEmoji && getCustomEmoji(content);
if (emoji) {
return <CustomEmoji key={node.key} baseUrl={baseUrl} style={styles.customEmoji} emoji={emoji} />;
}
return <Text key={node.key}>:{content}:</Text>;
@ -90,10 +108,7 @@ const Markdown = React.memo(({
link: styles.link,
...style
}}
plugins={[
new PluginContainer(MarkdownFlowdock),
new PluginContainer(MarkdownEmojiPlugin)
]}
plugins={plugins}
>{m}
</MarkdownRenderer>
);
@ -101,13 +116,17 @@ const Markdown = React.memo(({
Markdown.propTypes = {
msg: PropTypes.string,
username: PropTypes.string.isRequired,
baseUrl: PropTypes.string.isRequired,
customEmojis: PropTypes.object.isRequired,
username: PropTypes.string,
baseUrl: PropTypes.string,
style: PropTypes.any,
rules: PropTypes.object,
edited: PropTypes.bool,
numberOfLines: PropTypes.number
isEdited: PropTypes.bool,
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;

View File

@ -1,609 +1,133 @@
import React, { PureComponent } from 'react';
import React from 'react';
import PropTypes from 'prop-types';
import {
View, Text, ViewPropTypes, TouchableWithoutFeedback
} from 'react-native';
import moment from 'moment';
import { KeyboardUtils } from 'react-native-keyboard-input';
import { View } from 'react-native';
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 Avatar from '../Avatar';
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 MessageError from './MessageError';
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 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 = [
'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'
];
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');
const MessageInner = React.memo((props) => {
if (props.type === 'discussion-created') {
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>
<User {...props} />
<Discussion {...props} />
</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 {
tcount, tlm, onThreadPress, msg
} = this.props;
if (!tlm) {
return null;
}
const time = this.formatLastMessage(tlm);
const buttonText = this.formatMessageCount(tcount, 'thread');
const Message = React.memo((props) => {
if (props.isThreadReply || props.isThreadSequential || props.isInfo) {
const thread = props.isThreadReply ? <RepliedThread isTemp={props.isTemp} {...props} /> : null;
return (
<View style={styles.buttonContainer}>
<Touchable
onPress={onThreadPress}
background={Touchable.Ripple('#fff')}
style={[styles.button, styles.smallButton]}
hitSlop={BUTTON_HIT_SLOP}
testID={`message-thread-button-${ msg }`}
>
<React.Fragment>
<CustomIcon name='thread' size={20} style={styles.buttonIcon} />
<Text style={styles.buttonText}>{buttonText}</Text>
</React.Fragment>
</Touchable>
<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 style={[styles.container, props.style, props.isTemp && styles.temp]}>
{thread}
<View style={[styles.flex, sharedStyles.alignItemsCenter]}>
<MessageAvatar small {...props} />
<View
style={[
styles.messageContent,
props.isHeader && styles.messageContentWithHeader,
props.hasError && props.isHeader && styles.messageContentWithHeader,
props.hasError && !props.isHeader && styles.messageContentWithError
]}
>
<Content {...props} />
</View>
</React.Fragment>
);
}
return (
</View>
</View>
);
}
return (
<View style={[styles.container, props.style, props.isTemp && styles.temp]}>
<View style={styles.flex}>
{this.renderAvatar()}
<MessageAvatar {...props} />
<View
style={[
styles.messageContent,
header && styles.messageContentWithHeader,
this.hasError() && header && styles.messageContentWithHeader,
this.hasError() && !header && styles.messageContentWithError,
this.isTemp() && styles.temp
props.isHeader && styles.messageContentWithHeader,
props.hasError && props.isHeader && styles.messageContentWithHeader,
props.hasError && !props.isHeader && styles.messageContentWithError
]}
>
{this.renderInner()}
<MessageInner {...props} />
</View>
</View>
);
}
render() {
const {
editing, style, reactionsModal, closeReactions, msg, ts, reactions, author, user, timeFormat, customEmojis, baseUrl
} = this.props;
const accessibilityLabel = I18n.t('Message_accessibility', { user: author.username, time: moment(ts).format(timeFormat), message: msg });
</View>
);
});
Message.displayName = 'Message';
const MessageTouchable = React.memo((props) => {
if (props.hasError) {
return (
<View style={styles.root}>
{this.renderError()}
<TouchableWithoutFeedback
onLongPress={this.onLongPress}
onPress={this.onPress}
>
<View
style={[styles.container, editing && styles.editing, style]}
accessibilityLabel={accessibilityLabel}
>
{this.renderMessage()}
{reactionsModal
? (
<ReactionsModal
isVisible={reactionsModal}
reactions={reactions}
user={user}
customEmojis={customEmojis}
baseUrl={baseUrl}
close={closeReactions}
/>
)
: null
}
</View>
</TouchableWithoutFeedback>
<MessageError {...props} />
<Message {...props} />
</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 moment from 'moment';
import Touchable from 'react-native-platform-touchable';
import isEqual from 'deep-equal';
import Markdown from './Markdown';
import openLink from '../../utils/openLink';
@ -69,98 +70,130 @@ const styles = StyleSheet.create({
}
});
const onPress = (attachment, baseUrl, user) => {
let url = attachment.title_link || attachment.author_link;
if (!url) {
return;
const Title = React.memo(({ attachment, timeFormat }) => {
if (!attachment.author_name) {
return null;
}
if (attachment.type === 'file') {
url = `${ baseUrl }${ url }?rc_uid=${ user.id }&rc_token=${ user.token }`;
}
openLink(url);
};
const time = attachment.ts ? moment(attachment.ts).format(timeFormat) : null;
return (
<View style={styles.authorContainer}>
{attachment.author_name ? <Text style={styles.author}>{attachment.author_name}</Text> : null}
{time ? <Text style={styles.time}>{ time }</Text> : null}
</View>
);
}, () => true);
const Reply = ({
attachment, timeFormat, baseUrl, customEmojis, user, index
const Description = React.memo(({
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) {
return null;
}
const renderAuthor = () => (
attachment.author_name ? <Text style={styles.author}>{attachment.author_name}</Text> : null
);
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;
const onPress = () => {
let url = attachment.title_link || attachment.author_link;
if (!url) {
return;
}
return (
<View style={styles.authorContainer}>
{renderAuthor()}
{renderTime()}
</View>
);
};
const renderText = () => {
const text = attachment.text || attachment.title;
if (text) {
return (
<Markdown
msg={text}
customEmojis={customEmojis}
baseUrl={baseUrl}
username={user.username}
/>
);
if (attachment.type === 'file') {
url = `${ baseUrl }${ url }?rc_uid=${ user.id }&rc_token=${ user.token }`;
}
};
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>
);
openLink(url);
};
return (
<Touchable
onPress={() => onPress(attachment, baseUrl, user)}
onPress={onPress}
style={[styles.button, index > 0 && styles.marginTop]}
background={Touchable.Ripple('#fff')}
>
<View style={styles.attachmentContainer}>
{renderTitle()}
{renderText()}
{renderFields()}
<Title attachment={attachment} timeFormat={timeFormat} />
<Description
attachment={attachment}
timeFormat={timeFormat}
baseUrl={baseUrl}
user={user}
getCustomEmoji={getCustomEmoji}
useMarkdown={useMarkdown}
/>
<Fields attachment={attachment} />
</View>
</Touchable>
);
};
}, (prevProps, nextProps) => isEqual(prevProps.attachment, nextProps.attachment));
Reply.propTypes = {
attachment: PropTypes.object.isRequired,
timeFormat: PropTypes.string.isRequired,
baseUrl: PropTypes.string.isRequired,
customEmojis: PropTypes.object.isRequired,
user: PropTypes.object.isRequired,
index: PropTypes.number
attachment: PropTypes.object,
timeFormat: PropTypes.string,
baseUrl: PropTypes.string,
user: PropTypes.object,
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;

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 }`;
return <FastImage source={{ uri: image }} style={styles.image} resizeMode={FastImage.resizeMode.cover} />;
});
}, (prevProps, nextProps) => prevProps.image === nextProps.image);
const UrlContent = React.memo(({ title, description }) => (
<View style={styles.textContainer}>
{title ? <Text style={styles.title} numberOfLines={2}>{title}</Text> : null}
{description ? <Text style={styles.description} numberOfLines={2}>{description}</Text> : null}
</View>
));
), (prevProps, nextProps) => {
if (prevProps.title !== nextProps.title) {
return false;
}
if (prevProps.description !== nextProps.description) {
return false;
}
return true;
});
const Url = React.memo(({
url, index, user, baseUrl
@ -89,16 +97,28 @@ const Url = React.memo(({
);
}, (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 = {
image: PropTypes.string,
user: PropTypes.object,
baseUrl: PropTypes.string
};
UrlImage.displayName = 'MessageUrlImage';
UrlContent.propTypes = {
title: PropTypes.string,
description: PropTypes.string
};
UrlContent.displayName = 'MessageUrlContent';
Url.propTypes = {
url: PropTypes.object.isRequired,
@ -106,5 +126,13 @@ Url.propTypes = {
user: PropTypes.object,
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 {
static propTypes = {
timeFormat: PropTypes.string.isRequired,
username: PropTypes.string,
alias: PropTypes.string,
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 User = React.memo(({
isHeader, useRealName, author, alias, ts, timeFormat
}) => {
if (isHeader) {
const username = (useRealName && author.name) || author.username;
const aliasUsername = alias ? (<Text style={styles.alias}> @{username}</Text>) : null;
const time = moment(ts).format(timeFormat);
@ -67,4 +50,17 @@ export default class User extends React.PureComponent {
</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 PropTypes from 'prop-types';
import { StyleSheet, View } from 'react-native';
import Modal from 'react-native-modal';
import VideoPlayer from 'react-native-video-controls';
import { StyleSheet } from 'react-native';
import Touchable from 'react-native-platform-touchable';
import isEqual from 'deep-equal';
import Markdown from './Markdown';
import openLink from '../../utils/openLink';
import { isIOS } from '../../utils/deviceInfo';
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 isTypeSupported = type => SUPPORTED_TYPES.indexOf(type) !== -1;
@ -32,77 +32,46 @@ const styles = StyleSheet.create({
}
});
export default class Video extends React.PureComponent {
static propTypes = {
file: PropTypes.object.isRequired,
baseUrl: PropTypes.string.isRequired,
user: PropTypes.object.isRequired,
customEmojis: PropTypes.object.isRequired
const Video = React.memo(({
file, baseUrl, user, useMarkdown, onOpenFileModal, getCustomEmoji
}) => {
if (!baseUrl) {
return null;
}
state = { isVisible: false };
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;
const onPress = () => {
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() {
const { isVisible } = this.state;
const {
baseUrl, user, customEmojis, file
} = this.props;
const { description } = file;
return (
<React.Fragment>
<Touchable
onPress={onPress}
style={styles.button}
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) {
return null;
}
Video.propTypes = {
file: PropTypes.object,
baseUrl: PropTypes.string,
user: PropTypes.object,
useMarkdown: PropTypes.bool,
onOpenFileModal: PropTypes.func,
getCustomEmoji: PropTypes.func
};
return (
[
<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>
]
);
}
}
export default Video;

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 PropTypes from 'prop-types';
import { ViewPropTypes } from 'react-native';
import { connect } from 'react-redux';
import equal from 'deep-equal';
import { KeyboardUtils } from 'react-native-keyboard-input';
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 { 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 {
static propTypes = {
item: PropTypes.object.isRequired,
@ -33,31 +16,28 @@ export default class MessageContainer extends React.Component {
username: PropTypes.string.isRequired,
token: PropTypes.string.isRequired
}),
customTimeFormat: PropTypes.string,
timeFormat: PropTypes.string,
customThreadTimeFormat: PropTypes.string,
style: ViewPropTypes.style,
archived: PropTypes.bool,
broadcast: PropTypes.bool,
previousItem: PropTypes.object,
_updatedAt: PropTypes.instanceOf(Date),
// redux
baseUrl: PropTypes.string,
customEmojis: PropTypes.object,
Message_GroupingPeriod: PropTypes.number,
Message_TimeFormat: PropTypes.string,
editingMessage: PropTypes.object,
useRealName: PropTypes.bool,
useMarkdown: PropTypes.bool,
status: PropTypes.number,
navigation: PropTypes.object,
// methods - props
onLongPress: PropTypes.func,
onReactionPress: PropTypes.func,
onDiscussionPress: PropTypes.func,
// methods - redux
onThreadPress: PropTypes.func,
errorActionsShow: PropTypes.func,
replyBroadcast: PropTypes.func,
toggleReactionPicker: PropTypes.func,
fetchThreadName: PropTypes.func
fetchThreadName: PropTypes.func,
onOpenFileModal: PropTypes.func,
onReactionLongPress: PropTypes.func
}
static defaultProps = {
@ -67,21 +47,11 @@ export default class MessageContainer extends React.Component {
broadcast: false
}
constructor(props) {
super(props);
this.state = { reactionsModal: false };
this.closeReactions = this.closeReactions.bind(this);
}
shouldComponentUpdate(nextProps, nextState) {
const { reactionsModal } = this.state;
shouldComponentUpdate(nextProps) {
const {
status, editingMessage, item, _updatedAt, navigation
status, item, _updatedAt, previousItem
} = this.props;
if (reactionsModal !== nextState.reactionsModal) {
return true;
}
if (status !== nextProps.status) {
return true;
}
@ -89,65 +59,68 @@ export default class MessageContainer extends React.Component {
return true;
}
if (navigation.isFocused() && !equal(editingMessage, nextProps.editingMessage)) {
if (nextProps.editingMessage && nextProps.editingMessage._id === item._id) {
return true;
} else if (!nextProps.editingMessage._id !== item._id && editingMessage._id === item._id) {
return true;
}
if (!previousItem && !!nextProps.previousItem) {
return true;
}
return _updatedAt.toISOString() !== nextProps._updatedAt.toISOString();
}
onPress = debounce(() => {
const { item } = this.props;
KeyboardUtils.dismiss();
if ((item.tlm || item.tmid)) {
this.onThreadPress();
}
}, 300, true);
onLongPress = () => {
const { onLongPress } = this.props;
onLongPress(this.parseMessage());
const { archived, onLongPress } = this.props;
if (this.isInfo || this.hasError || archived) {
return;
}
if (onLongPress) {
onLongPress(this.parseMessage());
}
}
onErrorPress = () => {
const { errorActionsShow } = this.props;
errorActionsShow(this.parseMessage());
if (errorActionsShow) {
errorActionsShow(this.parseMessage());
}
}
onReactionPress = (emoji) => {
const { onReactionPress, item } = this.props;
onReactionPress(emoji, item._id);
if (onReactionPress) {
onReactionPress(emoji, item._id);
}
}
onReactionLongPress = () => {
this.setState({ reactionsModal: true });
vibrate();
const { onReactionLongPress, item } = this.props;
if (onReactionLongPress) {
onReactionLongPress(item);
}
}
onDiscussionPress = () => {
const { onDiscussionPress, item } = this.props;
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'
});
if (onDiscussionPress) {
onDiscussionPress(item);
}
}, 1000, true)
get timeFormat() {
const { customTimeFormat, Message_TimeFormat } = this.props;
return customTimeFormat || Message_TimeFormat;
}
closeReactions = () => {
this.setState({ reactionsModal: false });
onThreadPress = () => {
const { onThreadPress, item } = this.props;
if (onThreadPress) {
onThreadPress(item);
}
}
isHeader = () => {
get isHeader() {
const {
item, previousItem, broadcast, Message_GroupingPeriod
} = this.props;
@ -163,7 +136,7 @@ export default class MessageContainer extends React.Component {
return true;
}
isThreadReply = () => {
get isThreadReply() {
const {
item, previousItem
} = this.props;
@ -173,7 +146,7 @@ export default class MessageContainer extends React.Component {
return false;
}
isThreadSequential = () => {
get isThreadSequential() {
const {
item, previousItem
} = this.props;
@ -183,6 +156,21 @@ export default class MessageContainer extends React.Component {
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 = () => {
const { item } = this.props;
return JSON.parse(JSON.stringify(item));
@ -190,23 +178,26 @@ export default class MessageContainer extends React.Component {
toggleReactionPicker = () => {
const { toggleReactionPicker } = this.props;
toggleReactionPicker(this.parseMessage());
if (toggleReactionPicker) {
toggleReactionPicker(this.parseMessage());
}
}
replyBroadcast = () => {
const { replyBroadcast } = this.props;
replyBroadcast(this.parseMessage());
if (replyBroadcast) {
replyBroadcast(this.parseMessage());
}
}
render() {
const { reactionsModal } = this.state;
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;
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;
const isEditing = editingMessage._id === item._id;
return (
<Message
id={_id}
@ -214,26 +205,18 @@ export default class MessageContainer extends React.Component {
author={u}
ts={ts}
type={t}
status={status}
attachments={attachments}
urls={urls}
reactions={reactions}
alias={alias}
editing={isEditing}
header={this.isHeader()}
isThreadReply={this.isThreadReply()}
isThreadSequential={this.isThreadSequential()}
avatar={avatar}
user={user}
edited={editedBy && !!editedBy.username}
timeFormat={this.timeFormat}
timeFormat={timeFormat}
customThreadTimeFormat={customThreadTimeFormat}
style={style}
archived={archived}
broadcast={broadcast}
baseUrl={baseUrl}
customEmojis={customEmojis}
reactionsModal={reactionsModal}
useRealName={useRealName}
role={role}
drid={drid}
@ -243,16 +226,27 @@ export default class MessageContainer extends React.Component {
tcount={tcount}
tlm={tlm}
tmsg={tmsg}
useMarkdown={useMarkdown}
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}
onPress={this.onPress}
onLongPress={this.onLongPress}
onReactionLongPress={this.onReactionLongPress}
onReactionPress={this.onReactionPress}
replyBroadcast={this.replyBroadcast}
toggleReactionPicker={this.toggleReactionPicker}
onDiscussionPress={this.onDiscussionPress}
onThreadPress={this.onThreadPress}
onOpenFileModal={onOpenFileModal}
getCustomEmoji={getCustomEmoji}
/>
);
}

View File

@ -18,8 +18,7 @@ export default StyleSheet.create({
paddingVertical: 4,
width: '100%',
paddingHorizontal: 14,
flexDirection: 'column',
flex: 1
flexDirection: 'column'
},
messageContent: {
flex: 1,
@ -32,8 +31,8 @@ export default StyleSheet.create({
marginLeft: 0
},
flex: {
flexDirection: 'row',
flex: 1
flexDirection: 'row'
// flex: 1
},
text: {
fontSize: 16,
@ -46,9 +45,6 @@ export default StyleSheet.create({
...sharedStyles.textColorDescription,
...sharedStyles.textRegular
},
editing: {
backgroundColor: '#fff5df'
},
customEmoji: {
width: 20,
height: 20
@ -161,7 +157,7 @@ export default StyleSheet.create({
justifyContent: 'flex-start'
},
imageContainer: {
flex: 1,
// flex: 1,
flexDirection: 'column',
borderRadius: 4
},
@ -173,6 +169,9 @@ export default StyleSheet.create({
borderColor: COLOR_BORDER,
borderWidth: 1
},
imagePressed: {
opacity: 0.5
},
inlineImage: {
width: 300,
height: 300,
@ -220,7 +219,7 @@ export default StyleSheet.create({
},
repliedThread: {
flexDirection: 'row',
flex: 1,
// flex: 1,
alignItems: 'center',
marginTop: 6,
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?',
Do_you_really_want_to_key_this_room_question_mark: 'Do you really want to {{key}} this room?',
edit: 'edit',
erasing_room: 'erasing room',
edited: 'edited',
Edit: 'Edit',
Email_or_password_field_is_empty: 'Email or password field is empty',
Email: 'Email',
email: 'e-mail',
Enable_notifications: 'Enable notifications',
Everyone_can_access_this_channel: 'Everyone can access this channel',
erasing_room: 'erasing room',
Error_uploading: 'Error uploading',
Favorites: 'Favorites',
Files: 'Files',

View File

@ -154,6 +154,7 @@ export default {
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?',
edit: 'editar',
edited: 'editado',
erasing_room: 'apagando sala',
Edit: 'Editar',
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 database from '../realm';
import * as actions from '../../actions';
import log from '../../utils/log';
const getUpdatedSince = () => {
@ -17,7 +16,7 @@ const create = (customEmojis) => {
try {
database.create('customEmojis', emoji, true);
} catch (e) {
log('getEmojis create', e);
// log('getEmojis create', e);
}
});
}
@ -40,7 +39,6 @@ export default async function() {
database.write(() => {
create(emojis);
});
reduxStore.dispatch(actions.setCustomEmojis(this.parseEmojis(result.emojis)));
});
} else {
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) {
subscription.roles = [];
}
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 {
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 })) }));
// }
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._updatedAt = new Date();

View File

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

View File

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

View File

@ -472,19 +472,6 @@ const RocketChat = {
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) {
const { _id, rid } = message;
// 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 createChannel from './createChannel';
import app from './app';
import customEmojis from './customEmojis';
import sortPreferences from './sortPreferences';
export default combineReducers({
@ -21,6 +20,5 @@ export default combineReducers({
createChannel,
app,
rooms,
customEmojis,
sortPreferences
});

View File

@ -19,7 +19,13 @@ const handleRoomsRequest = function* handleRoomsRequest() {
const { subscriptions } = mergeSubscriptionsRooms(subscriptionsResult, roomsResult);
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(() => {
try {

View File

@ -51,8 +51,6 @@ const handleSelectServer = function* handleSelectServer({ server, version, fetch
const settings = database.objects('settings');
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));
} catch (e) {

View File

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

View File

@ -8,8 +8,9 @@ import { isIOS } from '../utils/deviceInfo';
import { CloseModalButton } from '../containers/HeaderButton';
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 ? 'UserAgent' : userAgentAndroid;
const userAgent = isIOS
? '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 => ({
server: state.server.server
@ -62,6 +63,7 @@ export default class OAuthView extends React.PureComponent {
<React.Fragment>
<StatusBar />
<WebView
useWebKit
source={{ uri: oAuthUrl }}
userAgent={userAgent}
onNavigationStateChange={(webViewState) => {

View File

@ -158,7 +158,7 @@ export default class RoomMembersView extends LoggedView {
const { muted } = room;
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;
if (userIsMuted) {
this.actionSheetOptions.push(I18n.t('Unmute'));

View File

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

View File

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

View File

@ -13,8 +13,10 @@ import EJSON from 'ejson';
import {
toggleReactionPicker as toggleReactionPickerAction,
actionsShow as actionsShowAction,
errorActionsShow as errorActionsShowAction,
editCancel as editCancelAction,
replyCancel as replyCancelAction
replyCancel as replyCancelAction,
replyBroadcast as replyBroadcastAction
} from '../../actions/messages';
import LoggedView from '../View';
import { List } from './List';
@ -37,6 +39,9 @@ import Separator from './Separator';
import { COLOR_WHITE } from '../../constants/colors';
import debounce from '../../utils/debounce';
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';
@connect(state => ({
@ -52,12 +57,17 @@ import { Toast } from '../../utils/info';
showErrorActions: state.messages.showErrorActions,
appState: state.app.ready && state.app.foreground ? 'foreground' : 'background',
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 => ({
editCancel: () => dispatch(editCancelAction()),
replyCancel: () => dispatch(replyCancelAction()),
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 */
export default class RoomView extends LoggedView {
@ -105,12 +115,17 @@ export default class RoomView extends LoggedView {
appState: PropTypes.string,
useRealName: PropTypes.bool,
isAuthenticated: PropTypes.bool,
Message_GroupingPeriod: PropTypes.number,
Message_TimeFormat: PropTypes.string,
editing: PropTypes.bool,
replying: PropTypes.bool,
toggleReactionPicker: PropTypes.func.isRequired,
baseUrl: PropTypes.string,
toggleReactionPicker: PropTypes.func,
actionsShow: PropTypes.func,
editCancel: PropTypes.func,
replyCancel: PropTypes.func
replyCancel: PropTypes.func,
replyBroadcast: PropTypes.func,
errorActionsShow: PropTypes.func
};
constructor(props) {
@ -120,16 +135,20 @@ export default class RoomView extends LoggedView {
this.rid = props.navigation.getParam('rid');
this.t = props.navigation.getParam('t');
this.tmid = props.navigation.getParam('tmid');
this.useMarkdown = props.navigation.getParam('useMarkdown', true);
this.rooms = database.objects('subscriptions').filtered('rid = $0', this.rid);
this.state = {
joined: this.rooms.length > 0,
room: this.rooms[0] || { rid: this.rid, t: this.t },
lastOpen: null
lastOpen: null,
photoModalVisible: false,
reactionsModalVisible: false,
selectedAttachment: {},
selectedMessage: {}
};
this.beginAnimating = false;
this.beginAnimatingTimeout = setTimeout(() => this.beginAnimating = true, 300);
this.messagebox = React.createRef();
safeAddListener(this.rooms, this.updateRoom);
this.willBlurListener = props.navigation.addListener('willBlur', () => this.mounted = false);
this.mounted = false;
console.timeEnd(`${ this.constructor.name } init`);
@ -152,6 +171,7 @@ export default class RoomView extends LoggedView {
} else {
EventEmitter.addEventListener('connected', this.handleConnected);
}
safeAddListener(this.rooms, this.updateRoom);
this.mounted = true;
});
console.timeEnd(`${ this.constructor.name } mount`);
@ -159,12 +179,16 @@ export default class RoomView extends LoggedView {
shouldComponentUpdate(nextProps, nextState) {
const {
room, joined, lastOpen
room, joined, lastOpen, photoModalVisible, reactionsModalVisible
} = this.state;
const { showActions, showErrorActions, appState } = this.props;
if (lastOpen !== nextState.lastOpen) {
return true;
} else if (photoModalVisible !== nextState.photoModalVisible) {
return true;
} else if (reactionsModalVisible !== nextState.reactionsModalVisible) {
return true;
} else if (room.ro !== nextState.room.ro) {
return true;
} else if (room.f !== nextState.room.f) {
@ -285,6 +309,14 @@ export default class RoomView extends LoggedView {
actionsShow({ ...message, rid: this.rid });
}
onOpenFileModal = (attachment) => {
this.setState({ selectedAttachment: attachment, photoModalVisible: true });
}
onCloseFileModal = () => {
this.setState({ selectedAttachment: {}, photoModalVisible: false });
}
onReactionPress = (shortname, messageId) => {
const { actionMessage, toggleReactionPicker } = this.props;
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) => {
const { navigation } = this.props;
navigation.push('RoomView', {
@ -305,6 +346,35 @@ export default class RoomView extends LoggedView {
});
}, 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 = () => {
this.init();
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() => {
try {
@ -388,7 +458,7 @@ export default class RoomView extends LoggedView {
isMuted = () => {
const { room } = this.state;
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 = () => {
@ -433,7 +503,9 @@ export default class RoomView extends LoggedView {
renderItem = (item, previousItem) => {
const { room, lastOpen } = this.state;
const { user, navigation } = this.props;
const {
user, Message_GroupingPeriod, Message_TimeFormat, useRealName, baseUrl
} = this.props;
let dateSeparator = null;
let showUnreadSeparator = false;
@ -459,11 +531,21 @@ export default class RoomView extends LoggedView {
status={item.status}
_updatedAt={item._updatedAt}
previousItem={previousItem}
navigation={navigation}
fetchThreadName={this.fetchThreadName}
onReactionPress={this.onReactionPress}
onReactionLongPress={this.onReactionLongPress}
onLongPress={this.onMessageLongPress}
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() {
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;
return (
@ -559,6 +644,20 @@ export default class RoomView extends LoggedView {
{this.renderActions()}
<ReactionPicker onEmojiSelected={this.onReactionPress} />
<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} />
</SafeAreaView>
);

View File

@ -66,6 +66,7 @@ export default class RoomsListView extends LoggedView {
const cancelSearchingAndroid = navigation.getParam('cancelSearchingAndroid');
const onPressItem = navigation.getParam('onPressItem', () => {});
const initSearchingAndroid = navigation.getParam('initSearchingAndroid', () => {});
const toggleUseMarkdown = navigation.getParam('toggleUseMarkdown', () => {});
return {
headerLeft: (
@ -75,7 +76,7 @@ export default class RoomsListView extends LoggedView {
<Item title='cancel' iconName='cross' onPress={cancelSearchingAndroid} />
</CustomHeaderButtons>
)
: <DrawerButton navigation={navigation} testID='rooms-list-view-sidebar' />
: <DrawerButton navigation={navigation} testID='rooms-list-view-sidebar' onLongPress={toggleUseMarkdown} />
),
headerTitle: <RoomsListHeaderView />,
headerRight: (
@ -124,6 +125,7 @@ export default class RoomsListView extends LoggedView {
searching: false,
search: [],
loading: true,
useMarkdown: true,
chats: [],
unread: [],
favorites: [],
@ -142,7 +144,10 @@ export default class RoomsListView extends LoggedView {
this.getSubscriptions();
const { navigation } = this.props;
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`);
}
@ -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)
hasActiveDB = () => database && database.databases && database.databases.activeDB;
@ -341,9 +355,10 @@ export default class RoomsListView extends LoggedView {
goRoom = (item) => {
this.cancelSearchingAndroid();
const { useMarkdown } = this.state;
const { navigation } = this.props;
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 => ({
baseUrl: state.settings.Site_Url || state.server ? state.server.server : '',
customEmojis: state.customEmojis,
user: {
id: state.login.user && state.login.user.id,
username: state.login.user && state.login.user.username,
@ -35,8 +34,7 @@ export default class SearchMessagesView extends LoggedView {
static propTypes = {
navigation: PropTypes.object,
user: PropTypes.object,
baseUrl: PropTypes.string,
customEmojis: PropTypes.object
baseUrl: PropTypes.string
}
constructor(props) {
@ -96,10 +94,9 @@ export default class SearchMessagesView extends LoggedView {
)
renderItem = ({ item }) => {
const { user, customEmojis, baseUrl } = this.props;
const { user, baseUrl } = this.props;
return (
<Message
customEmojis={customEmojis}
baseUrl={baseUrl}
user={user}
author={item.u}
@ -107,8 +104,9 @@ export default class SearchMessagesView extends LoggedView {
msg={item.msg}
attachments={item.attachments || []}
timeFormat='MMM Do YYYY, h:mm:ss a'
edited={!!item.editedAt}
header
isEdited={!!item.editedAt}
isHeader
onOpenFileModal={() => {}}
/>
);
}
@ -145,7 +143,7 @@ export default class SearchMessagesView extends LoggedView {
placeholder={I18n.t('Search_Messages')}
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>
{this.renderList()}

View File

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

View File

@ -39,10 +39,6 @@
[self.window makeKeyAndVisible];
[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];
return YES;

View File

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

View File

@ -24,19 +24,27 @@ const author = {
username: 'diego.mello'
};
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 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 => (
<MessageComponent
baseUrl={baseUrl}
customEmojis={customEmojis}
user={user}
author={author}
ts={date}
timeFormat='LT'
header
isHeader
getCustomEmoji={getCustomEmoji}
{...props}
/>
);
@ -62,12 +70,12 @@ export default (
username: longText
}}
/>
<Message msg='This is the third message' header={false} />
<Message msg='This is the second message' header={false} />
<Message msg='This is the third message' isHeader={false} />
<Message msg='This is the second message' isHeader={false} />
<Message msg='This is the first message' />
<Separator title='Without header' />
<Message msg='Message' header={false} />
<Message msg='Message' isHeader={false} />
<Separator title='With alias' />
<Message msg='Message' alias='Diego Mello' />
@ -101,7 +109,21 @@ export default (
/>
<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' />
<Message msg='👊🤙👏' />
@ -194,7 +216,7 @@ export default (
...author,
username: 'rocket.cat'
}}
header={false}
isHeader={false}
/>
<Message
msg='Second message'
@ -217,7 +239,7 @@ export default (
<Message
attachments={[{
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'
}]}
/>
@ -226,7 +248,13 @@ export default (
<Message
attachments={[{
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'
}]}
/>
@ -235,32 +263,32 @@ export default (
<Message
attachments={[{
title: 'This is a title',
description: 'This is a description',
description: 'This is a description :nyan_rocket:',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]}
/>
<Message msg='First message' header={false} />
<Message msg='First message' isHeader={false} />
<Message
attachments={[{
title: 'This is a title',
description: 'This is a description',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]}
header={false}
isHeader={false}
/>
<Message
attachments={[{
title: 'This is a title',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]}
header={false}
isHeader={false}
/>
<Message
attachments={[{
title: 'This is a title',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]}
header={false}
isHeader={false}
/>
<Separator title='Message with reply' />
@ -279,7 +307,7 @@ export default (
author_name: 'rocket.cat',
ts: date,
timeFormat: 'LT',
text: 'How are you?'
text: 'How are you? :nyan_rocket:'
}]}
/>
@ -335,7 +363,7 @@ export default (
tmsg='Thread with attachment'
attachments={[{
title: 'This is a title',
description: 'This is a description',
description: 'This is a description :nyan_rocket:',
audio_url: '/file-upload/c4wcNhrbXJLBvAJtN/1535569819516.aac'
}]}
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.'
}]}
/>
<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' />
<Message
@ -556,28 +600,29 @@ export default (
<Message msg='This message is inside an archived room' archived />
<Separator title='Error' />
<Message msg='This message has error too' status={messagesStatus.ERROR} onErrorPress={() => alert('Error pressed')} header={false} />
<Message msg='This message has error' status={messagesStatus.ERROR} onErrorPress={() => alert('Error pressed')} />
<Message hasError 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' />
<Message msg='Temp message' status={messagesStatus.TEMP} />
<Message msg='Temp message' status={messagesStatus.TEMP} isTemp />
<Separator title='Editing' />
<Message msg='Message being edited' editing />
<Separator title='Removed' />
<Message type='rm' />
<Message type='rm' isInfo />
<Separator title='Joined' />
<Message type='uj' />
<Message type='uj' isInfo />
<Separator title='Room name changed' />
<Message msg='New name' type='r' />
<Message msg='New name' type='r' isInfo />
<Separator title='Message pinned' />
<Message
msg='New name'
type='message_pinned'
isInfo
attachments={[{
author_name: 'rocket.cat',
ts: date,
@ -587,25 +632,26 @@ export default (
/>
<Separator title='Has left the channel' />
<Message type='ul' />
<Message type='ul' isInfo />
<Separator title='User removed' />
<Message msg='rocket.cat' type='ru' />
<Message msg='rocket.cat' type='ru' isInfo />
<Separator title='User added' />
<Message msg='rocket.cat' type='au' />
<Message msg='rocket.cat' type='au' isInfo />
<Separator title='User muted' />
<Message msg='rocket.cat' type='user-muted' />
<Message msg='rocket.cat' type='user-muted' isInfo />
<Separator title='User unmuted' />
<Message msg='rocket.cat' type='user-unmuted' />
<Message msg='rocket.cat' type='user-unmuted' isInfo />
<Separator title='Role added' />
<Message
msg='rocket.cat'
role='admin' // eslint-disable-line
type='subscription-role-added'
isInfo
/>
<Separator title='Role removed' />
@ -613,19 +659,20 @@ export default (
msg='rocket.cat'
role='admin' // eslint-disable-line
type='subscription-role-removed'
isInfo
/>
<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' />
<Message msg='new announcement' type='room_changed_announcement' />
<Message msg='new announcement' type='room_changed_announcement' isInfo />
<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' />
<Message msg='public' type='room_changed_privacy' />
<Message msg='public' type='room_changed_privacy' isInfo />
<Separator title='Custom style' />
<Message msg='Message' style={[styles.normalize, { backgroundColor: '#ddd' }]} />

View File

@ -10526,6 +10526,11 @@ react-native-slider@^0.11.0:
dependencies:
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:
version "3.2.0"
resolved "https://registry.yarnpkg.com/react-native-splash-screen/-/react-native-splash-screen-3.2.0.tgz#d47ec8557b1ba988ee3ea98d01463081b60fff45"