Channel drawer (#154)

* Channel list drawer
* Get channels by category
* Fix and connect websocket
* Fix Select first team using the team members

* create loadMe and fix login actions to loads the teams

* Add android back button handler for the channel drawer

* Channel drawer styling and functionality

* Mark channel as viewed and fixed reset unread counts

* Unread above/below indicators

* Improve performance replacing ScrollView with ListView

* Fix unread indicators

* Addressing review feedback
This commit is contained in:
enahum 2017-01-03 19:49:28 -03:00 committed by GitHub
parent aa5a77ae86
commit 81526fbb09
47 changed files with 1724 additions and 253 deletions

View file

@ -535,3 +535,36 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
## react-native-svg
This product contains a modified portion of 'react-native-svg', library to provide a SVG interface to react native on both iOS and Android.
* HOMEPAGE
* https://github.com/react-native-community/react-native-svg
* LICENSE:
The MIT License (MIT)
Copyright (c) [2015-2016] [Horcrux]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---

View file

@ -127,6 +127,7 @@ android {
}
dependencies {
compile project(':react-native-svg')
compile fileTree(dir: "libs", include: ["*.jar"])
compile "com.android.support:appcompat-v7:23.0.1"
compile "com.facebook.react:react-native:+" // From node_modules

View file

@ -4,6 +4,7 @@ import android.app.Application;
import android.util.Log;
import com.facebook.react.ReactApplication;
import com.horcrux.svg.RNSvgPackage;
import com.facebook.react.ReactInstanceManager;
import com.facebook.react.ReactNativeHost;
import com.facebook.react.ReactPackage;
@ -23,7 +24,8 @@ public class MainApplication extends Application implements ReactApplication {
@Override
protected List<ReactPackage> getPackages() {
return Arrays.<ReactPackage>asList(
new MainReactPackage()
new MainReactPackage(),
new RNSvgPackage()
);
}
};

View file

@ -1,3 +1,5 @@
rootProject.name = 'Mattermost'
include ':app'
include ':react-native-svg'
project(':react-native-svg').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-svg/android')

View file

@ -1,9 +1,13 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {fetchMyChannelsAndMembers, selectChannel} from 'service/actions/channels';
import {fetchMyChannelsAndMembers, getMyChannelMembers, selectChannel} from 'service/actions/channels';
import {getPosts} from 'service/actions/posts';
import {Constants} from 'service/constants';
import {getTeamMembersByIds} from 'service/actions/teams';
import {Constants, UsersTypes} from 'service/constants';
import {getChannelByName, getDirectChannelName} from 'service/utils/channel_utils';
import {getPreferencesByCategory} from 'service/utils/preference_utils';
import {batchActions} from 'redux-batched-actions';
export function loadChannelsIfNecessary(teamId) {
return async (dispatch, getState) => {
@ -18,18 +22,66 @@ export function loadChannelsIfNecessary(teamId) {
}
}
if (!hasChannelsForTeam) {
if (hasChannelsForTeam) {
await getMyChannelMembers(teamId)(dispatch, getState);
} else {
await fetchMyChannelsAndMembers(teamId)(dispatch, getState);
}
};
}
export function loadProfilesAndTeamMembersForDMSidebar(teamId) {
return async (dispatch, getState) => {
const state = getState();
const currentUserId = state.entities.users.currentId;
const {channels} = state.entities.channels;
const {myPreferences} = state.entities.preferences;
const {membersInTeam} = state.entities.teams;
const dmPrefs = getPreferencesByCategory(myPreferences, Constants.CATEGORY_DIRECT_CHANNEL_SHOW);
const members = [];
for (const [key, pref] of dmPrefs) {
if (pref.value === 'true') {
members.push(key);
}
}
let membersToLoad = members;
if (membersInTeam[teamId]) {
membersToLoad = members.filter((m) => !membersInTeam[teamId].has(m));
}
await getTeamMembersByIds(teamId, membersToLoad)(dispatch, getState);
const actions = [];
for (let i = 0; i < members.length; i++) {
const channelName = getDirectChannelName(currentUserId, members[i]);
const channel = getChannelByName(channels, channelName);
if (channel) {
actions.push({
type: UsersTypes.RECEIVED_PROFILE_IN_CHANNEL,
data: {user_id: members[i]},
id: channel.id
});
}
}
if (actions.length) {
dispatch(batchActions(actions), getState);
}
};
}
export function loadPostsIfNecessary(channel) {
return async (dispatch, getState) => {
const postsInChannel = getState().entities.posts.postsByChannel[channel.id];
if (!postsInChannel) {
await getPosts(channel.team_id, channel.id)(dispatch, getState);
let teamId = channel.team_id;
if (!teamId) {
teamId = getState().entities.teams.currentId;
}
await getPosts(teamId, channel.id)(dispatch, getState);
}
};
}

View file

@ -0,0 +1,16 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {ViewTypes} from 'app/constants';
export function openChannelDrawer() {
return async (dispatch, getState) => {
dispatch({type: ViewTypes.TOGGLE_CHANNEL_DRAWER, data: true}, getState);
};
}
export function closeChannelDrawer() {
return async (dispatch, getState) => {
dispatch({type: ViewTypes.TOGGLE_CHANNEL_DRAWER, data: false}, getState);
};
}

View file

@ -1,13 +1,11 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {batchActions} from 'redux-batched-actions';
import {NavigationTypes} from 'app/constants';
import Routes from 'app/navigation/routes';
import Client from 'service/client';
import {forceLogoutIfNecessary} from 'service/actions/helpers';
import {PreferencesTypes, TeamsTypes, UsersTypes} from 'service/constants';
import {loadMe} from 'service/actions/users';
export function goToSelectServer() {
return async (dispatch, getState) => {
@ -24,76 +22,7 @@ export function setStoreFromLocalData(data) {
Client.setToken(data.token);
Client.setUrl(data.url);
let user;
dispatch({type: UsersTypes.LOGIN_REQUEST}, getState);
try {
user = await Client.getMe();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState);
return;
}
let preferences;
dispatch({type: PreferencesTypes.MY_PREFERENCES_REQUEST}, getState);
try {
preferences = await Client.getMyPreferences();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: PreferencesTypes.MY_PREFERENCES_FAILURE, error}, getState);
return;
}
let teams;
dispatch({type: TeamsTypes.FETCH_TEAMS_REQUEST}, getState);
try {
teams = await Client.getAllTeams();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: TeamsTypes.FETCH_TEAMS_FAILURE, error}, getState);
return;
}
let teamMembers;
dispatch({type: TeamsTypes.MY_TEAM_MEMBERS_REQUEST}, getState);
try {
teamMembers = await Client.getMyTeamMembers();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: TeamsTypes.MY_TEAM_MEMBERS_FAILURE, error}, getState);
return;
}
dispatch(batchActions([
{
type: UsersTypes.RECEIVED_ME,
data: user
},
{
type: UsersTypes.LOGIN_SUCCESS
},
{
type: PreferencesTypes.RECEIVED_PREFERENCES,
data: preferences
},
{
type: PreferencesTypes.MY_PREFERENCES_SUCCESS
},
{
type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS,
data: teamMembers
},
{
type: TeamsTypes.MY_TEAM_MEMBERS_SUCCESS
},
{
type: TeamsTypes.RECEIVED_ALL_TEAMS,
data: teams
},
{
type: TeamsTypes.FETCH_TEAMS_SUCCESS
}
]), getState);
return loadMe()(dispatch, getState);
};
}

View file

@ -0,0 +1,93 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {View, Text, StyleSheet} from 'react-native';
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row'
},
text: {
paddingVertical: 2,
paddingHorizontal: 4,
backgroundColor: 'transparent',
fontSize: 14,
textAlign: 'center',
textAlignVertical: 'center'
}
});
export default class Badge extends React.Component {
static defaultProps = {
extraPaddingHorizontal: 10,
minHeight: 0,
minWidth: 0
};
static propTypes = {
count: React.PropTypes.number.isRequired,
extraPaddingHorizontal: React.PropTypes.number,
style: View.propTypes.style,
countStyle: Text.propTypes.style,
minHeight: React.PropTypes.number,
minWidth: React.PropTypes.number
};
constructor(props) {
super(props);
this.width = 0;
}
renderText = () => {
return (
<Text
onLayout={this.onLayout}
style={[styles.text, this.props.countStyle]}
>
{this.props.count}
</Text>
);
};
onLayout = (e) => {
let width;
if (e.nativeEvent.layout.width <= e.nativeEvent.layout.height) {
width = e.nativeEvent.layout.height;
} else {
width = e.nativeEvent.layout.width + this.props.extraPaddingHorizontal;
}
width = Math.max(width, this.props.minWidth);
if (this.width === width) {
return;
}
this.width = width;
const height = Math.max(e.nativeEvent.layout.height, this.props.minHeight);
const borderRadius = height / 2;
this.container.setNativeProps({
style: {
width,
height,
borderRadius
}
});
};
render() {
return (
<View
ref={(component) => {
this.container = component;
}}
style={[styles.container, this.props.style]}
>
{this.renderText()}
</View>
);
}
}

View file

