From 81526fbb0918a965b2ceab32204f1d68ff1bc744 Mon Sep 17 00:00:00 2001 From: enahum Date: Tue, 3 Jan 2017 19:49:28 -0300 Subject: [PATCH] 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 --- NOTICE.txt | 33 ++ android/app/build.gradle | 1 + .../java/com/mattermost/MainApplication.java | 4 +- android/settings.gradle | 2 + app/actions/views/channel.js | 60 +++- app/actions/views/drawer.js | 16 + app/actions/views/root.js | 75 +--- app/components/channel_drawer/badge.js | 93 +++++ .../channel_drawer/channel_drawer.js | 102 ++++++ .../channel_drawer_container.js | 33 ++ app/components/channel_drawer/channel_item.js | 192 +++++++++++ app/components/channel_drawer/channel_list.js | 321 ++++++++++++++++++ app/components/channel_drawer/index.js | 6 + .../channel_drawer/unread_indicator.js | 43 +++ app/components/channel_sidebar.js | 68 ---- app/components/line_divider.js | 83 +++++ app/components/status_icons/away.js | 45 +++ app/components/status_icons/index.js | 12 + app/components/status_icons/offline.js | 49 +++ app/components/status_icons/online.js | 53 +++ app/constants/view.js | 4 +- app/navigation/router.js | 4 +- app/reducers/views/drawer.js | 20 ++ app/reducers/views/index.js | 4 +- app/scenes/channel/channel.js | 78 ++--- app/scenes/channel/channel_container.js | 30 +- app/scenes/select_team/select_team.js | 19 +- .../select_team/select_team_container.js | 8 +- ios/Mattermost.xcodeproj/project.pbxproj | 20 +- package.json | 1 + service/actions/channels.js | 36 +- service/actions/teams.js | 56 ++- service/actions/users.js | 85 ++++- service/actions/websocket.js | 4 +- service/client/websocket_client.js | 12 +- service/constants/constants.js | 8 +- service/i18n/en.json | 7 +- service/i18n/es.json | 7 +- service/reducers/entities/channels.js | 13 +- service/reducers/entities/users.js | 6 +- service/selectors/entities/channels.js | 14 +- service/utils/channel_utils.js | 170 ++++++++++ service/utils/preference_utils.js | 14 + service/utils/user_utils.js | 38 +++ test/sanity.test.js | 2 + test/service/actions/channels.test.js | 18 +- test/service/actions/websocket.test.js | 8 +- 47 files changed, 1724 insertions(+), 253 deletions(-) create mode 100644 app/actions/views/drawer.js create mode 100644 app/components/channel_drawer/badge.js create mode 100644 app/components/channel_drawer/channel_drawer.js create mode 100644 app/components/channel_drawer/channel_drawer_container.js create mode 100644 app/components/channel_drawer/channel_item.js create mode 100644 app/components/channel_drawer/channel_list.js create mode 100644 app/components/channel_drawer/index.js create mode 100644 app/components/channel_drawer/unread_indicator.js delete mode 100644 app/components/channel_sidebar.js create mode 100644 app/components/line_divider.js create mode 100644 app/components/status_icons/away.js create mode 100644 app/components/status_icons/index.js create mode 100644 app/components/status_icons/offline.js create mode 100644 app/components/status_icons/online.js create mode 100644 app/reducers/views/drawer.js create mode 100644 service/utils/channel_utils.js create mode 100644 service/utils/preference_utils.js create mode 100644 service/utils/user_utils.js diff --git a/NOTICE.txt b/NOTICE.txt index 2a9be1901..9eef79035 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -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. + +--- diff --git a/android/app/build.gradle b/android/app/build.gradle index 42d2dcb76..a4ac2aad8 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 diff --git a/android/app/src/main/java/com/mattermost/MainApplication.java b/android/app/src/main/java/com/mattermost/MainApplication.java index 63522e2dd..de4835f51 100644 --- a/android/app/src/main/java/com/mattermost/MainApplication.java +++ b/android/app/src/main/java/com/mattermost/MainApplication.java @@ -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 getPackages() { return Arrays.asList( - new MainReactPackage() + new MainReactPackage(), + new RNSvgPackage() ); } }; diff --git a/android/settings.gradle b/android/settings.gradle index 1bae8dd12..e9d118a2d 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -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') diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js index dabd43188..544a2863c 100644 --- a/app/actions/views/channel.js +++ b/app/actions/views/channel.js @@ -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); } }; } diff --git a/app/actions/views/drawer.js b/app/actions/views/drawer.js new file mode 100644 index 000000000..7f7f8f336 --- /dev/null +++ b/app/actions/views/drawer.js @@ -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); + }; +} diff --git a/app/actions/views/root.js b/app/actions/views/root.js index c80376465..df34bd5e4 100644 --- a/app/actions/views/root.js +++ b/app/actions/views/root.js @@ -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); }; } diff --git a/app/components/channel_drawer/badge.js b/app/components/channel_drawer/badge.js new file mode 100644 index 000000000..49ca593e8 --- /dev/null +++ b/app/components/channel_drawer/badge.js @@ -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 ( + + {this.props.count} + + ); + }; + + 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 ( + { + this.container = component; + }} + style={[styles.container, this.props.style]} + > + {this.renderText()} + + ); + } +} diff --git a/app/components/channel_drawer/channel_drawer.js b/app/components/channel_drawer/channel_drawer.js new file mode 100644 index 000000000..69da52264 --- /dev/null +++ b/app/components/channel_drawer/channel_drawer.js @@ -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 ( + { + 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={ + + } + > + {this.props.children} + + ); + } +} diff --git a/app/components/channel_drawer/channel_drawer_container.js b/app/components/channel_drawer/channel_drawer_container.js new file mode 100644 index 000000000..0893da2c5 --- /dev/null +++ b/app/components/channel_drawer/channel_drawer_container.js @@ -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); diff --git a/app/components/channel_drawer/channel_item.js b/app/components/channel_drawer/channel_item.js new file mode 100644 index 000000000..5ef8b0596 --- /dev/null +++ b/app/components/channel_drawer/channel_item.js @@ -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 = ( + + ); + } + + 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 = ( + + ); + } + + if (channel.type === Constants.OPEN_CHANNEL) { + icon = ( + + ); + } else if (channel.type === Constants.PRIVATE_CHANNEL) { + icon = ( + + ); + } else { + switch (channel.status) { + case Constants.ONLINE: + icon = ( + + ); + break; + case Constants.AWAY: + icon = ( + + ); + break; + default: + icon = ( + + ); + break; + } + } + + return ( + this.props.onSelectChannel(channel)} + > + + {activeBorder} + + {icon} + + {channel.display_name} + + {badge} + + + + ); + } +} diff --git a/app/components/channel_drawer/channel_list.js b/app/components/channel_drawer/channel_list.js new file mode 100644 index 000000000..3ed818d4b --- /dev/null +++ b/app/components/channel_drawer/channel_list.js @@ -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 ( + + ); + }; + + 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( + , + ...favoriteChannels + ); + } + + data.push( + , + ...publicChannels + ); + data.push( + , + ...privateChannels + ); + data.push( + , + ...directChannels + ); + + if (directNonTeamChannels.length) { + data.push( + , + ...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 {'Loading'}; + } + + const { + theme + } = this.props; + + let above; + let below; + if (this.state.showAbove) { + above = ( + + )} + /> + ); + } + if (this.state.showBelow) { + below = ( + + )} + /> + ); + } + + return ( + + + + {this.props.currentTeam.display_name} + + + + {above} + {below} + + ); + } +} diff --git a/app/components/channel_drawer/index.js b/app/components/channel_drawer/index.js new file mode 100644 index 000000000..859043ad5 --- /dev/null +++ b/app/components/channel_drawer/index.js @@ -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; diff --git a/app/components/channel_drawer/unread_indicator.js b/app/components/channel_drawer/unread_indicator.js new file mode 100644 index 000000000..c8e56f918 --- /dev/null +++ b/app/components/channel_drawer/unread_indicator.js @@ -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 ( + + {this.props.text} + + ); + } +} diff --git a/app/components/channel_sidebar.js b/app/components/channel_sidebar.js deleted file mode 100644 index 99c7f4c7c..000000000 --- a/app/components/channel_sidebar.js +++ /dev/null @@ -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 ( - this.onSelectChannel(channel)} - > - {channel.name} - - ); - }); - - return ( - - {this.props.currentTeam.name} - {channels} - - ); - } -} - -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); diff --git a/app/components/line_divider.js b/app/components/line_divider.js new file mode 100644 index 000000000..f1ac19baa --- /dev/null +++ b/app/components/line_divider.js @@ -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 ( + + ); + } + + render() { + let renderText; + if (this.props.translationId && this.props.translationText) { + renderText = ( + + + + ); + } + + let content = ( + + {this.renderLine()} + {renderText} + + ); + if (this.props.side === 'left') { + content = ( + + {renderText} + {this.renderLine()} + + ); + } else if (this.props.side === 'center') { + content = ( + + {this.renderLine()} + {renderText} + {this.renderLine()} + + ); + } + + return content; + } +} diff --git a/app/components/status_icons/away.js b/app/components/status_icons/away.js new file mode 100644 index 000000000..1f2a84d73 --- /dev/null +++ b/app/components/status_icons/away.js @@ -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 ( + + + + + + + + ); + } +} diff --git a/app/components/status_icons/index.js b/app/components/status_icons/index.js new file mode 100644 index 000000000..1197ac31c --- /dev/null +++ b/app/components/status_icons/index.js @@ -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 +}; diff --git a/app/components/status_icons/offline.js b/app/components/status_icons/offline.js new file mode 100644 index 000000000..24c34702e --- /dev/null +++ b/app/components/status_icons/offline.js @@ -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 ( + + + + + + + + + + + + ); + } +} diff --git a/app/components/status_icons/online.js b/app/components/status_icons/online.js new file mode 100644 index 000000000..764541dc6 --- /dev/null +++ b/app/components/status_icons/online.js @@ -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 ( + + + + + + + + + + + + + ); + } +} diff --git a/app/constants/view.js b/app/constants/view.js index bf1941991..c2508f939 100644 --- a/app/constants/view.js +++ b/app/constants/view.js @@ -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; diff --git a/app/navigation/router.js b/app/navigation/router.js index 3c692c66e..52f89029f 100644 --- a/app/navigation/router.js +++ b/app/navigation/router.js @@ -43,10 +43,8 @@ class Router extends React.Component { diff --git a/app/reducers/views/drawer.js b/app/reducers/views/drawer.js new file mode 100644 index 000000000..c4f1e1c1b --- /dev/null +++ b/app/reducers/views/drawer.js @@ -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 +}); + diff --git a/app/reducers/views/index.js b/app/reducers/views/index.js index 4ad73548a..cd4369b4f 100644 --- a/app/reducers/views/index.js +++ b/app/reducers/views/index.js @@ -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 }); diff --git a/app/scenes/channel/channel.js b/app/scenes/channel/channel.js index 522087131..36ca4666e 100644 --- a/app/scenes/channel/channel.js +++ b/app/scenes/channel/channel.js @@ -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 ( - - } - side='left' - tapToClose={true} - onCloseStart={this.closeLeftSidebar} - openDrawerOffset={0.2} + - - - {'<'} - + + + + + + + + + {currentChannel.display_name} + + - + ); } diff --git a/app/scenes/channel/channel_container.js b/app/scenes/channel/channel_container.js index 70856aee1..65ba6bbb1 100644 --- a/app/scenes/channel/channel_container.js +++ b/app/scenes/channel/channel_container.js @@ -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) }; } diff --git a/app/scenes/select_team/select_team.js b/app/scenes/select_team/select_team.js index 540bfe33d..6c6d1a835 100644 --- a/app/scenes/select_team/select_team.js +++ b/app/scenes/select_team/select_team.js @@ -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); } } diff --git a/app/scenes/select_team/select_team_container.js b/app/scenes/select_team/select_team_container.js index 91d7ad95f..b08f38039 100644 --- a/app/scenes/select_team/select_team_container.js +++ b/app/scenes/select_team/select_team_container.js @@ -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) }; } diff --git a/ios/Mattermost.xcodeproj/project.pbxproj b/ios/Mattermost.xcodeproj/project.pbxproj index f7d50afc1..8c7b185cf 100644 --- a/ios/Mattermost.xcodeproj/project.pbxproj +++ b/ios/Mattermost.xcodeproj/project.pbxproj @@ -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 = ""; }; 78C398B01ACF4ADC00677621 /* RCTLinking.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTLinking.xcodeproj; path = "../node_modules/react-native/Libraries/LinkingIOS/RCTLinking.xcodeproj"; sourceTree = ""; }; 832341B01AAA6A8300B99B32 /* RCTText.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = RCTText.xcodeproj; path = "../node_modules/react-native/Libraries/Text/RCTText.xcodeproj"; sourceTree = ""; }; + EFA9957A8DD4430C85C9D4D5 /* RNSVG.xcodeproj */ = {isa = PBXFileReference; name = "RNSVG.xcodeproj"; path = "../node_modules/react-native-svg/ios/RNSVG.xcodeproj"; sourceTree = ""; fileEncoding = undefined; lastKnownFileType = wrapper.pb-project; explicitFileType = undefined; includeInIndex = 0; }; + 054B6299E0894F978806187D /* libRNSVG.a */ = {isa = PBXFileReference; name = "libRNSVG.a"; path = "libRNSVG.a"; sourceTree = ""; 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 = ""; @@ -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; diff --git a/package.json b/package.json index d3ea24189..43166e1c0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/service/actions/channels.js b/service/actions/channels.js index 2c03b1fd7..33d353df4 100644 --- a/service/actions/channels.js +++ b/service/actions/channels.js @@ -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, diff --git a/service/actions/teams.js b/service/actions/teams.js index d41255fcb..8b7ae28e4 100644 --- a/service/actions/teams.js +++ b/service/actions/teams.js @@ -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) { diff --git a/service/actions/users.js b/service/actions/users.js index 8fb85392c..04e1035fe 100644 --- a/service/actions/users.js +++ b/service/actions/users.js @@ -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, diff --git a/service/actions/websocket.js b/service/actions/websocket.js index 58e5f8ec3..22c6995ab 100644 --- a/service/actions/websocket.js +++ b/service/actions/websocket.js @@ -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); }; } diff --git a/service/client/websocket_client.js b/service/client/websocket_client.js index aacd682f8..ddde05bc2 100644 --- a/service/client/websocket_client.js +++ b/service/client/websocket_client.js @@ -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; diff --git a/service/constants/constants.js b/service/constants/constants.js index 8f7a0bbea..3570d8df5 100644 --- a/service/constants/constants.js +++ b/service/constants/constants.js @@ -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; diff --git a/service/i18n/en.json b/service/i18n/en.json index 981778105..58293fd93 100644 --- a/service/i18n/en.json +++ b/service/i18n/en.json @@ -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": "

