Render posts for the current channel (#153)

* Added selectChannel action

* Fixed selected team not being cleared on logout

* Fixed posts and postsByChannel not being initialized correctly

* Added basic post rendering

* Renamed selectors files to be plural

* Removed empty file

* Fixed unit tests after merge

* Removing unnecessary code
This commit is contained in:
Harrison Healey 2016-12-23 08:55:33 -05:00 committed by enahum
parent 8243eda444
commit 4ecd3758e7
28 changed files with 460 additions and 74 deletions

View file

@ -0,0 +1,49 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {fetchMyChannelsAndMembers, selectChannel} from 'service/actions/channels';
import {getPosts} from 'service/actions/posts';
import {Constants} from 'service/constants';
export function loadChannelsIfNecessary(teamId) {
return async (dispatch, getState) => {
const channels = getState().entities.channels.channels;
let hasChannelsForTeam = false;
for (const channel of Object.values(channels)) {
if (channel.team_id === teamId) {
// If we have one channel, assume we have all of them
hasChannelsForTeam = true;
break;
}
}
if (!hasChannelsForTeam) {
await fetchMyChannelsAndMembers(teamId)(dispatch, 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);
}
};
}
export function selectInitialChannel(teamId) {
return async (dispatch, getState) => {
const channels = getState().entities.channels.channels;
for (const channel of Object.values(channels)) {
// TODO figure out how to handle when we can't find the town square
if (channel.team_id === teamId && channel.name === Constants.DEFAULT_CHANNEL) {
await selectChannel(channel.id)(dispatch, getState);
break;
}
}
};
}

View file

@ -5,17 +5,19 @@ import React from 'react';
import {Text, View} from 'react-native';
export default class ChannelSidebar extends React.Component {
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: React.PropTypes.func
channels: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
};
onSelectChannel = (channel) => {
console.log('clicked channel ' + channel.name); // eslint-disable-line no-console
// this.props.onSelectChannel(channel);
this.props.actions.selectChannel(channel.id);
}
render() {
@ -45,3 +47,22 @@ export default class ChannelSidebar extends React.Component {
);
}
}
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,37 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import {injectIntl, intlShape} from 'react-intl';
import {Text} from 'react-native';
class FormattedTime extends React.Component {
static propTypes = {
intl: intlShape.isRequired,
value: React.PropTypes.any.isRequired,
format: React.PropTypes.string,
children: React.PropTypes.func
};
render() {
const {
intl,
value,
children,
...props
} = this.props;
Reflect.deleteProperty(props, 'format');
const formattedTime = intl.formatTime(value, this.props);
if (typeof children === 'function') {
return children(formattedTime);
}
return <Text>{formattedTime}</Text>;
}
}
export default injectIntl(FormattedTime);

View file

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

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 {Text, View} from 'react-native';
import FormattedText from 'app/components/formatted_text';
import FormattedTime from 'app/components/formatted_time';
import {isSystemMessage} from 'service/utils/post_utils.js';
export default class Post extends React.Component {
static propTypes = {
post: React.PropTypes.object.isRequired,
user: React.PropTypes.object,
theme: React.PropTypes.object.isRequired
};
render() {
let displayName;
if (isSystemMessage(this.props.post)) {
displayName = 'System'; // TODO this should be localized
} else if (this.props.user) {
displayName = this.props.user.username;
} else {
displayName = (
<FormattedText
id='channel_loader.someone'
defaultMessage='Someone'
/>
);
}
return (
<View>
<Text>
{'['}
<FormattedTime value={this.props.post.create_at}/>
{'] '}
{displayName}
{': ' + this.props.post.message}</Text>
</View>
);
}
}

View file

@ -0,0 +1,20 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {connect} from 'react-redux';
import {getTheme} from 'service/selectors/entities/preferences';
import {getUser} from 'service/selectors/entities/users';
import Post from './post';
function mapStateToProps(state, ownProps) {
return {
user: getUser(state, ownProps.post.user_id),
post: ownProps.post,
theme: getTheme(state)
};
}
export default connect(mapStateToProps)(Post);

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 {ListView} from 'react-native';
import Post from 'app/components/post';
export default class PostList extends React.Component {
static propTypes = {
posts: React.PropTypes.arrayOf(React.PropTypes.object).isRequired
};
constructor(props) {
super(props);
this.state = {
dataSource: new ListView.DataSource({
rowHasChanged: (a, b) => a !== b
}).cloneWithRows(this.props.posts)
};
}
componentWillReceiveProps(nextProps) {
this.setState({
dataSource: this.state.dataSource.cloneWithRows(nextProps.posts)
});
}
renderPost(post) {
return <Post post={post}/>;
}
render() {
return (
<ListView
enableEmptySections={true}
dataSource={this.state.dataSource}
renderRow={this.renderPost}
/>
);
}
}