@ -0,0 +1,102 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {Platform, BackAndroid} from 'react-native';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Drawer from 'react-native-drawer';
import ChannelList from './channel_list';
export default class ChannelDrawer extends React.Component {
static propTypes = {
children: React.PropTypes.element.isRequired,
actions: React.PropTypes.shape({
selectChannel: React.PropTypes.func.isRequired,
viewChannel: React.PropTypes.func.isRequired,
closeChannelDrawer: React.PropTypes.func.isRequired
}).isRequired,
currentTeam: React.PropTypes.object,
currentChannelId: React.PropTypes.string,
channels: React.PropTypes.object,
channelMembers: React.PropTypes.object,
theme: React.PropTypes.object.isRequired,
isOpen: React.PropTypes.bool.isRequired
};
constructor(props) {
super(props);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
this.handleBackButton = this.handleBackButton.bind(this);
}
componentDidMount() {
if (Platform.OS === 'android') {
BackAndroid.addEventListener('hardwareBackPress', this.handleBackButton);
}
}
componentWillUnmount() {
if (Platform.OS === 'android') {
BackAndroid.removeEventListener('hardwareBackPress', this.handleBackButton);
}
}
componentWillReceiveProps(nextProps) {
if (!this.props.isOpen && nextProps.isOpen) {
this.drawer.open();
} else if (this.props.isOpen && !nextProps.isOpen) {
this.drawer.close();
}
}
handleBackButton() {
if (this.props.isOpen) {
this.props.actions.closeChannelDrawer();
return true;
}
return false;
}
render() {
const {
currentChannelId,
currentTeam,
channels,
channelMembers,
theme
} = this.props;
return (
<Drawer
ref={(ref) => {
this.drawer = ref;
}}
onClose={this.props.actions.closeChannelDrawer}
type='displace'
openDrawerOffset={0.2}
closedDrawerOffset={0}
panOpenMask={0.1}
panCloseMask={0.2}
panThreshold={0.2}
acceptPan={true}
tapToClose={true}
content={
<ChannelList
currentTeam={currentTeam}
currentChannelId={currentChannelId}
channels={channels}
channelMembers={channelMembers}
theme={theme}
onSelectChannel={this.props.actions.selectChannel}
onViewChannel={this.props.actions.viewChannel}
closeChannelDrawer={this.props.actions.closeChannelDrawer}
/>
}
>
{this.props.children}
</Drawer>
);
}
}

View file

@ -0,0 +1,33 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {selectChannel, viewChannel} from 'service/actions/channels';
import {getChannelsByCategory} from 'service/selectors/entities/channels';
import {closeChannelDrawer} from 'app/actions/views/drawer';
import ChannelDrawer from './channel_drawer';
function mapStateToProps(state, ownProps) {
const isOpen = state.views.drawer.channel;
const channelMembers = state.entities.channels.myMembers;
return {
...ownProps,
channels: getChannelsByCategory(state),
channelMembers,
isOpen
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
selectChannel,
viewChannel,
closeChannelDrawer
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ChannelDrawer);

View file

@ -0,0 +1,192 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import Icon from 'react-native-vector-icons/FontAwesome';
import Badge from './badge';
import {TouchableHighlight, Text, View} from 'react-native';
import {OnlineStatus, AwayStatus, OfflineStatus} from 'app/components/status_icons';
import {Constants} from 'service/constants';
export default class ChannelItem extends React.Component {
static propTypes = {
channel: React.PropTypes.object.isRequired,
onSelectChannel: React.PropTypes.func.isRequired,
isActive: React.PropTypes.bool.isRequired,
hasUnread: React.PropTypes.bool.isRequired,
mentions: React.PropTypes.number.isRequired,
theme: React.PropTypes.object.isRequired
};
changeOpacity = (oldColor, opacity) => {
let color = oldColor;
if (color[0] === '#') {
color = color.slice(1);
}
if (color.length === 3) {
const tempColor = color;
color = '';
color += tempColor[0] + tempColor[0];
color += tempColor[1] + tempColor[1];
color += tempColor[2] + tempColor[2];
}
const r = parseInt(color.substring(0, 2), 16);
const g = parseInt(color.substring(2, 4), 16);
const b = parseInt(color.substring(4, 6), 16);
return 'rgba(' + r + ',' + g + ',' + b + ',' + opacity + ')';
};
render() {
const {
channel,
theme,
mentions,
hasUnread,
isActive
} = this.props;
let iconColor = this.changeOpacity(theme.centerChannelColor, 0.7);
let icon;
let activeBorder;
let badge;
if (mentions && !isActive) {
const badgeStyle = {
position: 'absolute',
top: 10,
right: 10,
flexDirection: 'row',
backgroundColor: theme.mentionBj
};
const mentionStyle = {
color: theme.mentionColor,
fontSize: 14
};
badge = (
<Badge
style={badgeStyle}
countStyle={mentionStyle}
count={mentions}
minHeight={20}
minWidth={20}
/>
);
}
const itemStyle = {
alignItems: 'center',
height: 40,
paddingLeft: 20,
paddingRight: 10,
flex: 1,
flexDirection: 'row'
};
const style = {
marginLeft: 5,
opacity: 0.6,
color: theme.sidebarText
};
const activeStyle = {
width: 5,
height: 40,
backgroundColor: theme.sidebarTextActiveBorder,
position: 'absolute'
};
if (hasUnread) {
style.fontWeight = 'bold';
style.color = theme.sidebarUnreadText;
style.opacity = 1;
iconColor = theme.centerChannelColor;
}
if (isActive) {
iconColor = theme.sidebarTextActiveColor;
style.color = theme.sidebarTextActiveColor;
style.opacity = 1;
itemStyle.backgroundColor = this.changeOpacity(theme.sidebarTextActiveColor, 0.1);
activeBorder = (
<View style={activeStyle}/>
);
}
if (channel.type === Constants.OPEN_CHANNEL) {
icon = (
<Icon
name='globe'
size={15}
color={iconColor}
/>
);
} else if (channel.type === Constants.PRIVATE_CHANNEL) {
icon = (
<Icon
name='lock'
size={15}
color={iconColor}
/>
);
} else {
switch (channel.status) {
case Constants.ONLINE:
icon = (
<OnlineStatus
width={13}
height={13}
color={theme.onlineIndicator}
/>
);
break;
case Constants.AWAY:
icon = (
<AwayStatus
width={13}
height={13}
color={theme.awayIndicator}
/>
);
break;
default:
icon = (
<OfflineStatus
width={13}
height={13}
color={this.changeOpacity(theme.centerChannelColor, 0.7)}
/>
);
break;
}
}
return (
<TouchableHighlight
underlayColor={this.changeOpacity(theme.sidebarTextHoverBg, 0.3)}
onPress={() => this.props.onSelectChannel(channel)}
>
<View style={{flex: 1}}>
{activeBorder}
<View style={itemStyle}>
{icon}
<Text
style={style}
ellipsizeMode='tail'
numberOfLines={1}
>
{channel.display_name}
</Text>
{badge}
</View>
</View>
</TouchableHighlight>
);
}
}

View file

@ -0,0 +1,321 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {StyleSheet, Text, View, ListView} from 'react-native';
import LineDivider from 'app/components/line_divider';
import ChannelItem from './channel_item';
import FormattedText from 'app/components/formatted_text';
import UnreadIndicator from './unread_indicator';
const Styles = StyleSheet.create({
container: {
flex: 1,
marginTop: 20
},
scrollContainer: {
flex: 1
},
headerContainer: {
justifyContent: 'center',
flexDirection: 'column',
height: 50,
width: 300,
paddingLeft: 10
},
header: {
fontSize: 18,
fontWeight: 'bold'
},
title: {
paddingTop: 10,
paddingRight: 10,
paddingLeft: 10,
paddingBottom: 5,
fontSize: 15,
opacity: 0.6
},
indicatorText: {
paddingVertical: 2,
paddingHorizontal: 4,
backgroundColor: 'transparent',
fontSize: 14,
textAlign: 'center',
textAlignVertical: 'center'
}
});
export default class ChannelList extends React.Component {
static propTypes = {
currentTeam: React.PropTypes.object.isRequired,
currentChannelId: React.PropTypes.string,
channels: React.PropTypes.object.isRequired,
channelMembers: React.PropTypes.object,
theme: React.PropTypes.object.isRequired,
onSelectChannel: React.PropTypes.func.isRequired,
onViewChannel: React.PropTypes.func.isRequired,
closeChannelDrawer: React.PropTypes.func.isRequired
};
constructor(props) {
super(props);
this.firstUnreadChannel = null;
this.lastUnreadChannel = null;
this.state = {
showAbove: false,
showBelow: false,
dataSource: new ListView.DataSource({
rowHasChanged: (a, b) => a !== b
}).cloneWithRows(props)
};
}
componentWillReceiveProps(nextProps) {
this.setState({
dataSource: this.state.dataSource.cloneWithRows(this.buildData(nextProps))
});
const container = this.refs.scrollContainer;
if (container && container._visibleRows && container._visibleRows.s1) { //eslint-disable-line no-underscore-dangle
this.updateUnreadIndicators(container._visibleRows); //eslint-disable-line no-underscore-dangle
}
}
updateUnreadIndicators = (v) => {
const data = this.state.dataSource._dataBlob.s1; //eslint-disable-line no-underscore-dangle
if (this.firstUnreadChannel) {
const index = data.findIndex((obj) => obj.id === this.firstUnreadChannel);
if (v.s1[index]) {
this.setState({
showAbove: false
});
} else if (!v.s1[index - 1]) {
this.setState({
showAbove: true
});
}
}
if (this.lastUnreadChannel) {
const index = data.findIndex((obj) => obj.id === this.lastUnreadChannel);
if (v.s1[index]) {
this.setState({
showBelow: false
});
} else if (!v.s1[index + 1]) {
this.setState({
showBelow: true
});
}
}
};
onSelectChannel = (channel) => {
console.log('clicked channel ' + channel.name); // eslint-disable-line no-console
const {
currentChannelId,
currentTeam
} = this.props;
this.props.onSelectChannel(channel.id);
this.props.onViewChannel(currentTeam.id, channel.id, currentChannelId);
this.props.closeChannelDrawer();
};
getUnreadMessages = (channel) => {
const member = this.props.channelMembers[channel.id];
const mentions = member.mention_count;
let unreadCount = channel.total_msg_count - member.msg_count;
if (member.notify_props && member.notify_props.mark_unread === 'mention') {
unreadCount = 0;
}
return {
mentions,
unreadCount
};
};
findUnreadChannels = (data) => {
data.forEach((c) => {
if (c.id) {
const {mentions, unreadCount} = this.getUnreadMessages(c);
const unread = (mentions + unreadCount) > 0;
if (unread && c.id !== this.props.currentChannelId) {
if (this.firstUnreadChannel) {
this.lastUnreadChannel = c.id;
} else {
this.firstUnreadChannel = c.id;
}
}
}
});
};
createChannelElement = (channel) => {
const {mentions, unreadCount} = this.getUnreadMessages(channel);
const msgCount = mentions + unreadCount;
const unread = msgCount > 0;
return (
<ChannelItem
ref={channel.id}
key={channel.id}
channel={channel}
hasUnread={unread}
mentions={mentions}
onSelectChannel={this.onSelectChannel}
isActive={channel.id === this.props.currentChannelId}
theme={this.props.theme}
/>
);
};
buildData = (props) => {
const data = [];
if (!props.currentChannelId) {
return data;
}
const {
theme
} = props;
const {
favoriteChannels,
publicChannels,
privateChannels,
directChannels,
directNonTeamChannels
} = props.channels;
if (favoriteChannels.length) {
data.push(
<FormattedText
style={[Styles.title, {color: theme.sidebarText}]}
id='sidebar.favorite'
defaultMessage='FAVORITES'
/>,
...favoriteChannels
);
}
data.push(
<FormattedText
style={[Styles.title, {color: theme.sidebarText}]}
id='sidebar.channels'
defaultMessage='CHANNELS'
/>,
...publicChannels
);
data.push(
<FormattedText
style={[Styles.title, {color: theme.sidebarText}]}
id='sidebar.pg'
defaultMessage='PRIVATE GROUPS'
/>,
...privateChannels
);
data.push(
<FormattedText
style={[Styles.title, {color: theme.sidebarText}]}
id='sidebar.direct'
defaultMessage='DIRECT MESSAGES'
/>,
...directChannels
);
if (directNonTeamChannels.length) {
data.push(
<LineDivider
color={theme.sidebarTextActiveBorder}
translationId='sidebar.otherMembers'
translationText='Outside this team'
/>,
...directNonTeamChannels
);
}
this.firstUnreadChannel = null;
this.lastUnreadChannel = null;
this.findUnreadChannels(data);
return data;
};
renderRow = (rowData, sectionId, rowId) => {
if (rowData && rowData.id) {
return this.createChannelElement(rowData, sectionId, rowId);
}
return rowData;
};
render() {
if (!this.props.currentChannelId) {
return <Text>{'Loading'}</Text>;
}
const {
theme
} = this.props;
let above;
let below;
if (this.state.showAbove) {
above = (
<UnreadIndicator
style={{top: 55, backgroundColor: theme.mentionBj}}
text={(
<FormattedText
style={[Styles.indicatorText, {color: theme.mentionColor}]}
id='sidebar.unreadAbove'
defaultMessage='Unread post(s) above'
/>
)}
/>
);
}
if (this.state.showBelow) {
below = (
<UnreadIndicator
style={{bottom: 15, backgroundColor: theme.mentionBj}}
text={(
<FormattedText
style={[Styles.indicatorText, {color: theme.mentionColor}]}
id='sidebar.unreadBelow'
defaultMessage='Unread post(s) below'
/>
)}
/>
);
}
return (
<View style={[Styles.container, {backgroundColor: theme.sidebarBg}]}>
<View style={[Styles.headerContainer, {backgroundColor: theme.sidebarHeaderBg}]}>
<Text
ellipsizeMode='tail'
numberOfLines={1}
style={[Styles.header, {color: theme.sidebarHeaderTextColor}]}
>
{this.props.currentTeam.display_name}
</Text>
</View>
<ListView
ref='scrollContainer'
style={Styles.scrollContainer}
dataSource={this.state.dataSource}
renderRow={this.renderRow}
onChangeVisibleRows={this.updateUnreadIndicators}
/>
{above}
{below}
</View>
);
}
}

View file

@ -0,0 +1,6 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import ChannelDrawerContainer from './channel_drawer_container';
export default ChannelDrawerContainer;

View file

@ -0,0 +1,43 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {StyleSheet, Text, View} from 'react-native';
const Styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'row',
position: 'absolute',
borderRadius: 15,
marginLeft: 25,
width: 250,
height: 25
}
});
export default class UnreadIndicator extends React.Component {
static propTypes = {
style: View.propTypes.style,
textStyle: Text.propTypes.style,
text: React.PropTypes.node.isRequired
};
constructor(props) {
super(props);
this.shouldComponentUpdate = PureRenderMixin.shouldComponentUpdate.bind(this);
}
render() {
return (
<View
style={[Styles.container, this.props.style]}
>
{this.props.text}
</View>
);
}
}

