RN-10 Ability to search Posts (#763)

* Fix offline indicator UI

* Add search screen with recent mentions functionality

* Fix make start

* Add autocomplete to the search box

* Fix search bar in other screens

* Get search results and scroll the list

* Add reply arrow to search result posts

* Search result preview and jump to channel

* Feedback review
This commit is contained in:
enahum
2017-07-21 17:07:47 -04:00
committed by GitHub
parent aafc8001dc
commit dc8b9a04b1
36 changed files with 1382 additions and 80 deletions

View File

@@ -105,12 +105,12 @@ post-install:
sed -i'' -e 's|"./locale-data/complete.js": false|"./locale-data/complete.js": "./locale-data/complete.js"|g' node_modules/intl/package.json
start-packager:
@if [ $(shell ps -a | grep "cli.js start" | grep -civ grep) -eq 0 ]; then \
@if [ $(shell ps -e | grep -i "cli.js start" | grep -civ grep) -eq 0 ]; then \
echo Starting React Native packager server; \
node ./node_modules/react-native/local-cli/cli.js start --reset-cache & echo $$! > server.PID; \
else \
echo React Native packager server already running; \
ps -a | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \
ps -e | grep -i "cli.js start" | grep -v grep | awk '{print $$1}' > server.PID; \
fi
stop-packager:

View File

@@ -12,7 +12,7 @@ import {
selectChannel,
leaveChannel as serviceLeaveChannel
} from 'mattermost-redux/actions/channels';
import {getPosts, getPostsBefore, getPostsSince} from 'mattermost-redux/actions/posts';
import {getPosts, getPostsBefore, getPostsSince, getPostThread} from 'mattermost-redux/actions/posts';
import {getFilesForPost} from 'mattermost-redux/actions/files';
import {savePreferences, deletePreferences} from 'mattermost-redux/actions/preferences';
import {getTeamMembersByIds} from 'mattermost-redux/actions/teams';
@@ -166,6 +166,17 @@ export function loadFilesForPostIfNecessary(postId) {
};
}
export function loadThreadIfNecessary(rootId) {
return async (dispatch, getState) => {
const state = getState();
const {posts} = state.entities.posts;
if (rootId && !posts[rootId]) {
getPostThread(rootId)(dispatch, getState);
}
};
}
export function selectInitialChannel(teamId) {
return async (dispatch, getState) => {
const state = getState();

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
export function handleSearchDraftChanged(text) {
return async (dispatch, getState) => {
dispatch({
type: ViewTypes.SEARCH_DRAFT_CHANGED,
text
}, getState);
};
}

View File

@@ -11,9 +11,11 @@ import CustomPropTypes from 'app/constants/custom_prop_types';
class AtMention extends React.PureComponent {
static propTypes = {
intl: intlShape,
isSearchResult: PropTypes.bool,
mentionName: PropTypes.string.isRequired,
mentionStyle: CustomPropTypes.Style,
navigator: PropTypes.object.isRequired,
onPostPress: PropTypes.func,
textStyle: CustomPropTypes.Style,
theme: PropTypes.object.isRequired,
usersByUsername: PropTypes.object.isRequired
@@ -41,6 +43,7 @@ class AtMention extends React.PureComponent {
goToUserProfile = () => {
const {intl, navigator, theme} = this.props;
navigator.push({
screen: 'UserProfile',
title: intl.formatMessage({id: 'mobile.routes.user_profile', defaultMessage: 'Profile'}),
@@ -84,7 +87,7 @@ class AtMention extends React.PureComponent {
}
render() {
const {mentionName, mentionStyle, textStyle} = this.props;
const {isSearchResult, mentionName, mentionStyle, onPostPress, textStyle} = this.props;
const username = this.state.username;
if (!username) {
@@ -96,7 +99,7 @@ class AtMention extends React.PureComponent {
return (
<Text
style={textStyle}
onPress={this.goToUserProfile}
onPress={isSearchResult ? onPostPress : this.goToUserProfile}
>
<Text style={mentionStyle}>
{'@' + username}

View File

@@ -12,6 +12,8 @@ import {
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import {sortByUsername} from 'mattermost-redux/utils/user_utils';
import FormattedText from 'app/components/formatted_text';
import ProfilePicture from 'app/components/profile_picture';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
@@ -19,6 +21,7 @@ import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {RequestStatus} from 'mattermost-redux/constants';
const AT_MENTION_REGEX = /\B(@([^@\r\n\s]*))$/i;
const FROM_REGEX = /\bfrom:\s*(\S*)$/i;
export default class AtMention extends Component {
static propTypes = {
@@ -27,7 +30,8 @@ export default class AtMention extends Component {
currentTeamId: PropTypes.string.isRequired,
cursorPosition: PropTypes.number.isRequired,
defaultChannel: PropTypes.object.isRequired,
autocompleteUsersInCurrentChannel: PropTypes.object.isRequired,
autocompleteUsers: PropTypes.object.isRequired,
isSearch: PropTypes.bool,
postDraft: PropTypes.string,
requestStatus: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
@@ -38,9 +42,10 @@ export default class AtMention extends Component {
};
static defaultProps = {
autocompleteUsersInCurrentChannel: {},
autocompleteUsers: {},
defaultChannel: {},
postDraft: ''
postDraft: '',
isSearch: false
};
constructor(props) {
@@ -58,7 +63,9 @@ export default class AtMention extends Component {
}
componentWillReceiveProps(nextProps) {
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(AT_MENTION_REGEX);
const {isSearch} = nextProps;
const regex = isSearch ? FROM_REGEX : AT_MENTION_REGEX;
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex);
if (!match || this.state.mentionComplete) {
this.setState({
@@ -69,8 +76,7 @@ export default class AtMention extends Component {
return;
}
const matchTerm = match[2];
const matchTerm = isSearch ? match[1] : match[2];
if (matchTerm !== this.state.matchTerm) {
this.setState({
matchTerm
@@ -81,30 +87,35 @@ export default class AtMention extends Component {
}
if (nextProps.requestStatus !== RequestStatus.STARTED) {
const membersInChannel = this.filter(nextProps.autocompleteUsersInCurrentChannel.inChannel, matchTerm) || [];
const membersOutOfChannel = this.filter(nextProps.autocompleteUsersInCurrentChannel.outChannel, matchTerm) || [];
const membersInChannel = this.filter(nextProps.autocompleteUsers.inChannel, matchTerm) || [];
const membersOutOfChannel = this.filter(nextProps.autocompleteUsers.outChannel, matchTerm) || [];
let data = {};
if (membersInChannel.length > 0) {
data = Object.assign({}, data, {inChannel: membersInChannel});
}
if (this.checkSpecialMentions(matchTerm)) {
data = Object.assign({}, data, {specialMentions: this.getSpecialMentions()});
}
if (membersOutOfChannel.length > 0) {
data = Object.assign({}, data, {notInChannel: membersOutOfChannel});
if (isSearch) {
data = {members: membersInChannel.concat(membersOutOfChannel).sort(sortByUsername)};
} else {
if (membersInChannel.length > 0) {
data = Object.assign({}, data, {inChannel: membersInChannel});
}
if (this.checkSpecialMentions(matchTerm) && !isSearch) {
data = Object.assign({}, data, {specialMentions: this.getSpecialMentions()});
}
if (membersOutOfChannel.length > 0) {
data = Object.assign({}, data, {notInChannel: membersOutOfChannel});
}
}
this.setState({
active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel'),
active: data.hasOwnProperty('inChannel') || data.hasOwnProperty('specialMentions') || data.hasOwnProperty('notInChannel') || data.hasOwnProperty('members'),
dataSource: this.state.dataSource.cloneWithRowsAndSections(data)
});
}
}
filter = (profiles, matchTerm) => {
const {isSearch} = this.props;
return profiles.filter((p) => {
return ((p.id !== this.props.currentUserId) && (
return ((p.id !== this.props.currentUserId || isSearch) && (
p.username.toLowerCase().includes(matchTerm) || p.email.toLowerCase().includes(matchTerm) ||
p.first_name.toLowerCase().includes(matchTerm) || p.last_name.toLowerCase().includes(matchTerm)));
});
@@ -134,14 +145,21 @@ export default class AtMention extends Component {
};
completeMention = (mention) => {
const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition);
const {cursorPosition, isSearch, onChangeText, postDraft} = this.props;
const mentionPart = postDraft.substring(0, cursorPosition);
let completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
if (this.props.postDraft.length > this.props.cursorPosition) {
completedDraft += this.props.postDraft.substring(this.props.cursorPosition);
let completedDraft;
if (isSearch) {
completedDraft = mentionPart.replace(FROM_REGEX, `from: ${mention} `);
} else {
completedDraft = mentionPart.replace(AT_MENTION_REGEX, `@${mention} `);
}
this.props.onChangeText(completedDraft);
if (postDraft.length > cursorPosition) {
completedDraft += postDraft.substring(cursorPosition);
}
onChangeText(completedDraft);
this.setState({
active: false,
mentionComplete: true
@@ -163,6 +181,10 @@ export default class AtMention extends Component {
specialMentions: {
id: 'suggestion.mention.special',
defaultMessage: 'Special Mentions'
},
members: {
id: 'mobile.suggestion.members',
defaultMessage: 'Members'
}
};
@@ -236,7 +258,7 @@ export default class AtMention extends Component {
};
render() {
const {autocompleteUsersInCurrentChannel, requestStatus} = this.props;
const {autocompleteUsers, requestStatus} = this.props;
if (!this.state.active && (requestStatus !== RequestStatus.STARTED || requestStatus !== RequestStatus.SUCCESS)) {
// If we are not in an active state return null so nothing is rendered
// other components are not blocked.
@@ -246,14 +268,14 @@ export default class AtMention extends Component {
const style = getStyleFromTheme(this.props.theme);
if (
!autocompleteUsersInCurrentChannel.inChannel &&
!autocompleteUsersInCurrentChannel.outChannel &&
!autocompleteUsers.inChannel &&
!autocompleteUsers.outChannel &&
requestStatus === RequestStatus.STARTED
) {
return (
<View style={style.loading}>
<FormattedText
id='analytics.chart.loading": "Loading...'
id='analytics.chart.loading'
defaultMessage='Loading...'
style={style.sectionText}
/>

View File

@@ -15,7 +15,9 @@ function mapStateToProps(state, ownProps) {
const {currentChannelId} = state.entities.channels;
let postDraft;
if (ownProps.rootId.length) {
if (ownProps.isSearch) {
postDraft = state.views.search;
} else if (ownProps.rootId.length) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;
@@ -28,18 +30,18 @@ function mapStateToProps(state, ownProps) {
}
return {
...ownProps,
currentUserId: state.entities.users.currentUserId,
currentChannelId,
currentTeamId: state.entities.teams.currentTeamId,
defaultChannel: getDefaultChannel(state),
postDraft,
autocompleteUsersInCurrentChannel: {
autocompleteUsers: {
inChannel: getProfilesInCurrentChannel(state),
outChannel: getProfilesNotInCurrentChannel(state)
},
requestStatus: state.requests.users.autocompleteUsers.status,
theme: getTheme(state)
theme: getTheme(state),
...ownProps
};
}

View File

@@ -17,6 +17,7 @@ import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {RequestStatus} from 'mattermost-redux/constants';
const CHANNEL_MENTION_REGEX = /\B(~([^~\r\n]*))$/i;
const CHANNEL_SEARCH_REGEX = /\b(?:in|channel):\s*(\S*)$/i;
export default class ChannelMention extends Component {
static propTypes = {
@@ -25,6 +26,7 @@ export default class ChannelMention extends Component {
cursorPosition: PropTypes.number.isRequired,
autocompleteChannels: PropTypes.object.isRequired,
postDraft: PropTypes.string,
isSearch: PropTypes.bool,
requestStatus: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
@@ -34,7 +36,8 @@ export default class ChannelMention extends Component {
};
static defaultProps = {
postDraft: ''
postDraft: '',
isSearch: false
};
constructor(props) {
@@ -52,7 +55,9 @@ export default class ChannelMention extends Component {
}
componentWillReceiveProps(nextProps) {
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(CHANNEL_MENTION_REGEX);
const {isSearch} = nextProps;
const regex = isSearch ? CHANNEL_SEARCH_REGEX : CHANNEL_MENTION_REGEX;
const match = nextProps.postDraft.substring(0, nextProps.cursorPosition).match(regex);
// If not match or if user clicked on a channel
if (!match || this.state.mentionComplete) {
@@ -70,7 +75,7 @@ export default class ChannelMention extends Component {
return;
}
const matchTerm = match[2];
const matchTerm = isSearch ? match[1] : match[2];
const myChannels = this.filter(nextProps.autocompleteChannels.myChannels, matchTerm);
const otherChannels = this.filter(nextProps.autocompleteChannels.otherChannels, matchTerm);
@@ -84,7 +89,14 @@ export default class ChannelMention extends Component {
}
// Still matching the same term that didn't return any results
if (match[0].startsWith(`~${this.state.matchTerm}`) && (myChannels.length === 0 && otherChannels.length === 0)) {
let startsWith;
if (isSearch) {
startsWith = match[0].startsWith(`in:${this.state.matchTerm}`) || match[0].startsWith(`channel:${this.state.matchTerm}`);
} else {
startsWith = match[0].startsWith(`~${this.state.matchTerm}`);
}
if (startsWith && (myChannels.length === 0 && otherChannels.length === 0)) {
this.setState({
active: false
});
@@ -123,14 +135,22 @@ export default class ChannelMention extends Component {
};
completeMention = (mention) => {
const mentionPart = this.props.postDraft.substring(0, this.props.cursorPosition);
const {cursorPosition, isSearch, onChangeText, postDraft} = this.props;
const mentionPart = postDraft.substring(0, cursorPosition);
let completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
if (this.props.postDraft.length > this.props.cursorPosition) {
completedDraft += this.props.postDraft.substring(this.props.cursorPosition);
let completedDraft;
if (isSearch) {
const channelOrIn = mentionPart.includes('in:') ? 'in:' : 'channel:';
completedDraft = mentionPart.replace(CHANNEL_SEARCH_REGEX, `${channelOrIn} ${mention} `);
} else {
completedDraft = mentionPart.replace(CHANNEL_MENTION_REGEX, `~${mention} `);
}
this.props.onChangeText(completedDraft);
if (postDraft.length > cursorPosition) {
completedDraft += postDraft.substring(cursorPosition);
}
onChangeText(completedDraft);
this.setState({
active: false,
mentionComplete: true,

View File

@@ -16,7 +16,9 @@ function mapStateToProps(state, ownProps) {
const {currentChannelId} = state.entities.channels;
let postDraft;
if (ownProps.rootId.length) {
if (ownProps.isSearch) {
postDraft = state.views.search;
} else if (ownProps.rootId.length) {
const threadDraft = state.views.thread.drafts[ownProps.rootId];
if (threadDraft) {
postDraft = threadDraft.draft;

View File

@@ -19,13 +19,27 @@ const style = StyleSheet.create({
right: 0,
maxHeight: 200,
overflow: 'hidden'
},
searchContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
maxHeight: 300,
overflow: 'hidden',
zIndex: 5
}
});
export default class Autocomplete extends Component {
static propTypes = {
onChangeText: PropTypes.func.isRequired,
rootId: PropTypes.string
rootId: PropTypes.string,
isSearch: PropTypes.bool
};
static defaultProps = {
isSearch: false
};
state = {
@@ -39,18 +53,17 @@ export default class Autocomplete extends Component {
};
render() {
const container = this.props.isSearch ? style.searchContainer : style.container;
return (
<View>
<View style={style.container}>
<View style={container}>
<AtMention
cursorPosition={this.state.cursorPosition}
onChangeText={this.props.onChangeText}
rootId={this.props.rootId}
{...this.props}
/>
<ChannelMention
cursorPosition={this.state.cursorPosition}
onChangeText={this.props.onChangeText}
rootId={this.props.rootId}
{...this.props}
/>
</View>
</View>

View File

@@ -29,8 +29,10 @@ export default class Markdown extends PureComponent {
baseTextStyle: CustomPropTypes.Style,
blockStyles: PropTypes.object,
emojiSizes: PropTypes.object,
isSearchResult: PropTypes.bool,
navigator: PropTypes.object.isRequired,
onLongPress: PropTypes.func,
onPostPress: PropTypes.func,
textStyles: PropTypes.object,
value: PropTypes.string.isRequired
};
@@ -134,7 +136,9 @@ export default class Markdown extends PureComponent {
<AtMention
mentionStyle={this.props.textStyles.mention}
textStyle={this.computeTextStyle(this.props.baseTextStyle, context)}
isSearchResult={this.props.isSearchResult}
mentionName={mentionName}
onPostPress={this.props.onPostPress}
navigator={this.props.navigator}
/>
);

View File

@@ -210,7 +210,8 @@ const styles = StyleSheet.create({
flex: 1,
height: HEIGHT,
flexDirection: 'row',
paddingHorizontal: 12,
paddingLeft: 12,
paddingRight: 5,
backgroundColor: 'red'
},
message: {
@@ -220,13 +221,15 @@ const styles = StyleSheet.create({
flex: 1
},
actionButton: {
alignItems: 'center',
borderWidth: 1,
borderColor: '#FFFFFF'
},
actionContainer: {
alignItems: 'center',
alignItems: 'flex-end',
height: 24,
justifyContent: 'center',
paddingRight: 10,
width: 60
}
});

View File

@@ -26,6 +26,7 @@ function makeMapStateToProps() {
post,
config,
currentUserId: getCurrentUserId(state),
highlight: ownProps.post.highlight,
license,
roles,
theme: getTheme(state),

View File

@@ -36,19 +36,27 @@ class Post extends PureComponent {
}).isRequired,
config: PropTypes.object.isRequired,
currentUserId: PropTypes.string.isRequired,
highlight: PropTypes.bool,
intl: intlShape.isRequired,
style: ViewPropTypes.style,
post: PropTypes.object.isRequired,
renderReplies: PropTypes.bool,
isFirstReply: PropTypes.bool,
isLastReply: PropTypes.bool,
isSearchResult: PropTypes.bool,
commentedOnPost: PropTypes.object,
license: PropTypes.object.isRequired,
navigator: PropTypes.object,
roles: PropTypes.string,
shouldRenderReplyButton: PropTypes.bool,
tooltipVisible: PropTypes.bool,
theme: PropTypes.object.isRequired,
onPress: PropTypes.func
onPress: PropTypes.func,
onReply: PropTypes.func
};
static defaultProps = {
isSearchResult: false
};
constructor(props) {
@@ -201,6 +209,15 @@ class Post extends PureComponent {
}
};
handleReply = () => {
const {post, onReply, tooltipVisible} = this.props;
if (!tooltipVisible && onReply) {
return preventDoubleTap(onReply, null, post);
}
return this.handlePress();
};
onRemovePost = (post) => {
const {removePost} = this.props.actions;
removePost(post);
@@ -235,7 +252,11 @@ class Post extends PureComponent {
};
viewUserProfile = () => {
preventDoubleTap(this.goToUserProfile, this);
const {isSearchResult} = this.props;
if (!isSearchResult) {
preventDoubleTap(this.goToUserProfile, this);
}
};
toggleSelected = (selected) => {
@@ -246,16 +267,20 @@ class Post extends PureComponent {
render() {
const {
commentedOnPost,
highlight,
isLastReply,
isSearchResult,
post,
renderReplies,
shouldRenderReplyButton,
theme
} = this.props;
const style = getStyleSheet(theme);
const selected = this.state && this.state.selected ? style.selected : null;
const highlighted = highlight ? style.highlight : null;
return (
<View style={[style.container, this.props.style, selected]}>
<View style={[style.container, this.props.style, highlighted, selected]}>
<View style={[style.profilePictureContainer, (isPostPendingOrFailed(post) && style.pendingPost)]}>
<PostProfilePicture
onViewUserProfile={this.viewUserProfile}
@@ -269,7 +294,9 @@ class Post extends PureComponent {
postId={post.id}
commentedOnUserId={commentedOnPost && commentedOnPost.user_id}
createAt={post.create_at}
onPress={this.handlePress}
isSearchResult={isSearchResult}
shouldRenderReplyButton={shouldRenderReplyButton}
onPress={this.handleReply}
onViewUserProfile={this.viewUserProfile}
renderReplies={renderReplies}
theme={theme}
@@ -277,6 +304,7 @@ class Post extends PureComponent {
<PostBody
canDelete={this.state.canDelete}
canEdit={this.state.canEdit}
isSearchResult={isSearchResult}
navigator={this.props.navigator}
onFailedPostPress={this.handleFailedPostPress}
onPostDelete={this.handlePostDelete}
@@ -336,6 +364,9 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
},
selected: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1)
},
highlight: {
backgroundColor: changeOpacity(theme.mentionHighlightBg, 0.5)
}
});
});

View File

@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import {
Platform,
StyleSheet,
TouchableHighlight,
TouchableOpacity,
View
} from 'react-native';
@@ -39,6 +40,7 @@ class PostBody extends PureComponent {
isFailed: PropTypes.bool,
isFlagged: PropTypes.bool,
isPending: PropTypes.bool,
isSearchResult: PropTypes.bool,
isSystemMessage: PropTypes.bool,
message: PropTypes.string,
navigator: PropTypes.object.isRequired,
@@ -62,8 +64,16 @@ class PostBody extends PureComponent {
toggleSelected: emptyFunction
};
handleHideUnderlay = () => {
this.props.toggleSelected(false);
};
handleShowUnderlay = () => {
this.props.toggleSelected(true);
};
hideOptionsContext = () => {
if (Platform.OS === 'ios') {
if (Platform.OS === 'ios' && this.refs.options) {
this.refs.options.hide();
}
};
@@ -79,7 +89,9 @@ class PostBody extends PureComponent {
};
showOptionsContext = () => {
return this.refs.options.show();
if (this.refs.options) {
this.refs.options.show();
}
};
renderFileAttachments() {
@@ -138,6 +150,7 @@ class PostBody extends PureComponent {
isFailed,
isFlagged,
isPending,
isSearchResult,
isSystemMessage,
intl,
message,
@@ -160,7 +173,7 @@ class PostBody extends PureComponent {
const isPendingOrFailedPost = isPending || isFailed;
// we should check for the user roles and permissions
if (!isPendingOrFailedPost) {
if (!isPendingOrFailedPost && !isSearchResult) {
if (isFlagged) {
actions.push({
text: formatMessage({id: 'post_info.mobile.unflag', defaultMessage: 'Unflag'}),
@@ -199,8 +212,10 @@ class PostBody extends PureComponent {
baseTextStyle={messageStyle}
textStyles={textStyles}
blockStyles={blockStyles}
isSearchResult={isSearchResult}
value={message}
onLongPress={this.showOptionsContext}
onPostPress={onPress}
navigator={navigator}
/>
</View>
@@ -208,23 +223,46 @@ class PostBody extends PureComponent {
);
}
let body;
if (isSearchResult) {
body = (
<TouchableHighlight
onHideUnderlay={this.handleHideUnderlay}
onLongPress={this.show}
onPress={onPress}
onShowUnderlay={this.handleShowUnderlay}
underlayColor='transparent'
>
<View>
{messageComponent}
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
</View>
</TouchableHighlight>
);
} else {
body = (
<OptionsContext
actions={actions}
ref='options'
onPress={onPress}
toggleSelected={toggleSelected}
cancelText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})}
>
{messageComponent}
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
{hasReactions && <Reactions postId={postId}/>}
</OptionsContext>
);
}
return (
<View style={style.messageContainerWithReplyBar}>
{renderReplyBar()}
<View style={{flex: 1, flexDirection: 'row'}}>
<View style={{flex: 1}}>
<OptionsContext
actions={actions}
ref='options'
onPress={onPress}
toggleSelected={toggleSelected}
cancelText={formatMessage({id: 'channel_modal.cancel', defaultMessage: 'Cancel'})}
>
{messageComponent}
{this.renderSlackAttachments(messageStyle, blockStyles, textStyles)}
{this.renderFileAttachments()}
{hasReactions && <Reactions postId={postId}/>}
</OptionsContext>
{body}
</View>
{isFailed &&
<TouchableOpacity

View File

@@ -27,6 +27,8 @@ export default class PostHeader extends PureComponent {
enablePostUsernameOverride: PropTypes.bool,
fromWebHook: PropTypes.bool,
isPendingOrFailedPost: PropTypes.bool,
isSearchResult: PropTypes.bool,
shouldRenderReplyButton: PropTypes.bool,
isSystemMessage: PropTypes.bool,
onPress: PropTypes.func,
onViewUserProfile: PropTypes.func,
@@ -138,11 +140,14 @@ export default class PostHeader extends PureComponent {
commentCount,
createAt,
isPendingOrFailedPost,
isSearchResult,
onPress,
renderReplies,
shouldRenderReplyButton,
theme
} = this.props;
const style = getStyleSheet(theme);
const showReply = shouldRenderReplyButton || (!commentedOnDisplayName && commentCount > 0 && renderReplies);
return (
<View>
@@ -155,7 +160,7 @@ export default class PostHeader extends PureComponent {
</Text>
</View>
</View>
{(!commentedOnDisplayName && commentCount > 0 && renderReplies) &&
{showReply &&
<TouchableOpacity
onPress={onPress}
style={style.replyIconContainer}
@@ -165,7 +170,9 @@ export default class PostHeader extends PureComponent {
width={15}
color={theme.linkColor}
/>
{!isSearchResult &&
<Text style={style.replyText}>{commentCount}</Text>
}
</TouchableOpacity>
}
</View>

View File

@@ -29,6 +29,7 @@ export default class PostList extends PureComponent {
currentUserId: PropTypes.string,
indicateNewMessages: PropTypes.bool,
isLoadingMore: PropTypes.bool,
isSearchResult: PropTypes.bool,
lastViewedAt: PropTypes.number,
loadMore: PropTypes.func,
navigator: PropTypes.object,
@@ -37,6 +38,7 @@ export default class PostList extends PureComponent {
refreshing: PropTypes.bool,
renderReplies: PropTypes.bool,
showLoadMore: PropTypes.bool,
shouldRenderReplyButton: PropTypes.bool,
theme: PropTypes.object.isRequired
};
@@ -128,15 +130,25 @@ export default class PostList extends PureComponent {
};
renderPost = (post) => {
const {
isSearchResult,
navigator,
onPostPress,
renderReplies,
shouldRenderReplyButton
} = this.props;
return (
<Post
post={post}
renderReplies={this.props.renderReplies}
renderReplies={renderReplies}
isFirstReply={post.isFirstReply}
isLastReply={post.isLastReply}
isSearchResult={isSearchResult}
shouldRenderReplyButton={shouldRenderReplyButton}
commentedOnPost={post.commentedOnPost}
onPress={this.props.onPostPress}
navigator={this.props.navigator}
onPress={onPostPress}
navigator={navigator}
/>
);
};

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {SectionList} from 'react-native';
import MetroListView from 'react-native/Libraries/Lists/MetroListView';
import VirtualizedSectionList from './virtualized_section_list';
export default class ScrollableSectionList extends SectionList {
getWrapperRef = () => {
return this._wrapperListRef; //eslint-disable-line no-underscore-dangle
};
render() {
const List = this.props.legacyImplementation ?
MetroListView :
VirtualizedSectionList;
return (
<List
{...this.props}
ref={this._captureRef} //eslint-disable-line no-underscore-dangle
/>
);
}
_wrapperListRef: MetroListView | VirtualizedSectionList<any>; //eslint-disable-line no-underscore-dangle
_captureRef = (ref) => {
this._wrapperListRef = ref; //eslint-disable-line no-underscore-dangle
};
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {VirtualizedList} from 'react-native';
import VirtualizedSectionList from 'react-native/Libraries/Lists/VirtualizedSectionList';
export default class VirtualizedScrollableSectionList extends VirtualizedSectionList {
getListRef() {
return this._listRef; //eslint-disable-line no-underscore-dangle
}
render() {
return (
<VirtualizedList
{...this.state.childProps}
ref={this._captureRef} //eslint-disable-line no-underscore-dangle
/>
);
}
_listRef: VirtualizedList;
_captureRef = (ref) => {
this._listRef = ref; //eslint-disable-line no-underscore-dangle
};
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getPost, makeGetPostsAroundPost} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import SearchPreview from './search_preview';
function makeMapStateToProps() {
const getPostsAroundPost = makeGetPostsAroundPost();
return function mapStateToProps(state, ownProps) {
const post = getPost(state, ownProps.focusedPostId);
const postsAroundPost = getPostsAroundPost(state, post.id, post.channel_id);
const focusedPostIndex = postsAroundPost ? postsAroundPost.findIndex((p) => p.id === ownProps.focusedPostId) : -1;
let posts = [];
if (focusedPostIndex !== -1) {
const desiredPostIndexBefore = focusedPostIndex - 5;
const minPostIndex = desiredPostIndexBefore < 0 ? 0 : desiredPostIndexBefore;
posts = [...postsAroundPost].splice(minPostIndex, 10);
}
return {
...ownProps,
channelId: post.channel_id,
currentUserId: getCurrentUserId(state),
posts
};
};
}
export default connect(makeMapStateToProps, null, null, {withRef: true})(SearchPreview);

View File

@@ -0,0 +1,234 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
Dimensions,
Platform,
StyleSheet,
Text,
TouchableOpacity,
View
} from 'react-native';
import * as Animatable from 'react-native-animatable';
import MaterialIcon from 'react-native-vector-icons/MaterialIcons';
import FormattedText from 'app/components/formatted_text';
import Loading from 'app/components/loading';
import PostList from 'app/components/post_list';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
Animatable.initializeRegistryWithDefinitions({
growOut: {
from: {
opacity: 1,
scale: 1
},
0.5: {
opacity: 1,
scale: 3
},
to: {
opacity: 0,
scale: 5
}
}
});
export default class SearchPreview extends PureComponent {
static propTypes = {
channelId: PropTypes.string,
channelName: PropTypes.string,
currentUserId: PropTypes.string.isRequired,
navigator: PropTypes.object,
onClose: PropTypes.func,
onPress: PropTypes.func,
posts: PropTypes.array,
theme: PropTypes.object.isRequired
};
static defaultProps = {
posts: []
};
state = {
showPosts: false
};
handleClose = () => {
this.refs.view.zoomOut().then(() => {
if (this.props.onClose) {
this.props.onClose();
}
});
return true;
};
handlePress = () => {
const {channelId, onPress} = this.props;
this.refs.view.growOut().then(() => {
if (onPress) {
onPress(channelId);
}
});
};
showPostList = () => {
if (!this.state.showPosts && this.props.posts.length) {
this.setState({showPosts: true});
}
};
render() {
const {channelName, currentUserId, posts, theme} = this.props;
const {height, width} = Dimensions.get('window');
const style = getStyleSheet(theme);
let postList;
if (this.state.showPosts) {
postList = (
<PostList
indicateNewMessages={false}
isSearchResult={true}
shouldRenderReplyButton={false}
renderReplies={false}
posts={posts}
currentUserId={currentUserId}
lastViewedAt={0}
navigator={navigator}
/>
);
} else {
postList = <Loading/>;
}
return (
<View
style={[style.container, {width, height}]}
>
<Animatable.View
ref='view'
animation='zoomIn'
duration={500}
delay={0}
style={style.wrapper}
onAnimationEnd={this.showPostList}
>
<View
style={style.header}
>
<TouchableOpacity
style={style.close}
onPress={this.handleClose}
>
<MaterialIcon
name='close'
size={20}
color={theme.sidebarHeaderTextColor}
/>
</TouchableOpacity>
<View style={style.titleContainer}>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={style.title}
>
{channelName}
</Text>
</View>
</View>
<View style={style.postList}>
{postList}
</View>
<TouchableOpacity
style={style.footer}
onPress={this.handlePress}
>
<FormattedText
id='mobile.search.jump'
defautMessage='JUMP'
style={style.jump}
/>
</TouchableOpacity>
</Animatable.View>
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
container: {
position: 'absolute',
backgroundColor: changeOpacity('#000', 0.3),
top: 0,
left: 0,
zIndex: 10
},
wrapper: {
flex: 1,
marginHorizontal: 10,
opacity: 0,
...Platform.select({
android: {
marginTop: 10,
marginBottom: 35
},
ios: {
marginTop: 20,
marginBottom: 10
}
})
},
header: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.2),
borderBottomWidth: 1,
borderTopLeftRadius: 6,
borderTopRightRadius: 6,
flexDirection: 'row',
height: 44,
paddingRight: 16,
width: '100%'
},
close: {
justifyContent: 'center',
height: 44,
width: 40,
paddingLeft: 7
},
titleContainer: {
alignItems: 'center',
flex: 1,
paddingRight: 40
},
title: {
color: theme.sidebarHeaderTextColor,
fontSize: 17,
fontWeight: '600'
},
postList: {
backgroundColor: theme.centerChannelBg,
flex: 1
},
footer: {
alignItems: 'center',
justifyContent: 'center',
backgroundColor: theme.buttonBg,
borderBottomLeftRadius: 6,
borderBottomRightRadius: 6,
flexDirection: 'row',
height: 44,
paddingRight: 16,
width: '100%'
},
jump: {
color: theme.buttonColor,
fontSize: 16,
fontWeight: '600',
textAlignVertical: 'center'
}
});
});

View File

@@ -14,6 +14,7 @@ const ViewTypes = keyMirror({
POST_DRAFT_CHANGED: null,
COMMENT_DRAFT_CHANGED: null,
SEARCH_DRAFT_CHANGED: null,
NOTIFICATION_CHANGED: null,
NOTIFICATION_IN_APP: null,

View File

@@ -44,6 +44,12 @@ registerScreens(store, Provider);
export default class Mattermost {
constructor() {
if (Platform.OS === 'android') {
// This is to remove the warnings for the scaleY property used in android.
// The property is necessary because some Android devices won't render the posts
// properly if we use transform: {scaleY: -1} in the stylesheet.
console.ignoredYellowBox = ['`scaleY`']; //eslint-disable-line
}
this.isConfigured = false;
setJSExceptionHandler(this.errorHandler, false);
Orientation.lockToPortrait();

View File

@@ -10,6 +10,7 @@ import i18n from './i18n';
import login from './login';
import notification from './notification';
import root from './root';
import search from './search';
import selectServer from './select_server';
import team from './team';
import thread from './thread';
@@ -22,6 +23,7 @@ export default combineReducers({
login,
notification,
root,
search,
selectServer,
team,
thread

View File

@@ -0,0 +1,18 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
import {UserTypes} from 'mattermost-redux/action_types';
export default function search(state = '', action) {
switch (action.type) {
case ViewTypes.SEARCH_DRAFT_CHANGED: {
return action.text;
}
case UserTypes.LOGOUT_SUCCESS:
return '';
default:
return state;
}
}

View File

@@ -28,6 +28,7 @@ import {makeStyleSheetFromTheme} from 'app/utils/theme';
import ChannelDrawerButton from './channel_drawer_button';
import ChannelTitle from './channel_title';
import ChannelPostList from './channel_post_list';
import ChannelSearchButton from './channel_search_button';
class Channel extends PureComponent {
static propTypes = {
@@ -239,6 +240,7 @@ class Channel extends PureComponent {
<ChannelTitle
onPress={() => preventDoubleTap(this.goToChannelInfo, this)}
/>
<ChannelSearchButton navigator={navigator}/>
</View>
<OfflineIndicator/>
</View>

View File

@@ -154,6 +154,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: 10,
paddingTop: 5,
zIndex: 30
},
badge: {

View File

@@ -24,6 +24,7 @@ class ChannelPostList extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
loadPostsIfNecessary: PropTypes.func.isRequired,
loadThreadIfNecessary: PropTypes.func.isRequired,
increasePostVisibility: PropTypes.func.isRequired,
selectPost: PropTypes.func.isRequired
}).isRequired,
@@ -98,6 +99,7 @@ class ChannelPostList extends PureComponent {
const channelId = post.channel_id;
const rootId = (post.root_id || post.id);
actions.loadThreadIfNecessary(post.root_id);
actions.selectPost(rootId);
let title;

View File

@@ -9,7 +9,7 @@ import {RequestStatus} from 'mattermost-redux/constants';
import {makeGetPostsInChannel} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentChannelId, getMyCurrentChannelMembership} from 'mattermost-redux/selectors/entities/channels';
import {loadPostsIfNecessary, increasePostVisibility} from 'app/actions/views/channel';
import {loadPostsIfNecessary, loadThreadIfNecessary, increasePostVisibility} from 'app/actions/views/channel';
import {getTheme} from 'app/selectors/preferences';
import ChannelPostList from './channel_post_list';
@@ -42,6 +42,7 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
loadPostsIfNecessary,
loadThreadIfNecessary,
increasePostVisibility,
selectPost
}, dispatch)

View File

@@ -0,0 +1,116 @@
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {
StyleSheet,
TouchableOpacity,
View
} from 'react-native';
import AwesomeIcon from 'react-native-vector-icons/FontAwesome';
import {clearSearch} from 'mattermost-redux/actions/search';
import {handlePostDraftChanged} from 'app/actions/views/channel';
import {getTheme} from 'app/selectors/preferences';
import {preventDoubleTap} from 'app/utils/tap';
const SEARCH = 'search';
class ChannelSearchButton extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
clearSearch: PropTypes.func.isRequired,
handlePostDraftChanged: PropTypes.func.isRequired
}).isRequired,
applicationInitializing: PropTypes.bool.isRequired,
navigator: PropTypes.object,
theme: PropTypes.object
};
handlePress = async () => {
const {actions, navigator, theme} = this.props;
await actions.clearSearch();
actions.handlePostDraftChanged(SEARCH, '');
navigator.showModal({
screen: 'Search',
animated: true,
backButtonTitle: '',
overrideBackPress: true,
navigatorStyle: {
navBarHidden: true,
screenBackgroundColor: theme.centerChannelBg
},
passProps: {
theme
}
});
};
render() {
const {
applicationInitializing,
theme
} = this.props;
if (applicationInitializing) {
return null;
}
return (
<TouchableOpacity
onPress={() => preventDoubleTap(this.handlePress, this)}
style={style.container}
>
<View style={style.wrapper}>
<AwesomeIcon
name='search'
size={18}
color={theme.sidebarHeaderTextColor}
/>
</View>
</TouchableOpacity>
);
}
}
const style = StyleSheet.create({
container: {
width: 40
},
wrapper: {
alignItems: 'center',
flex: 1,
flexDirection: 'column',
justifyContent: 'center',
paddingHorizontal: 10,
zIndex: 30
}
});
function mapStateToProps(state, ownProps) {
return {
applicationInitializing: state.views.root.appInitializing,
theme: getTheme(state),
...ownProps
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
clearSearch,
handlePostDraftChanged
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ChannelSearchButton);

View File

@@ -35,7 +35,7 @@ function ChannelTitle(props) {
return (
<TouchableOpacity
style={{flexDirection: 'row', flex: 1, marginRight: 40}}
style={{flexDirection: 'row', flex: 1}}
onPress={props.onPress}
>
<View style={{flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginHorizontal: 15}}>

View File

@@ -24,6 +24,7 @@ import Notification from 'app/screens/notification';
import OptionsModal from 'app/screens/options_modal';
import Root from 'app/screens/root';
import SSO from 'app/screens/sso';
import Search from 'app/screens/search';
import SelectServer from 'app/screens/select_server';
import SelectTeam from 'app/screens/select_team';
import Settings from 'app/screens/settings';
@@ -66,6 +67,7 @@ export function registerScreens(store, Provider) {
Navigation.registerComponent('OptionsModal', () => wrapWithContextProvider(OptionsModal), store, Provider);
Navigation.registerComponent('Notification', () => wrapWithContextProvider(Notification, true), store, Provider);
Navigation.registerComponent('Root', () => Root, store, Provider);
Navigation.registerComponent('Search', () => wrapWithContextProvider(Search), store, Provider);
Navigation.registerComponent('SelectServer', () => wrapWithContextProvider(SelectServer), store, Provider);
Navigation.registerComponent('SelectTeam', () => wrapWithContextProvider(SelectTeam), store, Provider);
Navigation.registerComponent('Settings', () => wrapWithContextProvider(Settings), store, Provider);

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {viewChannel, markChannelAsRead} from 'mattermost-redux/actions/channels';
import {getPostsAfter, getPostsBefore, getPostThread, selectPost} from 'mattermost-redux/actions/posts';
import {clearSearch, removeSearchTerms, searchPosts} from 'mattermost-redux/actions/search';
import {RequestStatus} from 'mattermost-redux/constants';
import {getCurrentChannelId, getMyChannels} from 'mattermost-redux/selectors/entities/channels';
import {getSearchResults} from 'mattermost-redux/selectors/entities/posts';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {
handleSelectChannel,
loadThreadIfNecessary,
setChannelDisplayName,
setChannelLoading
} from 'app/actions/views/channel';
import {handleSearchDraftChanged} from 'app/actions/views/search';
import Search from './search';
function mapStateToProps(state, ownProps) {
const currentTeamId = getCurrentTeamId(state);
const currentChannelId = getCurrentChannelId(state);
const {recent} = state.entities.search;
const {searchPosts: searchRequest} = state.requests.search;
return {
...ownProps,
currentTeamId,
currentChannelId,
posts: getSearchResults(state),
recent: recent[currentTeamId] || [],
channels: getMyChannels(state),
searching: searchRequest.status === RequestStatus.STARTED
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
clearSearch,
getPostsAfter,
getPostsBefore,
getPostThread,
handleSearchDraftChanged,
handleSelectChannel,
loadThreadIfNecessary,
markChannelAsRead,
removeSearchTerms,
searchPosts,
selectPost,
setChannelDisplayName,
setChannelLoading,
viewChannel
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(Search);

View File

@@ -0,0 +1,574 @@
// Copyright (c) 2016-present Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React, {Component} from 'react';
import PropTypes from 'prop-types';
import {injectIntl, intlShape} from 'react-intl';
import {
Dimensions,
Platform,
StyleSheet,
Text,
TouchableHighlight,
TouchableOpacity,
View
} from 'react-native';
import IonIcon from 'react-native-vector-icons/Ionicons';
import {General} from 'mattermost-redux/constants';
import Autocomplete from 'app/components/autocomplete';
import Loading from 'app/components/loading';
import Post from 'app/components/post';
import SectionList from 'app/components/scrollable_section_list';
import SearchBar from 'app/components/search_bar';
import SearchPreview from 'app/components/search_preview';
import StatusBar from 'app/components/status_bar';
import {ViewTypes} from 'app/constants';
import {preventDoubleTap} from 'app/utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
const SECTION_HEIGHT = 20;
const RECENT_LABEL_HEIGHT = 42;
const RECENT_SEPARATOR_HEIGHT = 3;
const POSTS_PER_PAGE = ViewTypes.POST_VISIBILITY_CHUNK_SIZE;
const SEARCHING = 'searching';
class Search extends Component {
static propTypes = {
actions: PropTypes.shape({
clearSearch: PropTypes.func.isRequired,
getPostsAfter: PropTypes.func.isRequired,
getPostsBefore: PropTypes.func.isRequired,
getPostThread: PropTypes.func.isRequired,
handleSearchDraftChanged: PropTypes.func.isRequired,
loadThreadIfNecessary: PropTypes.func.isRequired,
removeSearchTerms: PropTypes.func.isRequired,
searchPosts: PropTypes.func.isRequired,
selectPost: PropTypes.func.isRequired
}).isRequired,
channels: PropTypes.array.isRequired,
currentTeamId: PropTypes.string.isRequired,
currentChannelId: PropTypes.string.isRequired,
intl: intlShape.isRequired,
navigator: PropTypes.object,
posts: PropTypes.array,
recent: PropTypes.array.isRequired,
searching: PropTypes.bool,
theme: PropTypes.object.isRequired
};
static defaultProps = {
posts: []
};
constructor(props) {
super(props);
props.navigator.setOnNavigatorEvent(this.onNavigatorEvent);
this.state = {
channelName: '',
isFocused: true,
postId: null,
preview: false,
value: ''
};
}
componentDidMount() {
this.refs.searchBar.focus();
}
shouldComponentUpdate(nextProps, nextState) {
return (
this.props.recent !== nextProps.recent ||
this.props.posts !== nextProps.posts ||
this.state !== nextState
);
}
componentDidUpdate() {
const {posts, recent} = this.props;
const recentLenght = recent.length;
if (posts.length && !this.state.isFocused) {
this.refs.list.getWrapperRef().getListRef().scrollToOffset({
animated: true,
offset: SECTION_HEIGHT + (recentLenght * RECENT_LABEL_HEIGHT) + ((recentLenght - 1) * RECENT_SEPARATOR_HEIGHT)
});
}
}
attachAutocomplete = (c) => {
this.autocomplete = c;
};
cancelSearch = () => {
const {navigator} = this.props;
this.handleTextChanged('');
navigator.dismissModal({animationType: 'slide-down'});
};
goToThread = (post) => {
const {actions, channels, intl, navigator, theme} = this.props;
const channelId = post.channel_id;
const channel = channels.find((c) => c.id === channelId);
const rootId = (post.root_id || post.id);
actions.loadThreadIfNecessary(post.root_id);
actions.selectPost(rootId);
let title;
if (channel.type === General.DM_CHANNEL) {
title = intl.formatMessage({id: 'mobile.routes.thread_dm', defaultMessage: 'Direct Message Thread'});
} else {
const channelName = channel.display_name;
title = intl.formatMessage({id: 'mobile.routes.thread', defaultMessage: '{channelName} Thread'}, {channelName});
}
const options = {
screen: 'Thread',
title,
animated: true,
backButtonTitle: '',
navigatorStyle: {
navBarTextColor: theme.sidebarHeaderTextColor,
navBarBackgroundColor: theme.sidebarHeaderBg,
navBarButtonColor: theme.sidebarHeaderTextColor,
screenBackgroundColor: theme.centerChannelBg
},
passProps: {
channelId,
rootId
}
};
if (Platform.OS === 'android') {
navigator.showModal(options);
} else {
navigator.push(options);
}
};
handleSelectionChange = (event) => {
if (this.autocomplete) {
this.autocomplete.handleSelectionChange(event);
}
};
handleTextChanged = (value) => {
const {actions, posts} = this.props;
this.setState({value});
actions.handleSearchDraftChanged(value);
if (!value && posts.length) {
actions.clearSearch();
}
};
keyRecentExtractor = (item) => {
return `recent-${item.terms}`;
};
keyPostExtractor = (item) => {
return `result-${item.id}`;
};
onBlur = () => {
this.setState({isFocused: false});
};
onFocus = () => {
const {posts} = this.props;
this.setState({isFocused: true});
if (posts.length) {
this.refs.list.getWrapperRef().getListRef().scrollToOffset({
animated: false,
offset: 0
});
}
};
onNavigatorEvent = (event) => {
if (event.id === 'backPress') {
if (this.state.preview) {
this.refs.preview.getWrappedInstance().handleClose();
} else {
this.props.navigator.dismissModal();
}
}
};
previewPost = (post) => {
const {actions, channels} = this.props;
const focusedPostId = post.id;
const channelId = post.channel_id;
actions.getPostThread(focusedPostId);
actions.getPostsBefore(channelId, focusedPostId, 0, POSTS_PER_PAGE);
actions.getPostsAfter(channelId, focusedPostId, 0, POSTS_PER_PAGE);
const channel = channels.find((c) => c.id === channelId);
let displayName = '';
if (channel) {
displayName = channel.display_name;
}
this.setState({preview: true, postId: focusedPostId, channelName: displayName});
};
removeSearchTerms = (item) => {
const {actions, currentTeamId} = this.props;
actions.removeSearchTerms(currentTeamId, item.terms);
};
renderPost = ({item}) => {
if (item.id === SEARCHING) {
return (
<View style={{width: '100%', height: '100%'}}>
{item.component}
</View>
);
}
const {channels, theme} = this.props;
const style = getStyleFromTheme(theme);
const channel = channels.find((c) => c.id === item.channel_id);
let displayName = '';
if (channel) {
displayName = channel.display_name;
}
return (
<View>
<Text style={style.channelName}>
{displayName}
</Text>
<Post
post={item}
renderReplies={true}
isFirstReply={false}
isLastReply={false}
commentedOnPost={null}
onPress={this.previewPost}
onReply={this.goToThread}
isSearchResult={true}
shouldRenderReplyButton={true}
navigator={this.props.navigator}
/>
</View>
);
};
renderRecentSeparator = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={style.separatorContainer}>
<View style={style.separator}/>
</View>
);
};
renderPostSeparator = () => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<View style={[style.separatorContainer, style.postsSeparator]}>
<View style={style.separator}/>
</View>
);
};
renderSectionHeader = ({section}) => {
const {theme} = this.props;
const {title} = section;
const style = getStyleFromTheme(theme);
return (
<View style={style.sectionWrapper}>
<View style={style.sectionContainer}>
<Text style={style.sectionLabel}>
{title}
</Text>
</View>
</View>
);
};
renderRecentItem = ({item}) => {
const {theme} = this.props;
const style = getStyleFromTheme(theme);
return (
<TouchableHighlight
key={item.terms}
underlayColor={changeOpacity(theme.sidebarTextHoverBg, 0.5)}
onPress={() => preventDoubleTap(this.setRecentValue, this, item)}
>
<View
style={style.recentItemContainer}
>
<Text
style={style.recentItemLabel}
>
{item.terms}
</Text>
<TouchableOpacity
onPress={() => preventDoubleTap(this.removeSearchTerms, this, item)}
style={style.recentRemove}
>
<IonIcon
name='ios-close-circle-outline'
size={20}
color={changeOpacity(theme.centerChannelColor, 0.6)}
/>
</TouchableOpacity>
</View>
</TouchableHighlight>
);
};
search = (terms, isOrSearch) => {
const {actions, currentTeamId} = this.props;
actions.searchPosts(currentTeamId, terms, isOrSearch);
};
setRecentValue = (recent) => {
const {terms, isOrSearch} = recent;
this.handleTextChanged(terms);
this.search(terms, isOrSearch);
this.refs.searchBar.blur();
};
handleClosePreview = () => {
// console.warn('close preview');
this.setState({preview: false, postId: null});
};
handleJumpToChannel = (channelId) => {
if (channelId) {
const {actions, channels, currentChannelId} = this.props;
const {
handleSelectChannel,
markChannelAsRead,
setChannelLoading,
setChannelDisplayName,
viewChannel
} = actions;
const channel = channels.find((c) => c.id === channelId);
let displayName = '';
if (channel) {
displayName = channel.display_name;
}
this.props.navigator.dismissModal({animationType: 'none'});
setChannelLoading();
markChannelAsRead(channelId, currentChannelId);
viewChannel(channelId, currentChannelId);
setChannelDisplayName(displayName);
handleSelectChannel(channelId);
}
};
render() {
const {
intl,
navigator,
posts,
recent,
searching,
theme
} = this.props;
const {channelName, postId, preview, value} = this.state;
const style = getStyleFromTheme(theme);
const sections = [];
if (recent.length) {
sections.push({
data: recent,
key: 'recent',
title: intl.formatMessage({id: 'mobile.search.recentTitle', defaultMessage: 'Recent Searches'}),
renderItem: this.renderRecentItem,
keyExtractor: this.keyRecentExtractor,
ItemSeparatorComponent: this.renderRecentSeparator
});
}
let results;
if (searching) {
results = [{
id: SEARCHING,
component: <Loading/>
}];
} else if (posts.length) {
results = posts;
}
if (results) {
sections.push({
data: results,
key: 'results',
title: intl.formatMessage({id: 'search_header.results', defaultMessage: 'Search Results'}),
renderItem: this.renderPost,
keyExtractor: this.keyPostExtractor,
ItemSeparatorComponent: this.renderPostSeparator
});
}
let previewComponent;
if (preview) {
previewComponent = (
<SearchPreview
ref='preview'
channelName={channelName}
focusedPostId={postId}
navigator={navigator}
onClose={this.handleClosePreview}
onPress={this.handleJumpToChannel}
theme={theme}
/>
);
}
return (
<View style={{flex: 1}}>
<StatusBar/>
<View style={style.header}>
<SearchBar
ref='searchBar'
placeholder={intl.formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={intl.formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={Platform.OS === 'ios' ? 33 : 46}
inputStyle={{
backgroundColor: changeOpacity(theme.sidebarHeaderTextColor, 0.2),
color: theme.sidebarHeaderTextColor,
...Platform.select({
android: {
fontSize: 15
},
ios: {
fontSize: 13
}
})
}}
placeholderTextColor={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
tintColorSearch={changeOpacity(theme.sidebarHeaderTextColor, 0.8)}
tintColorDelete={changeOpacity(theme.sidebarHeaderTextColor, 0.5)}
titleCancelColor={theme.sidebarHeaderTextColor}
onChangeText={this.handleTextChanged}
onBlur={this.onBlur}
onFocus={this.onFocus}
onSearchButtonPress={(text) => preventDoubleTap(this.search, this, text)}
onCancelButtonPress={() => preventDoubleTap(this.cancelSearch, this)}
onSelectionChange={this.handleSelectionChange}
autoCapitalize='none'
value={value}
containerStyle={{padding: 0}}
backArrowSize={28}
/>
</View>
<Autocomplete
ref={this.attachAutocomplete}
onChangeText={this.handleTextChanged}
isSearch={true}
/>
<SectionList
ref='list'
style={style.sectionList}
renderSectionHeader={this.renderSectionHeader}
sections={sections}
keyboardShouldPersistTaps='handled'
stickySectionHeadersEnabled={true}
/>
{previewComponent}
</View>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return StyleSheet.create({
header: {
backgroundColor: theme.sidebarHeaderBg,
width: Dimensions.get('window').width,
...Platform.select({
android: {
height: 46,
justifyContent: 'center'
},
ios: {
height: 64,
paddingTop: 20
}
})
},
sectionWrapper: {
backgroundColor: theme.centerChannelBg
},
sectionContainer: {
justifyContent: 'center',
backgroundColor: changeOpacity(theme.centerChannelColor, 0.07),
paddingLeft: 16,
height: SECTION_HEIGHT
},
sectionLabel: {
color: theme.centerChannelColor,
fontSize: 12,
fontWeight: '600'
},
recentItemContainer: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
height: RECENT_LABEL_HEIGHT
},
recentItemLabel: {
color: theme.centerChannelColor,
fontSize: 14,
height: 20,
flex: 1,
paddingHorizontal: 16
},
recentRemove: {
alignItems: 'center',
height: RECENT_LABEL_HEIGHT,
justifyContent: 'center',
width: 50
},
separatorContainer: {
justifyContent: 'center',
flex: 1,
height: RECENT_SEPARATOR_HEIGHT
},
postsSeparator: {
height: 15
},
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: StyleSheet.hairlineWidth
},
channelName: {
color: changeOpacity(theme.centerChannelColor, 0.8),
fontSize: 14,
fontWeight: '600',
marginTop: 5,
paddingHorizontal: 16
},
sectionList: {
flex: 1,
zIndex: -1
}
});
});
export default injectIntl(Search);

View File

@@ -1775,6 +1775,7 @@
"mobile.routes.thread_dm": "Direct Message Thread",
"mobile.routes.user_profile": "Profile",
"mobile.routes.user_profile.send_message": "Send Message",
"mobile.search.jump": "JUMP",
"mobile.select_team.choose": "Your teams:",
"mobile.select_team.join_open": "Open teams you can join",
"mobile.select_team.no_teams": "There are no available teams for you to join.",
@@ -1788,6 +1789,7 @@
"mobile.settings.clear_button": "Clear",
"mobile.settings.clear_message": "\nThis will clear all offline data and restart the app. You will be automatically logged back in once the app restarts.\n",
"mobile.settings.team_selection": "Team Selection",
"mobile.suggestion.members": "Members",
"modal.manaul_status.ask": "Do not ask me again",
"modal.manaul_status.button": "Yes, set my status to \"Online\"",
"modal.manaul_status.cancel": "No, keep it as \"{status}\"",

View File

@@ -17,6 +17,7 @@
"react": "16.0.0-alpha.6",
"react-intl": "2.3.0",
"react-native": "0.44.0",
"react-native-animatable": "1.2.2",
"react-native-bottom-sheet": "1.0.1",
"react-native-button": "1.8.2",
"react-native-cookies": "3.1.0",

View File

@@ -3645,7 +3645,7 @@ makeerror@1.0.x:
mattermost-redux@mattermost/mattermost-redux#master:
version "0.0.1"
resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/0d2b682cabc3893a0124f6674bcbfb653eead4a5"
resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/81416da8fd9f2df12286a732c7e98fd031dfd329"
dependencies:
deep-equal "1.0.1"
harmony-reflect "1.5.1"
@@ -4452,6 +4452,10 @@ react-intl@2.3.0:
intl-relativeformat "^1.3.0"
invariant "^2.1.1"
react-native-animatable@1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/react-native-animatable/-/react-native-animatable-1.2.2.tgz#a873550e6f7cb95f90baf46b9eb15b1ab56425cb"
react-native-bottom-sheet@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/react-native-bottom-sheet/-/react-native-bottom-sheet-1.0.1.tgz#bce4df50cda419f61c938179508640d5d9ff7768"