View file

@ -2,21 +2,24 @@
// See License.txt for license information.
import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';
import Drawer from 'react-native-drawer';
import ChannelSidebar from 'app/components/channel_sidebar';
import Loading from 'app/components/loading';
import RightSidebarMenu from 'app/components/right_sidebar_menu';
import {StatusBar, Text, TouchableHighlight, View} from 'react-native';
import Drawer from 'react-native-drawer';
import ChannelSidebar from 'app/components/channel_sidebar';
import RightSidebarMenu from 'app/components/right_sidebar_menu';
import ChannelPostList from './components/channel_post_list';
export default class Channel extends React.Component {
static propTypes = {
actions: React.PropTypes.object.isRequired,
currentTeam: React.PropTypes.object,
actions: React.PropTypes.shape({
loadChannelsIfNecessary: React.PropTypes.func.isRequired,
selectInitialChannel: React.PropTypes.func.isRequired
}).isRequired,
currentTeam: React.PropTypes.object.isRequired,
currentChannel: React.PropTypes.object,
channels: React.PropTypes.arrayOf(React.PropTypes.object).isRequired,
channels: React.PropTypes.array,
theme: React.PropTypes.object.isRequired
};
@ -32,12 +35,16 @@ export default class Channel extends React.Component {
}
componentWillMount() {
this.props.actions.fetchMyChannelsAndMembers(this.props.currentTeam.id);
this.props.actions.loadChannelsIfNecessary(this.props.currentTeam.id).then(() => {
return this.props.actions.selectInitialChannel(this.props.currentTeam.id);
});
}
componentWillReceiveProps(nextProps) {
if (this.props.currentTeam && nextProps.currentTeam && this.props.currentTeam.id !== nextProps.currentTeam.id) {
this.props.actions.fetchMyChannelsAndMembers(nextProps.currentTeam.id);
if (this.props.currentTeam.id !== nextProps.currentTeam.id) {
this.props.actions.loadChannelsIfNecessary(nextProps.currentTeam.id).then(() => {
this.props.actions.selectInitialChannel(nextProps.currentTeam.id);
});
}
}
@ -59,14 +66,16 @@ export default class Channel extends React.Component {
render() {
const {
currentChannel,
currentTeam,
currentChannel,
channels,
theme
} = this.props;
if (!currentChannel) {
return <Loading/>;
if (!currentTeam) {
return <Text>{'Waiting on team'}</Text>;
} else if (!currentChannel) {
return <Text>{'Waiting on channel'}</Text>;
}
return (
@ -109,10 +118,7 @@ export default class Channel extends React.Component {
<Text style={{color: theme.sidebarHeaderTextColor}}>{'>'}</Text>
</TouchableHighlight>
</View>
<View style={{flex: 1}}>
<Text style={{color: theme.centerChannelColor}}>{currentTeam.id + ' - ' + currentTeam.name}</Text>
<Text style={{color: theme.centerChannelColor}}>{currentChannel.id + ' - ' + currentChannel.name}</Text>
</View>
<ChannelPostList channel={currentChannel}/>
</Drawer>
</Drawer>
</View>

View file

@ -4,38 +4,20 @@
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {fetchMyChannelsAndMembers} from 'service/actions/channels';
import {loadChannelsIfNecessary, selectInitialChannel} from 'app/actions/views/channel';
import {getChannelsOnCurrentTeam, getCurrentChannel} from 'service/selectors/entities/channels';
import {getTheme} from 'service/selectors/entities/preferences';
import {getCurrentTeam} from 'service/selectors/entities/teams';
import Channel from './channel.js';
function mapStateToProps(state, ownProps) {
const currentTeamId = state.entities.teams.currentId;
const currentTeam = state.entities.teams.teams[currentTeamId];
let channels = state.entities.channels.channels;
let currentChannel;
if (ownProps.currentChannelId) {
currentChannel = state.entities.channels.channels[ownProps.currentChannelId];
} else {
// TODO figure out the town square id before this
for (const channelId of Object.keys(channels)) {
const channel = channels[channelId];
if (channel.name === 'town-square') {
currentChannel = channel;
break;
}
}
}
channels = Object.keys(channels).map((channelId) => channels[channelId]);
return {
...ownProps,
currentTeam,
currentChannel,
channels,
currentTeam: getCurrentTeam(state),
currentChannel: getCurrentChannel(state),
channels: getChannelsOnCurrentTeam(state),
theme: getTheme(state)
};
}
@ -43,7 +25,8 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
fetchMyChannelsAndMembers
loadChannelsIfNecessary,
selectInitialChannel
}, dispatch)
};
}

View file

@ -0,0 +1,34 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import PostList from 'app/components/post_list';
export default class ChannelPostList extends React.Component {
static propTypes = {
actions: React.PropTypes.shape({
loadPostsIfNecessary: React.PropTypes.func.isRequired
}).isRequired,
channel: React.PropTypes.object.isRequired,
posts: React.PropTypes.array
}
componentDidMount() {
this.props.actions.loadPostsIfNecessary(this.props.channel);
}
componentWillReceiveProps(nextProps) {
if (this.props.channel.id !== nextProps.channel.id) {
this.props.actions.loadPostsIfNecessary(nextProps.channel);
}
}
render() {
if (!this.props.posts) {
return <Text>{'waiting on posts'}</Text>;
}
return <PostList posts={this.props.posts}/>;
}
}

View file

@ -0,0 +1,28 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {loadPostsIfNecessary} from 'app/actions/views/channel';
import {getPostsInCurrentChannel} from 'service/selectors/entities/posts';
import ChannelPostList from './channel_post_list';
function mapStateToProps(state, ownProps) {
return {
...ownProps,
posts: getPostsInCurrentChannel(state)
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
loadPostsIfNecessary
}, dispatch)
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ChannelPostList);

View file

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

View file

@ -38,8 +38,9 @@ export default class SelectTeam extends Component {
}
onSelectTeam(team) {
this.props.actions.selectTeam(team);
this.props.actions.goToChannelView();
this.props.actions.selectTeam(team).then(() => {
this.props.actions.goToChannelView();
});
}
render() {

View file

@ -16,7 +16,8 @@
"react-redux": "4.4.5",
"redux": "3.6.0",
"redux-batched-actions": "0.1.3",
"redux-thunk": "2.1.0"
"redux-thunk": "2.1.0",
"reselect": "2.5.4"
},
"devDependencies": {
"babel-eslint": "7.0.0",

View file

@ -15,7 +15,7 @@ import Client from 'service/client';
export function selectChannel(channelId) {
return async (dispatch, getState) => {
dispatch({
type: ChannelTypes.SELECTED_CHANNEL,
type: ChannelTypes.SELECT_CHANNEL,
data: channelId
}, getState);
};

View file

@ -128,7 +128,7 @@ export function getPosts(teamId, channelId, offset = 0, limit = Constants.POST_C
} catch (error) {
forceLogoutIfNecessary(error, dispatch);
dispatch({type: PostsTypes.GET_POSTS_FAILURE, error}, getState);
return;
return null;
}
dispatch(batchActions([
@ -141,6 +141,8 @@ export function getPosts(teamId, channelId, offset = 0, limit = Constants.POST_C
type: PostsTypes.GET_POSTS_SUCCESS
}
]), getState);
return posts;
};
}

View file

@ -233,7 +233,7 @@ function handleLeaveTeamEvent(msg, dispatch, getState) {
data: ''
},
{
type: ChannelTypes.SELECTED_CHANNEL,
type: ChannelTypes.SELECT_CHANNEL,
data: ''
}
]), getState);
@ -314,7 +314,7 @@ function handleChannelDeletedEvent(msg, dispatch, getState) {
channelId = channel[0];
}
dispatch({type: ChannelTypes.SELECTED_CHANNEL, data: channelId}, getState);
dispatch({type: ChannelTypes.SELECT_CHANNEL, data: channelId}, getState);
}
fetchMyChannelsAndMembers(teams.currentId)(dispatch, getState);