View file

@ -1,68 +0,0 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {Text, View} from 'react-native';
class ChannelSidebar extends React.Component {
static propTypes = {
actions: React.PropTypes.shape({
selectChannel: React.PropTypes.func.isRequired
}).isRequired,
currentTeam: React.PropTypes.object.isRequired,
channels: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
};
onSelectChannel = (channel) => {
console.log('clicked channel ' + channel.name); // eslint-disable-line no-console
this.props.actions.selectChannel(channel.id);
}
render() {
const channels = this.props.channels.map((channel) => {
return (
<Text
key={channel.id}
style={{height: 30, width: 100}}
onPress={() => this.onSelectChannel(channel)}
>
{channel.name}
</Text>
);
});
return (
<View
style={{
backgroundColor: 'gray',
flex: 1,
paddingTop: 20
}}
>
<Text style={{height: 60, width: 100}}>{this.props.currentTeam.name}</Text>
{channels}
</View>
);
}
}
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {selectChannel} from 'service/actions/channels';
function mapStateToProps(state, ownProps) {
return ownProps;
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
selectChannel
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ChannelSidebar);

View file

@ -0,0 +1,83 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {StyleSheet, View} from 'react-native';
import FormattedText from 'app/components/formatted_text';
const Styles = StyleSheet.create({
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#b3b3b3'
},
dividerContainer: {
height: 20,
marginLeft: 15,
marginRight: 15
},
dividerText: {
flex: 1,
textAlign: 'center'
}
});
export default class LineDivider extends React.Component {
static propTypes = {
color: React.PropTypes.string.isRequired,
translationId: React.PropTypes.string,
translationText: React.PropTypes.string,
side: React.PropTypes.oneOf(['left', 'right', 'center'])
};
static defaultProps = {
side: 'right'
};
renderLine() {
return (
<View style={[Styles.dividerLine, {backgroundColor: this.props.color}]}/>
);
}
render() {
let renderText;
if (this.props.translationId && this.props.translationText) {
renderText = (
<View style={Styles.dividerContainer}>
<FormattedText
style={[Styles.dividerText, {color: this.props.color}]}
id={this.props.translationId}
defaultMessage={this.props.translationText}
/>
</View>
);
}
let content = (
<View style={{flexDirection: 'row', alignItems: 'center'}}>
{this.renderLine()}
{renderText}
</View>
);
if (this.props.side === 'left') {
content = (
<View style={{flexDirection: 'row', alignItems: 'center'}}>
{renderText}
{this.renderLine()}
</View>
);
} else if (this.props.side === 'center') {
content = (
<View style={{flexDirection: 'row', alignItems: 'center'}}>
{this.renderLine()}
{renderText}
{this.renderLine()}
</View>
);
}
return content;
}
}

View file

