forked from Ivasoft/mattermost-mobile
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:
4
Makefile
4
Makefile
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
13
app/actions/views/search.js
Normal file
13
app/actions/views/search.js
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ function makeMapStateToProps() {
|
||||
post,
|
||||
config,
|
||||
currentUserId: getCurrentUserId(state),
|
||||
highlight: ownProps.post.highlight,
|
||||
license,
|
||||
roles,
|
||||
theme: getTheme(state),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
30
app/components/scrollable_section_list/index.js
Normal file
30
app/components/scrollable_section_list/index.js
Normal 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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
35
app/components/search_preview/index.js
Normal file
35
app/components/search_preview/index.js
Normal 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);
|
||||
234
app/components/search_preview/search_preview.js
Normal file
234
app/components/search_preview/search_preview.js
Normal 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'
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
18
app/reducers/views/search.js
Normal file
18
app/reducers/views/search.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -154,6 +154,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 10,
|
||||
paddingTop: 5,
|
||||
zIndex: 30
|
||||
},
|
||||
badge: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
116
app/screens/channel/channel_search_button.js
Normal file
116
app/screens/channel/channel_search_button.js
Normal 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);
|
||||
@@ -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}}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
63
app/screens/search/index.js
Normal file
63
app/screens/search/index.js
Normal 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);
|
||||
574
app/screens/search/search.js
Normal file
574
app/screens/search/search.js
Normal 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);
|
||||
@@ -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}\"",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user