View file

@ -60,7 +60,7 @@ const ChannelTypes = keyMirror({
REMOVE_CHANNEL_MEMBER_SUCCESS: null,
REMOVE_CHANNEL_MEMBER_FAILURE: null,
SELECTED_CHANNEL: null,
SELECT_CHANNEL: null,
LEAVE_CHANNEL: null,
RECEIVED_CHANNEL: null,
RECEIVED_CHANNELS: null,

View file

@ -20,6 +20,7 @@ const Constants = {
DM_CHANNEL: 'D',
POST_DELETED: 'DELETED',
SYSTEM_MESSAGE_PREFIX: 'system_',
CATEGORY_DIRECT_CHANNEL_SHOW: 'direct_channel_show'
};

View file

@ -6,7 +6,7 @@ import {combineReducers} from 'redux';
function currentId(state = '', action) {
switch (action.type) {
case ChannelTypes.SELECTED_CHANNEL:
case ChannelTypes.SELECT_CHANNEL:
return action.data;
case UsersTypes.LOGOUT_SUCCESS:
return '';

View file

@ -3,13 +3,6 @@
import {Constants, PostsTypes, UsersTypes} from 'service/constants';
function initialState() {
return {
posts: {},
postsByChannel: {}
};
}
function handleReceivedPost(posts = {}, postsByChannel = {}, action) {
const post = action.data;
const channelId = post.channel_id;
@ -130,21 +123,27 @@ function handleRemovePost(posts = {}, postsByChannel = {}, action) {
return {posts: nextPosts, postsByChannel: nextPostsByChannel};
}
function handlePosts(state = initialState(), action) {
function handlePosts(posts = {}, postsByChannel = {}, action) {
switch (action.type) {
case PostsTypes.RECEIVED_POST:
return handleReceivedPost(state.posts, state.postsByChannel, action);
return handleReceivedPost(posts, postsByChannel, action);
case PostsTypes.RECEIVED_POSTS:
return handleReceivedPosts(state.posts, state.postsByChannel, action);
return handleReceivedPosts(posts, postsByChannel, action);
case PostsTypes.POST_DELETED:
return handlePostDeleted(state.posts, state.postsByChannel, action);
return handlePostDeleted(posts, postsByChannel, action);
case PostsTypes.REMOVE_POST:
return handleRemovePost(state.posts, state.postsByChannel, action);
return handleRemovePost(posts, postsByChannel, action);
case UsersTypes.LOGOUT_SUCCESS:
return initialState();
return {
posts: {},
postsByChannel: {}
};
default:
return state;
return {
posts,
postsByChannel
};
}
}
@ -166,8 +165,8 @@ function currentFocusedPostId(state = '', action) {
}
}
export default function(state = initialState(), action) {
const {posts, postsByChannel} = handlePosts(state, action);
export default function(state = {}, action) {
const {posts, postsByChannel} = handlePosts(state.posts, state.postsByChannel, action);
const nextState = {

View file

@ -9,6 +9,8 @@ function currentId(state = '', action) {
case TeamsTypes.SELECT_TEAM:
return action.data;
case UsersTypes.LOGOUT_SUCCESS:
return '';
default:
return state;
}

View file

@ -0,0 +1,38 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {createSelector} from 'reselect';
import {getCurrentTeamId} from 'service/selectors/entities/teams';
function getAllChannels(state) {
return state.entities.channels.channels;
}
export function getCurrentChannelId(state) {
return state.entities.channels.currentId;
}
export const getCurrentChannel = createSelector(
getAllChannels,
getCurrentChannelId,
(allChannels, currentChannelId) => {
return allChannels[currentChannelId];
}
);
export const getChannelsOnCurrentTeam = createSelector(
getAllChannels,
getCurrentTeamId,
(allChannels, currentTeamId) => {
const channels = [];
for (const channel of Object.values(allChannels)) {
if (channel.team_id === currentTeamId) {
channels.push(channel);
}
}
return channels;
}
);

View file

@ -0,0 +1,20 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {createSelector} from 'reselect';
function getPosts(state) {
return state.entities.posts.posts;
}
function getPostIdsInCurrentChannel(state) {
return state.entities.posts.postsByChannel[state.entities.channels.currentId] || [];
}
export const getPostsInCurrentChannel = createSelector(
getPosts,
getPostIdsInCurrentChannel,
(posts, postIds) => {
return postIds.map((id) => posts[id]);
}
);

View file

@ -0,0 +1,21 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {createSelector} from 'reselect';
export function getCurrentTeamId(state) {
return state.entities.teams.currentId;
}
export function getTeams(state) {
return state.entities.teams.teams;
}
export const getCurrentTeam = createSelector(
getTeams,
getCurrentTeamId,
(teams, currentTeamId) => {
return teams[currentTeamId];
}
);

View file

@ -0,0 +1,6 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
export function getUser(state, id) {
return state.entities.users.profiles[id];
}

View file

@ -0,0 +1,8 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import {Constants} from 'service/constants';
export function isSystemMessage(post) {
return post.type && post.type.startsWith(Constants.SYSTEM_MESSAGE_PREFIX);
}

View file

@ -25,6 +25,15 @@ describe('Actions.Channels', () => {
await TestHelper.basicClient.logout();
});
it('selectChannel', async () => {
const channelId = TestHelper.generateId();
await Actions.selectChannel(channelId)(store.dispatch, store.getState);
const state = store.getState();
assert.equal(state.entities.channels.currentId, channelId);
});
it('createChannel', async () => {
const channel = {
team_id: TestHelper.basicTeam.id,