@ -0,0 +1,45 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import Svg, {
Ellipse,
G,
Path
} from 'react-native-svg';
export default class AwayStatus extends React.Component {
static propTypes = {
width: React.PropTypes.number.isRequired,
height: React.PropTypes.number.isRequired,
color: React.PropTypes.string.isRequired
};
render() {
return (
<Svg
width={this.props.width}
height={this.props.height}
viewBox='-299 391 12 12'
>
<G>
<Ellipse
cx='-294.6'
cy='394'
rx='2.5'
ry='2.5'
fill={this.props.color}
/>
<Path
d='M-293.8,399.4c0-0.4,0.1-0.7,0.2-1c-0.3,0.1-0.6,0.2-1,0.2c-2.5,0-2.5-2-2.5-2s-1,0.1-1.2,0.5c-0.4,0.6-0.6,1.7-0.7,2.5 c0,0.1-0.1,0.5,0,0.6c0.2,1.3,2.2,2.3,4.4,2.4c0,0,0.1,0,0.1,0c0,0,0.1,0,0.1,0c0.7,0,1.4-0.1,2-0.3 C-293.3,401.5-293.8,400.5-293.8,399.4z'
fill={this.props.color}
/>
</G>
<Path
d='M-287,400c0,0.1-0.1,0.1-0.1,0.1l-4.9,0c-0.1,0-0.1-0.1-0.1-0.1v-1.6c0-0.1,0.1-0.1,0.1-0.1l4.9,0c0.1,0,0.1,0.1,0.1,0.1 V400z'
fill={this.props.color}
/>
</Svg>
);
}
}

View file

@ -0,0 +1,12 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import OnlineStatus from './online';
import AwayStatus from './away';
import OfflineStatus from './offline';
export {
OnlineStatus,
AwayStatus,
OfflineStatus
};

View file

@ -0,0 +1,49 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import Svg, {
Ellipse,
G,
Path
} from 'react-native-svg';
export default class OfflineStatus extends React.Component {
static propTypes = {
width: React.PropTypes.number.isRequired,
height: React.PropTypes.number.isRequired,
color: React.PropTypes.string.isRequired
};
render() {
return (
<Svg
width={this.props.width}
height={this.props.height}
viewBox='-299 391 12 12'
>
<G>
<G>
<Ellipse
cx='-294.5'
cy='394'
rx='2.5'
ry='2.5'
fill={this.props.color}
/>
<Path
d='M-294.3,399.7c0-0.4,0.1-0.8,0.2-1.2c-0.1,0-0.2,0-0.4,0c-2.5,0-2.5-2-2.5-2s-1,0.1-1.2,0.5c-0.4,0.6-0.6,1.7-0.7,2.5 c0,0.1-0.1,0.5,0,0.6c0.2,1.3,2.2,2.3,4.4,2.4h0.1h0.1c0.3,0,0.7,0,1-0.1C-293.9,401.6-294.3,400.7-294.3,399.7z'
fill={this.props.color}
/>
</G>
</G>
<G>
<Path
d='M-288.9,399.4l1.8-1.8c0.1-0.1,0.1-0.3,0-0.3l-0.7-0.7c-0.1-0.1-0.3-0.1-0.3,0l-1.8,1.8l-1.8-1.8c-0.1-0.1-0.3-0.1-0.3,0 l-0.7,0.7c-0.1,0.1-0.1,0.3,0,0.3l1.8,1.8l-1.8,1.8c-0.1,0.1-0.1,0.3,0,0.3l0.7,0.7c0.1,0.1,0.3,0.1,0.3,0l1.8-1.8l1.8,1.8 c0.1,0.1,0.3,0.1,0.3,0l0.7-0.7c0.1-0.1,0.1-0.3,0-0.3L-288.9,399.4z'
fill={this.props.color}
/>
</G>
</Svg>
);
}
}

View file

@ -0,0 +1,53 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import Svg, {
Ellipse,
G,
Path
} from 'react-native-svg';
export default class OnlineStatus extends React.Component {
static propTypes = {
width: React.PropTypes.number.isRequired,
height: React.PropTypes.number.isRequired,
color: React.PropTypes.string.isRequired
};
render() {
return (
<Svg
width={this.props.width}
height={this.props.height}
viewBox='-243 245 12 12'
>
<G>
<Path
d='M-236,250.5C-236,250.5-236,250.5-236,250.5C-236,250.5-236,250.5-236,250.5C-236,250.5-236,250.5-236,250.5z'
fill={this.props.color}
/>
<Ellipse
cx='-238.5'
cy='248'
rx='2.5'
ry='2.5'
fill={this.props.color}
/>
</G>
<Path
d='M-238.9,253.8c0-0.4,0.1-0.9,0.2-1.3c-2.2-0.2-2.2-2-2.2-2s-1,0.1-1.2,0.5c-0.4,0.6-0.6,1.7-0.7,2.5c0,0.1-0.1,0.5,0,0.6 c0.2,1.3,2.2,2.3,4.4,2.4c0,0,0.1,0,0.1,0c0,0,0.1,0,0.1,0c0,0,0.1,0,0.1,0C-238.7,255.7-238.9,254.8-238.9,253.8z'
fill={this.props.color}
/>
<G>
<G>
<Path
d='M-232.3,250.1l1.3,1.3c0,0,0,0.1,0,0.1l-4.1,4.1c0,0,0,0-0.1,0c0,0,0,0,0,0l-2.7-2.7c0,0,0-0.1,0-0.1l1.2-1.2 c0,0,0.1,0,0.1,0l1.4,1.4l2.9-2.9C-232.4,250.1-232.3,250.1-232.3,250.1z'
fill={this.props.color}
/>
</G>
</G>
</Svg>
);
}
}

View file

@ -7,7 +7,9 @@ const ViewTypes = keyMirror({
SERVER_URL_CHANGED: null,
LOGIN_ID_CHANGED: null,
PASSWORD_CHANGED: null
PASSWORD_CHANGED: null,
TOGGLE_CHANNEL_DRAWER: null
});
export default ViewTypes;

View file