Channels

Channels organize conversations across different topics. They’re open to everyone on your team. To send private communications use Direct Messages for a single person or Private Groups for multiple people.

", "sidebar.tutorialScreen2": "

\"{townsquare}\" and \"{offtopic}\" channels

Here are two public channels to start:

{townsquare} is a place for team-wide communication. Everyone in your team is a member of this channel.

{offtopic} is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.

", diff --git a/service/i18n/es.json b/service/i18n/es.json index c75bd89b0..2c4e08530 100644 --- a/service/i18n/es.json +++ b/service/i18n/es.json @@ -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": "

Canales

Canales organizan las conversaciones en diferentes tópicos. Son abiertos para cualquier persona de tu equipo. Para enviar comunicaciones privadas con una sola persona utiliza Mensajes Directos o con multiples personas utilizando Grupos Privados.

", "sidebar.tutorialScreen2": "

Los canal \"{townsquare}\" y \"{offtopic}\"

Estos son dos canales para comenzar:

{townsquare} es el lugar para tener comunicación con todo el equipo. Todos los integrantes de tu equipo son miembros de este canal.

{offtopic} 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.

", diff --git a/service/reducers/entities/channels.js b/service/reducers/entities/channels.js index f656d1ea8..ea02b9fd8 100644 --- a/service/reducers/entities/channels.js +++ b/service/reducers/entities/channels.js @@ -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, diff --git a/service/reducers/entities/users.js b/service/reducers/entities/users.js index a688e56fc..416292f63 100644 --- a/service/reducers/entities/users.js +++ b/service/reducers/entities/users.js @@ -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 diff --git a/service/selectors/entities/channels.js b/service/selectors/entities/channels.js index 02129e6d8..277e39706 100644 --- a/service/selectors/entities/channels.js +++ b/service/selectors/entities/channels.js @@ -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); + } +); diff --git a/service/utils/channel_utils.js b/service/utils/channel_utils.js new file mode 100644 index 000000000..c68c39e2a --- /dev/null +++ b/service/utils/channel_utils.js @@ -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)); +} diff --git a/service/utils/preference_utils.js b/service/utils/preference_utils.js new file mode 100644 index 000000000..8669cc96e --- /dev/null +++ b/service/utils/preference_utils.js @@ -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; +} diff --git a/service/utils/user_utils.js b/service/utils/user_utils.js new file mode 100644 index 000000000..cf9abd2e9 --- /dev/null +++ b/service/utils/user_utils.js @@ -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; +} diff --git a/test/sanity.test.js b/test/sanity.test.js index 40d1d8c0b..b378bed4e 100644 --- a/test/sanity.test.js +++ b/test/sanity.test.js @@ -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) => { diff --git a/test/service/actions/channels.test.js b/test/service/actions/channels.test.js index 866ad4d55..793b12e76 100644 --- a/test/service/actions/channels.test.js +++ b/test/service/actions/channels.test.js @@ -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) { diff --git a/test/service/actions/websocket.test.js b/test/service/actions/websocket.test.js index 4558fb362..9c7979114 100644 --- a/test/service/actions/websocket.test.js +++ b/test/service/actions/websocket.test.js @@ -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 () => {