@ -43,10 +43,8 @@ class Router extends React.Component {
<NavigationExperimental.Card
{...cardProps}
style={NavigationExperimental.Card.PagerStyleInterpolator.forHorizontal({
...cardProps,
onNavigateBack: this.props.actions.goBack
...cardProps
})}
onNavigateBack={this.props.actions.goBack}
renderScene={this.renderScene}
key={scene.key}
/>

View file

@ -0,0 +1,20 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {combineReducers} from 'redux';
import {ViewTypes} from 'app/constants';
function channel(state = false, action) {
switch (action.type) {
case ViewTypes.TOGGLE_CHANNEL_DRAWER:
return action.data;
default:
return state;
}
}
export default combineReducers({
channel
});

View file

@ -6,9 +6,11 @@ import {combineReducers} from 'redux';
import login from './login';
import selectServer from './select_server';
import i18n from './i18n';
import drawer from './drawer';
export default combineReducers({
i18n,
login,
selectServer
selectServer,
drawer
});

View file

@ -4,9 +4,10 @@
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import {StatusBar, Text, TouchableHighlight, View} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import Drawer from 'react-native-drawer';
import ChannelSidebar from 'app/components/channel_sidebar';
import ChannelDrawer from 'app/components/channel_drawer';
import RightSidebarMenu from 'app/components/right_sidebar_menu';
import ChannelPostList from './components/channel_post_list';
@ -15,11 +16,12 @@ export default class Channel extends React.Component {
static propTypes = {
actions: React.PropTypes.shape({
loadChannelsIfNecessary: React.PropTypes.func.isRequired,
selectInitialChannel: React.PropTypes.func.isRequired
loadProfilesAndTeamMembersForDMSidebar: React.PropTypes.func.isRequired,
selectInitialChannel: React.PropTypes.func.isRequired,
openChannelDrawer: React.PropTypes.func.isRequired
}).isRequired,
currentTeam: React.PropTypes.object.isRequired,
currentTeam: React.PropTypes.object,
currentChannel: React.PropTypes.object,
channels: React.PropTypes.array,
theme: React.PropTypes.object.isRequired
};
@ -35,25 +37,22 @@ export default class Channel extends React.Component {
}
componentWillMount() {
this.props.actions.loadChannelsIfNecessary(this.props.currentTeam.id).then(() => {
return this.props.actions.selectInitialChannel(this.props.currentTeam.id);
});
const teamId = this.props.currentTeam.id;
this.loadChannels(teamId);
}
componentWillReceiveProps(nextProps) {
if (this.props.currentTeam.id !== nextProps.currentTeam.id) {
this.props.actions.loadChannelsIfNecessary(nextProps.currentTeam.id).then(() => {
this.props.actions.selectInitialChannel(nextProps.currentTeam.id);
});
if (nextProps.currentTeam && this.props.currentTeam.id !== nextProps.currentTeam.id) {
const teamId = nextProps.currentTeam.id;
this.loadChannels(teamId);
}
}
openLeftSidebar = () => {
this.setState({leftSidebarOpen: true});
};
closeLeftSidebar = () => {
this.setState({leftSidebarOpen: false});
loadChannels = (teamId) => {
this.props.actions.loadChannelsIfNecessary(teamId).then(() => {
this.props.actions.loadProfilesAndTeamMembersForDMSidebar(teamId);
return this.props.actions.selectInitialChannel(teamId);
});
};
openRightSidebar = () => {
@ -68,7 +67,6 @@ export default class Channel extends React.Component {
const {
currentTeam,
currentChannel,
channels,
theme
} = this.props;
@ -81,19 +79,10 @@ export default class Channel extends React.Component {
return (
<View style={{flex: 1, backgroundColor: theme.centerChannelBg}}>
<StatusBar barStyle='default'/>
<Drawer
open={this.state.leftSidebarOpen}
type='displace'
content={
<ChannelSidebar
currentTeam={currentTeam}
channels={channels}
/>
}
side='left'
tapToClose={true}
onCloseStart={this.closeLeftSidebar}
openDrawerOffset={0.2}
<ChannelDrawer
currentTeam={currentTeam}
currentChannelId={currentChannel.id}
theme={theme}
>
<Drawer
open={this.state.rightSidebarOpen}
@ -104,13 +93,24 @@ export default class Channel extends React.Component {
onCloseStart={this.closeRightSidebar}
openDrawerOffset={0.2}
>
<View style={{backgroundColor: theme.sidebarHeaderBg, flexDirection: 'row', justifyContent: 'space-between', marginTop: 20}}>
<TouchableHighlight
onPress={this.openLeftSidebar}
style={{height: 50, width: 50}}
>
<Text style={{color: theme.sidebarHeaderTextColor}}>{'<'}</Text>
</TouchableHighlight>
<View style={{backgroundColor: theme.sidebarHeaderBg, flexDirection: 'row', justifyContent: 'flex-start', marginTop: 20}}>
<View style={{flexDirection: 'column', justifyContent: 'center', alignItems: 'center'}}>
<TouchableHighlight
onPress={this.props.actions.openChannelDrawer}
style={{height: 25, width: 25, marginLeft: 10, marginRight: 10}}
>
<Icon
name='bars'
size={25}
color={theme.sidebarHeaderTextColor}
/>
</TouchableHighlight>
</View>
<View style={{flex: 1, flexDirection: 'row', justifyContent: 'flex-start', alignItems: 'center', backgroundColor: theme.sidebarHeaderBg}}>
<Text style={{color: theme.sidebarHeaderTextColor, fontSize: 15, fontWeight: 'bold'}}>
{currentChannel.display_name}
</Text>
</View>
<TouchableHighlight
onPress={this.openRightSidebar}
style={{height: 50, width: 50}}
@ -120,7 +120,7 @@ export default class Channel extends React.Component {
</View>
<ChannelPostList channel={currentChannel}/>
</Drawer>
</Drawer>
</ChannelDrawer>
</View>
);
}

View file

@ -4,20 +4,38 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {loadChannelsIfNecessary, selectInitialChannel} from 'app/actions/views/channel';
import {
loadChannelsIfNecessary,
loadProfilesAndTeamMembersForDMSidebar,
selectInitialChannel
} from 'app/actions/views/channel';
import {openChannelDrawer} from 'app/actions/views/drawer';
import {getChannelsOnCurrentTeam, getCurrentChannel} from 'service/selectors/entities/channels';
import {getCurrentChannel} from 'service/selectors/entities/channels';
import {getTheme} from 'service/selectors/entities/preferences';
import {getCurrentTeam} from 'service/selectors/entities/teams';
import {Constants} from 'service/constants';
import {displayUsername} from 'service/utils/user_utils';
import {getUserIdFromChannelName} from 'service/utils/channel_utils';
import Channel from './channel.js';
function mapStateToProps(state, ownProps) {
const channel = getCurrentChannel(state);
const currentChannel = {...channel};
const {currentId, profiles} = state.entities.users;
const {myPreferences} = state.entities.preferences;
if (channel && channel.type === Constants.DM_CHANNEL) {
const otherUserId = getUserIdFromChannelName(currentId, currentChannel);
currentChannel.display_name = displayUsername(profiles[otherUserId], myPreferences);
}
return {
...ownProps,
currentTeam: getCurrentTeam(state),
currentChannel: getCurrentChannel(state),
channels: getChannelsOnCurrentTeam(state),
currentChannel,
theme: getTheme(state)
};
}
@ -26,7 +44,9 @@ function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
loadChannelsIfNecessary,
selectInitialChannel
loadProfilesAndTeamMembersForDMSidebar,
selectInitialChannel,
openChannelDrawer
}, dispatch)
};
}

View file

@ -24,16 +24,25 @@ export default class SelectTeam extends Component {
};
componentWillMount() {
this.props.actions.fetchTeams();
this.props.actions.websocket();
}
componentDidMount() {
this.selectFirstTeam(this.props.teams, this.props.myMembers);
}
componentWillReceiveProps(nextProps) {
if (this.props.teamsRequest.status === RequestStatus.STARTED &&
nextProps.teamsRequest.status === RequestStatus.SUCCESS) {
const firstTeam = Object.values(nextProps.teams).sort((t) => t.name && t.name.trim().toLowerCase())[0];
if (firstTeam) {
this.onSelectTeam(firstTeam);
}
this.selectFirstTeam(nextProps.teams, nextProps.myMembers);
}
}
selectFirstTeam(allTeams, myMembers) {
const teams = Object.keys(myMembers).map((key) => allTeams[key]);
const firstTeam = Object.values(teams).sort((a, b) => a.display_name.localeCompare(b.display_name))[0];
if (firstTeam) {
this.onSelectTeam(firstTeam);
}
}

View file

@ -4,7 +4,8 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import * as teamActions from 'service/actions/teams';
import {selectTeam} from 'service/actions/teams';
import {init as websocket} from 'service/actions/websocket';
import {goToChannelView} from 'app/actions/navigation';
import SelectTeamView from './select_team.js';
@ -21,8 +22,9 @@ function mapStateToProps(state) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
...teamActions,
goToChannelView
goToChannelView,
selectTeam,
websocket
}, dispatch)
};
}

View file

@ -5,7 +5,6 @@
};
objectVersion = 46;
objects = {
/* Begin PBXBuildFile section */
00C302E51ABCBA2D00DB3ED1 /* libRCTActionSheet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 00C302AC1ABCB8CE00DB3ED1 /* libRCTActionSheet.a */; };
00C302E71ABCBA2D00DB3ED1 /* libRCTGeolocation.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 00C302BA1ABCB90400DB3ED1 /* libRCTGeolocation.a */; };
@ -31,6 +30,7 @@
1EA2B00A1DB7E9620013B2DB /* Zocial.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 1EA2B0021DB7E9620013B2DB /* Zocial.ttf */; };
7F55909D1DE33E62008E7FC5 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
832341BD1AAA6AB300B99B32 /* libRCTText.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 832341B51AAA6A8300B99B32 /* libRCTText.a */; };
6D451C9F2CCA47089433C8C5 /* libRNSVG.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 054B6299E0894F978806187D /* libRNSVG.a */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -143,6 +143,8 @@
1EA2B0021DB7E9620013B2DB /* Zocial.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Zocial.ttf; sourceTree = "<group>"; };
78C398B01ACF4ADC00677621 /* RCTLinking.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTLinking.xcodeproj; path = "../node_modules/react-native/Libraries/LinkingIOS/RCTLinking.xcodeproj"; sourceTree = "<group>"; };
832341B01AAA6A8300B99B32 /* RCTText.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTText.xcodeproj; path = "../node_modules/react-native/Libraries/Text/RCTText.xcodeproj"; sourceTree = "<group>"; };
EFA9957A8DD4430C85C9D4D5 /* RNSVG.xcodeproj */ = {isa = PBXFileReference; name = "RNSVG.xcodeproj"; path = "../node_modules/react-native-svg/ios/RNSVG.xcodeproj"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = wrapper.pb-project; explicitFileType = undefined; includeInIndex = 0; };
054B6299E0894F978806187D /* libRNSVG.a */ = {isa = PBXFileReference; name = "libRNSVG.a"; path = "libRNSVG.a"; sourceTree = "<group>"; fileEncoding = undefined; lastKnownFileType = archive.ar; explicitFileType = undefined; includeInIndex = 0; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -168,6 +170,7 @@
832341BD1AAA6AB300B99B32 /* libRCTText.a in Frameworks */,
00C302EA1ABCBA2D00DB3ED1 /* libRCTVibration.a in Frameworks */,
139FDEF61B0652A700C62182 /* libRCTWebSocket.a in Frameworks */,
6D451C9F2CCA47089433C8C5 /* libRNSVG.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -305,6 +308,7 @@
832341B01AAA6A8300B99B32 /* RCTText.xcodeproj */,
00C302DF1ABCB9EE00DB3ED1 /* RCTVibration.xcodeproj */,
139FDEE61B06529A00C62182 /* RCTWebSocket.xcodeproj */,
EFA9957A8DD4430C85C9D4D5 /* RNSVG.xcodeproj */,
);
name = Libraries;
sourceTree = "<group>";
@ -384,7 +388,7 @@
83CBB9F71A601CBA00E9B192 /* Project object */ = {
isa = PBXProject;
attributes = {
LastUpgradeCheck = 0810;
LastUpgradeCheck = 810;
ORGANIZATIONNAME = Facebook;
TargetAttributes = {
00E356ED1AD99517003FC87E = {
@ -626,6 +630,10 @@
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mattermost.app/Mattermost";
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"\"$(SRCROOT)/$(TARGET_NAME)\"",
);
};
name = Debug;
};
@ -640,6 +648,10 @@
PRODUCT_BUNDLE_IDENTIFIER = "org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier)";
PRODUCT_NAME = "$(TARGET_NAME)";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Mattermost.app/Mattermost";
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"\"$(SRCROOT)/$(TARGET_NAME)\"",
);
};
name = Release;
};
@ -653,6 +665,7 @@
"$(inherited)",
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include,
"$(SRCROOT)/../node_modules/react-native/React/**",
"$(SRCROOT)/../node_modules/react-native-svg/ios/**",
);
INFOPLIST_FILE = Mattermost/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@ -676,6 +689,7 @@
"$(inherited)",
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include,
"$(SRCROOT)/../node_modules/react-native/React/**",
"$(SRCROOT)/../node_modules/react-native-svg/ios/**",
);
INFOPLIST_FILE = Mattermost/Info.plist;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
@ -732,6 +746,7 @@
"$(inherited)",
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include,
"$(SRCROOT)/../node_modules/react-native/React/**",
"$(SRCROOT)/../node_modules/react-native-svg/ios/**",
);
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
MTL_ENABLE_DEBUG_INFO = YES;
@ -775,6 +790,7 @@
"$(inherited)",
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include,
"$(SRCROOT)/../node_modules/react-native/React/**",
"$(SRCROOT)/../node_modules/react-native-svg/ios/**",
);
IPHONEOS_DEPLOYMENT_TARGET = 8.0;
MTL_ENABLE_DEBUG_INFO = NO;

View file

@ -12,6 +12,7 @@
"react-native-button": "1.7.1",
"react-native-drawer": "2.3.0",
"react-native-keyboard-spacer": "0.3.0",
"react-native-svg": "4.3.3",
"react-native-vector-icons": "2.1.0",
"react-redux": "4.4.5",
"redux": "3.6.0",

View file

@ -274,6 +274,33 @@ export function fetchMyChannelsAndMembers(teamId) {
};
}
export function getMyChannelMembers(teamId) {
return async (dispatch, getState) => {
dispatch({type: ChannelTypes.CHANNEL_MEMBERS_REQUEST}, getState);
let channelMembers;
try {
const channelMembersRequest = Client.getMyChannelMembers(teamId);
channelMembers = await channelMembersRequest;
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: ChannelTypes.CHANNEL_MEMBERS_FAILURE, error}, getState);
return;
}
dispatch(batchActions([
{
type: ChannelTypes.RECEIVED_MY_CHANNEL_MEMBERS,
data: channelMembers
},
{
type: ChannelTypes.CHANNEL_MEMBERS_SUCCESS
}
]), getState);
};
}
export function leaveChannel(teamId, channelId) {
return async (dispatch, getState) => {
dispatch({type: ChannelTypes.LEAVE_CHANNEL_REQUEST}, getState);
@ -379,11 +406,14 @@ export function viewChannel(teamId, channelId, prevChannelId = '') {
return;
}
const {channels} = getState().entities.channels;
const actions = [{
type: ChannelTypes.RECEIVED_LAST_VIEWED,
data: {
channel_id: channelId,
last_viewed_at: new Date().getTime()
last_viewed_at: new Date().getTime(),
total_msg_count: channels[channelId].total_msg_count
}
}];
@ -392,7 +422,8 @@ export function viewChannel(teamId, channelId, prevChannelId = '') {
type: ChannelTypes.RECEIVED_LAST_VIEWED,
data: {
channel_id: prevChannelId,
last_viewed_at: new Date().getTime()
last_viewed_at: new Date().getTime(),
total_msg_count: channels[prevChannelId].total_msg_count
}
});
}
@ -514,6 +545,7 @@ export default {
updateChannelNotifyProps,
getChannel,
fetchMyChannelsAndMembers,
getMyChannelMembers,
leaveChannel,
joinChannel,
deleteChannel,

View file

@ -5,6 +5,31 @@ import Client from 'service/client';
import {batchActions} from 'redux-batched-actions';
import {Constants, TeamsTypes} from 'service/constants';
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
import {getProfilesByIds, getStatusesByIds} from './users';
async function getProfilesAndStatusesForMembers(userIds, dispatch, getState) {
const {profiles, statuses} = getState().entities.users;
const profilesToLoad = [];
const statusesToLoad = [];
userIds.forEach((userId) => {
if (!profiles[userId]) {
profilesToLoad.push(userId);
}
if (!statuses[userId]) {
statusesToLoad.push(userId);
}
});
if (profilesToLoad.length) {
await getProfilesByIds(profilesToLoad)(dispatch, getState);
}
if (statusesToLoad.length) {
await getStatusesByIds(statusesToLoad)(dispatch, getState);
}
}
export function selectTeam(team) {
return async (dispatch, getState) => {
@ -101,6 +126,7 @@ export function getTeamMember(teamId, userId) {
let member;
try {
member = await Client.getTeamMember(teamId, userId);
getProfilesAndStatusesForMembers([userId], dispatch, getState);
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: TeamsTypes.TEAM_MEMBERS_FAILURE, error}, getState);
@ -120,14 +146,28 @@ export function getTeamMember(teamId, userId) {
}
export function getTeamMembersByIds(teamId, userIds) {
return bindClientFunc(
Client.getTeamMemberByIds,
TeamsTypes.TEAM_MEMBERS_REQUEST,
[TeamsTypes.RECEIVED_MEMBERS_IN_TEAM, TeamsTypes.TEAM_MEMBERS_SUCCESS],
TeamsTypes.TEAM_MEMBERS_FAILURE,
teamId,
userIds
);
return async (dispatch, getState) => {
dispatch({type: TeamsTypes.TEAM_MEMBERS_REQUEST}, getState);
let members;
try {
members = await Client.getTeamMemberByIds(teamId, userIds);
getProfilesAndStatusesForMembers(userIds, dispatch, getState);
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: TeamsTypes.TEAM_MEMBERS_FAILURE}, getState);
}
dispatch(batchActions([
{
type: TeamsTypes.RECEIVED_MEMBERS_IN_TEAM,
data: members
},
{
type: TeamsTypes.TEAM_MEMBERS_SUCCESS
}
]), getState);
};
}
export function getTeamStats(teamId) {

View file

@ -4,6 +4,7 @@
import {batchActions} from 'redux-batched-actions';
import Client from 'service/client';
import {Constants, PreferencesTypes, UsersTypes, TeamsTypes} from 'service/constants';
import {fetchTeams} from 'service/actions/teams';
import {bindClientFunc, forceLogoutIfNecessary} from './helpers';
export function login(loginId, password, mfaToken = '') {
@ -20,8 +21,16 @@ export function login(loginId, password, mfaToken = '') {
teamMembers = await teamMembersRequest;
preferences = await preferencesRequest;
} catch (err) {
dispatch({type: UsersTypes.LOGIN_FAILURE, error: err}, getState);
} catch (error) {
dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState);
return;
}
try {
await fetchTeams()(dispatch, getState);
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState);
return;
}
@ -43,13 +52,81 @@ export function login(loginId, password, mfaToken = '') {
}
]), getState);
}).
catch((err) => {
dispatch({type: UsersTypes.LOGIN_FAILURE, error: err}, getState);
catch((error) => {
dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState);
return;
});
};
}
export function loadMe() {
return async (dispatch, getState) => {
dispatch({type: UsersTypes.LOGIN_REQUEST}, getState);
let user;
dispatch({type: UsersTypes.LOGIN_REQUEST}, getState);
try {
user = await Client.getMe();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: UsersTypes.LOGIN_FAILURE, error}, getState);
return;
}
let preferences;
dispatch({type: PreferencesTypes.MY_PREFERENCES_REQUEST}, getState);
try {
preferences = await Client.getMyPreferences();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: PreferencesTypes.MY_PREFERENCES_FAILURE, error}, getState);
return;
}
try {
await fetchTeams()(dispatch, getState);
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: TeamsTypes.FETCH_TEAMS_FAILURE, error}, getState);
return;
}
let teamMembers;
dispatch({type: TeamsTypes.MY_TEAM_MEMBERS_REQUEST}, getState);
try {
teamMembers = await Client.getMyTeamMembers();
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: TeamsTypes.MY_TEAM_MEMBERS_FAILURE, error}, getState);
return;
}
dispatch(batchActions([
{
type: UsersTypes.RECEIVED_ME,
data: user
},
{
type: UsersTypes.LOGIN_SUCCESS
},
{
type: PreferencesTypes.RECEIVED_PREFERENCES,
data: preferences
},
{
type: PreferencesTypes.MY_PREFERENCES_SUCCESS
},
{
type: TeamsTypes.RECEIVED_MY_TEAM_MEMBERS,
data: teamMembers
},
{
type: TeamsTypes.MY_TEAM_MEMBERS_SUCCESS
}
]), getState);
};
}
export function logout() {
return bindClientFunc(
Client.logout,

View file

@ -29,7 +29,7 @@ import {
import {getProfilesByIds, getStatusesByIds} from 'service/actions/users';
export function init(siteUrl, token) {
export function init(siteUrl, token, optionalWebSocket) {
return async (dispatch, getState) => {
dispatch({type: GeneralTypes.WEBSOCKET_REQUEST}, getState);
const config = getState().entities.general.config;
@ -57,7 +57,7 @@ export function init(siteUrl, token) {
websocketClient.setEventCallback(handleEvent);
websocketClient.setReconnectCallback(handleReconnect);
websocketClient.setCloseCallback(handleClose);
return websocketClient.initialize(connUrl, authToken, dispatch, getState);
return websocketClient.initialize(connUrl, authToken, dispatch, getState, optionalWebSocket);
};
}

View file

@ -5,16 +5,7 @@ const MAX_WEBSOCKET_FAILS = 7;
const MIN_WEBSOCKET_RETRY_TIME = 3000; // 3 sec
const MAX_WEBSOCKET_RETRY_TIME = 300000; // 5 mins
/* eslint-disable global-require, no-process-env */
let Socket;
if (process.env.NODE_ENV === 'test') {
Socket = require('ws');
} else {
Socket = WebSocket;
}
/* eslint-enable global-require, no-process-env */
class WebSocketClient {
constructor() {
@ -31,7 +22,7 @@ class WebSocketClient {
this.getState = null;
}
initialize(connectionUrl = this.connectionUrl, token, dispatch, getState) {
initialize(connectionUrl = this.connectionUrl, token, dispatch, getState, webSocketConnector = WebSocket) {
return new Promise((resolve, reject) => {
if (this.conn) {
resolve();
@ -54,6 +45,7 @@ class WebSocketClient {
console.log('websocket connecting to ' + connectionUrl); //eslint-disable-line no-console
}
Socket = webSocketConnector;
this.conn = new Socket(connectionUrl);
this.connectionUrl = connectionUrl;
this.dispatch = dispatch;

View file

@ -18,11 +18,17 @@ const Constants = {
DEFAULT_CHANNEL: 'town-square',
DM_CHANNEL: 'D',
OPEN_CHANNEL: 'O',
PRIVATE_CHANNEL: 'P',
POST_DELETED: 'DELETED',
SYSTEM_MESSAGE_PREFIX: 'system_',
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show'
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show',
CATEGORY_DISPLAY_SETTINGS: 'display_settings',
CATEGORY_FAVORITE_CHANNEL: 'favorite_channel',
DISPLAY_PREFER_NICKNAME: 'nickname_full_name',
DISPLAY_PREFER_FULL_NAME: 'full_name'
};
export default Constants;

View file

@ -1628,14 +1628,15 @@
"setting_upload.import": "Import",
"setting_upload.noFile": "No file selected.",
"setting_upload.select": "Select file",
"sidebar.channels": "Channels",
"sidebar.channels": "CHANNELS",
"sidebar.createChannel": "Create new channel",
"sidebar.createGroup": "Create new group",
"sidebar.direct": "Direct Messages",
"sidebar.direct": "DIRECT MESSAGES",
"sidebar.favorites": "FAVORITES",
"sidebar.more": "More",
"sidebar.moreElips": "More...",
"sidebar.otherMembers": "Outside this team",
"sidebar.pg": "Private Groups",
"sidebar.pg": "PRIVATE GROUPS",
"sidebar.removeList": "Remove from list",
"sidebar.tutorialScreen1": "<h4>Channels</h4><p><strong>Channels</strong> organize conversations across different topics. Theyre open to everyone on your team. To send private communications use <strong>Direct Messages</strong> for a single person or <strong>Private Groups</strong> for multiple people.</p>",
"sidebar.tutorialScreen2": "<h4>\"{townsquare}\" and \"{offtopic}\" channels</h4><p>Here are two public channels to start:</p><p><strong>{townsquare}</strong> is a place for team-wide communication. Everyone in your team is a member of this channel.</p><p><strong>{offtopic}</strong> is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.</p>",

View file

@ -1628,14 +1628,15 @@
"setting_upload.import": "Importar",
"setting_upload.noFile": "No ha seleccionado un archivo",
"setting_upload.select": "Selecciona un archivo",
"sidebar.channels": "Canales",
"sidebar.channels": "CANALES",
"sidebar.createChannel": "Crear un nuevo canal",
"sidebar.createGroup": "Crear un nuevo grupo",
"sidebar.direct": "Mensajes Directos",
"sidebar.direct": "MENSAJES DIRECTOS",
"sidebar.favorites": "FAVORITOS",
"sidebar.more": "Más",
"sidebar.moreElips": "Más...",
"sidebar.otherMembers": "Fuera de este equipo",
"sidebar.pg": "Grupos Privados",
"sidebar.pg": "GRUPOS PRIVADOS",
"sidebar.removeList": "Remover de la lista",
"sidebar.tutorialScreen1": "<h4>Canales</h4><p><strong>Canales</strong> organizan las conversaciones en diferentes tópicos. Son abiertos para cualquier persona de tu equipo. Para enviar comunicaciones privadas con una sola persona utiliza <strong>Mensajes Directos</strong> o con multiples personas utilizando <strong>Grupos Privados</strong>.</p>",
"sidebar.tutorialScreen2": "<h4>Los canal \"{townsquare}\" y \"{offtopic}\"</h4><p>Estos son dos canales para comenzar:</p><p><strong>{townsquare}</strong> es el lugar para tener comunicación con todo el equipo. Todos los integrantes de tu equipo son miembros de este canal.</p><p><strong>{offtopic}</strong> es un lugar para diversión y humor fuera de los canales relacionados con el trabajo. Tu y tu equipo pueden decidir que otros canales crear.</p>",

View file

@ -35,6 +35,17 @@ function channels(state = {}, action) {
case ChannelTypes.RECEIVED_CHANNEL_DELETED:
Reflect.deleteProperty(nextState, action.data);
return nextState;
case ChannelTypes.RECEIVED_LAST_VIEWED: {
const channelId = action.data.channel_id;
const lastUpdatedAt = action.data.last_viewed_at;
return {
...state,
[channelId]: {
...state[channelId],
extra_update_at: lastUpdatedAt
}
};
}
case UsersTypes.LOGOUT_SUCCESS:
return {};
@ -72,8 +83,8 @@ function myMembers(state = {}, action) {
case ChannelTypes.RECEIVED_LAST_VIEWED: {
const member = {...state[action.data.channel_id]};
member.last_viewed_at = action.data.last_viewed_at;
member.msg_count = action.data.total_msg_count;
member.mention_count = 0;
member.msg_count = 0;
return {
...state,

View file

@ -188,13 +188,13 @@ export default combineReducers({
// object where every key is a user id and has an object with the users details
profiles,
// object where every key is a user id and has a Set with the users id that are members of the team
// object where every key is a team id and has a Set with the users id that are members of the team
profilesInTeam,
// object where every key is a user id and has a Set with the users id that are members of the channel
// object where every key is a channel id and has a Set with the users id that are members of the channel
profilesInChannel,
// object where every key is a user id and has a Set with the users id that are members of the channel
// object where every key is a channel id and has a Set with the users id that are members of the channel
profilesNotInChannel,
// object where every key is the user id and has a value with the current status of each user

View file

@ -2,8 +2,8 @@
// See License.txt for license information.
import {createSelector} from 'reselect';
import {getCurrentTeamId} from 'service/selectors/entities/teams';
import {buildDisplayableChannelList} from 'service/utils/channel_utils';
function getAllChannels(state) {
return state.entities.channels.channels;
@ -28,7 +28,7 @@ export const getChannelsOnCurrentTeam = createSelector(
const channels = [];
for (const channel of Object.values(allChannels)) {
if (channel.team_id === currentTeamId) {
if (channel.team_id === currentTeamId || channel.team_id === '') {
channels.push(channel);
}
}
@ -36,3 +36,13 @@ export const getChannelsOnCurrentTeam = createSelector(
return channels;
}
);
export const getChannelsByCategory = createSelector(
getChannelsOnCurrentTeam,
(state) => state.entities.users,
(state) => state.entities.preferences.myPreferences,
(state) => state.entities.teams,
(channels, usersState, myPreferences, teamsState) => {
return buildDisplayableChannelList(usersState, teamsState, channels, myPreferences);
}
);

View file

@ -0,0 +1,170 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Constants} from 'service/constants';
import {displayUsername} from './user_utils';
import {getPreferencesByCategory} from './preference_utils';
const defaultPrefix = 'D'; // fallback for future types
const typeToPrefixMap = {[Constants.OPEN_CHANNEL]: 'A', [Constants.PRIVATE_CHANNEL]: 'B', [Constants.DM_CHANNEL]: 'C'};
export function buildDisplayableChannelList(usersState, teamsState, allChannels, myPreferences) {
const missingDMChannels = createMissingDirectChannels(usersState.currentId, allChannels, myPreferences);
const channels = allChannels.
concat(missingDMChannels).
map(completeDirectChannelInfo.bind(null, usersState, myPreferences));
channels.sort((a, b) => {
const locale = usersState.profiles[usersState.currentId].locale;
return buildDisplayNameAndTypeComparable(a).
localeCompare(buildDisplayNameAndTypeComparable(b), locale, {numeric: true});
});
const favoriteChannels = channels.filter(isFavoriteChannel.bind(null, myPreferences));
const notFavoriteChannels = channels.filter(not(isFavoriteChannel.bind(null, myPreferences)));
const directChannels = notFavoriteChannels.
filter(
andX(
isDirectChannel,
isDirectChannelVisible.bind(null, usersState.currentId, myPreferences)
)
);
return {
favoriteChannels,
publicChannels: notFavoriteChannels.filter(isOpenChannel),
privateChannels: notFavoriteChannels.filter(isPrivateChannel),
directChannels: directChannels.filter(
isConnectedToTeamMember.bind(null, teamsState.membersInTeam[teamsState.currentId])
),
directNonTeamChannels: directChannels.filter(
isNotConnectedToTeamMember.bind(null, teamsState.membersInTeam[teamsState.currentId])
)
};
}
export function getDirectChannelName(id, otherId) {
let handle;
if (otherId > id) {
handle = id + '__' + otherId;
} else {
handle = otherId + '__' + id;
}
return handle;
}
export function getChannelByName(channels, name) {
const channelIds = Object.keys(channels);
for (let i = 0; i < channelIds.length; i++) {
const id = channelIds[i];
if (channels[id].name === name) {
return channels[id];
}
}
return null;
}
function isOpenChannel(channel) {
return channel.type === Constants.OPEN_CHANNEL;
}
function isPrivateChannel(channel) {
return channel.type === Constants.PRIVATE_CHANNEL;
}
function isConnectedToTeamMember(members, channel) {
return members && members.has(channel.teammate_id);
}
function isNotConnectedToTeamMember(members, channel) {
if (!members) {
return true;
}
return !members.has(channel.teammate_id);
}
function isDirectChannel(channel) {
return channel.type === Constants.DM_CHANNEL;
}
function isDirectChannelVisible(userId, myPreferences, channel) {
const channelId = getUserIdFromChannelName(userId, channel);
const dm = myPreferences[`${Constants.CATEGORY_DIRECT_CHANNEL_SHOW}--${channelId}`];
return dm && dm.value === 'true';
}
function isFavoriteChannel(myPreferences, channel) {
const fav = myPreferences[`${Constants.CATEGORY_FAVORITE_CHANNEL}--${channel.id}`];
return fav && fav.value === 'true';
}
function createMissingDirectChannels(currentUserId, allChannels, myPreferences) {
const preferences = getPreferencesByCategory(myPreferences, Constants.CATEGORY_DIRECT_CHANNEL_SHOW);
return Array.
from(preferences).
filter((entry) => entry[1] === 'true').
map((entry) => entry[0]).
filter((teammateId) => !allChannels.some(isDirectChannelForUser.bind(null, currentUserId, teammateId))).
map(createFakeChannelCurried(currentUserId));
}
function isDirectChannelForUser(userId, otherUserId, channel) {
return channel.type === Constants.DM_CHANNEL && getUserIdFromChannelName(userId, channel) === otherUserId;
}
export function getUserIdFromChannelName(userId, channel) {
const ids = channel.name.split('__');
let otherUserId = '';
if (ids[0] === userId) {
otherUserId = ids[1];
} else {
otherUserId = ids[0];
}
return otherUserId;
}
function createFakeChannel(userId, otherUserId) {
return {
name: getDirectChannelName(userId, otherUserId),
last_post_at: 0,
total_msg_count: 0,
type: Constants.DM_CHANNEL,
fake: true
};
}
function createFakeChannelCurried(userId) {
return (otherUserId) => createFakeChannel(userId, otherUserId);
}
function completeDirectChannelInfo(usersState, myPreferences, channel) {
if (!isDirectChannel(channel)) {
return channel;
}
const dmChannelClone = JSON.parse(JSON.stringify(channel));
const teammateId = getUserIdFromChannelName(usersState.currentId, channel);
return Object.assign(dmChannelClone, {
display_name: displayUsername(usersState.profiles[teammateId], myPreferences),
teammate_id: teammateId,
status: usersState.statuses[teammateId] || 'offline'
});
}
function buildDisplayNameAndTypeComparable(channel) {
return (typeToPrefixMap[channel.type] || defaultPrefix) + channel.display_name + channel.name;
}
function not(f) {
return (...args) => !f(...args);
}
function andX(...fns) {
return (...args) => fns.every((f) => f(...args));
}

View file

@ -0,0 +1,14 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
export function getPreferencesByCategory(myPreferences, category) {
const prefix = `${category}--`;
const preferences = new Map();
Object.keys(myPreferences).forEach((key) => {
if (key.startsWith(prefix)) {
preferences.set(key.substring(prefix.length), myPreferences[key]);
}
});
return preferences;
}

View file

@ -0,0 +1,38 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Constants} from 'service/constants';
export function getFullName(user) {
if (user.first_name && user.last_name) {
return user.first_name + ' ' + user.last_name;
} else if (user.first_name) {
return user.first_name;
} else if (user.last_name) {
return user.last_name;
}
return '';
}
export function displayUsername(user, myPreferences) {
let nameFormat = 'false';
const pref = myPreferences[`${Constants.CATEGORY_DISPLAY_SETTINGS}--name_format`];
if (pref && pref.value) {
nameFormat = pref.value;
}
let username = '';
if (user) {
if (nameFormat === Constants.DISPLAY_PREFER_NICKNAME) {
username = user.nickname || getFullName(user);
} else if (nameFormat === Constants.DISPLAY_PREFER_FULL_NAME) {
username = getFullName(user);
}
if (!username.trim().length) {
username = user.username;
}
}
return username;
}

View file

@ -3,6 +3,8 @@
import 'react-native';
global.WebSocket = require('ws');
// Set up a global hooks to make debugging tests less of a pain
before(() => {
process.on('unhandledRejection', (reason) => {

View file

@ -45,7 +45,8 @@ describe('Actions.Channels', () => {
};
await Actions.createChannel(channel, TestHelper.basicUser.id)(store.dispatch, store.getState);
const {createChannel: createRequest, myMembers: membersRequest} = store.getState().requests.channels;
const createRequest = store.getState().requests.channels.createChannel;
const membersRequest = store.getState().requests.channels.myMembers;
if (createRequest.status === RequestStatus.FAILURE) {
throw new Error(JSON.stringify(createRequest.error));
} else if (membersRequest.status === RequestStatus.FAILURE) {
@ -81,19 +82,19 @@ describe('Actions.Channels', () => {
}
const state = store.getState();
const {channels, myMembers: members} = state.entities.channels;
const {channels, myMembers} = state.entities.channels;
const profiles = state.entities.users.profiles;
const preferences = state.entities.preferences.myPreferences;
const channelsCount = Object.keys(channels).length;
const membersCount = Object.keys(members).length;
const membersCount = Object.keys(myMembers).length;
assert.ok(channels, 'channels is empty');
assert.ok(members, 'members is empty');
assert.ok(myMembers, 'members is empty');
assert.ok(profiles[user.id], 'profiles does not have userId');
assert.ok(Object.keys(preferences).length, 'preferences is empty');
assert.ok(channels[Object.keys(members)[0]], 'channels should have the member');
assert.ok(members[Object.keys(channels)[0]], 'members should belong to channel');
assert.equal(members[Object.keys(channels)[0]].user_id, TestHelper.basicUser.id);
assert.ok(channels[Object.keys(myMembers)[0]], 'channels should have the member');
assert.ok(myMembers[Object.keys(channels)[0]], 'members should belong to channel');
assert.equal(myMembers[Object.keys(channels)[0]].user_id, TestHelper.basicUser.id);
assert.equal(channelsCount, membersCount);
assert.equal(channels[Object.keys(channels)[0]].type, 'D');
assert.equal(channelsCount, 1);
@ -137,7 +138,8 @@ describe('Actions.Channels', () => {
it('fetchMyChannelsAndMembers', async () => {
await Actions.fetchMyChannelsAndMembers(TestHelper.basicTeam.id)(store.dispatch, store.getState);
const {getChannels: channelsRequest, myMembers: membersRequest} = store.getState().requests.channels;
const channelsRequest = store.getState().requests.channels.getChannels;
const membersRequest = store.getState().requests.channels.myMembers;
if (channelsRequest.status === RequestStatus.FAILURE) {
throw new Error(JSON.stringify(channelsRequest.error));
} else if (membersRequest.status === RequestStatus.FAILURE) {

View file

@ -1,5 +1,6 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import assert from 'assert';
import * as Actions from 'service/actions/websocket';
import * as ChannelActions from 'service/actions/channels';
@ -15,7 +16,12 @@ describe('Actions.Websocket', () => {
before(async () => {
store = configureStore();
await TestHelper.initBasic(Client);
return await Actions.init()(store.dispatch, store.getState);
const webSocketConnector = require('ws');
return await Actions.init(
null,
null,
webSocketConnector
)(store.dispatch, store.getState);
});
after(async () => {