diff --git a/app/actions/navigation/index.js b/app/actions/navigation/index.js
index c787d3215..d71f63636 100644
--- a/app/actions/navigation/index.js
+++ b/app/actions/navigation/index.js
@@ -3,6 +3,7 @@
import {NavigationTypes} from 'app/constants';
import Routes from 'app/navigation/routes';
+import {selectPost} from 'service/actions/posts';
export function goBack() {
return async (dispatch, getState) => {
@@ -85,6 +86,8 @@ export function goToChannelAddMembers() {
export function goToThread(channelId, rootId) {
return async (dispatch, getState) => {
+ selectPost(rootId)(dispatch, getState);
+
dispatch({
type: NavigationTypes.NAVIGATION_PUSH,
route: {
diff --git a/app/actions/views/channel.js b/app/actions/views/channel.js
index e5aa1d262..a6b0524ff 100644
--- a/app/actions/views/channel.js
+++ b/app/actions/views/channel.js
@@ -9,6 +9,7 @@ import {closeDrawers} from 'app/actions/navigation';
import {
fetchMyChannelsAndMembers,
+ getChannelStats,
getMyChannelMembers,
selectChannel,
leaveChannel as serviceLeaveChannel
@@ -23,7 +24,7 @@ import {getPreferencesByCategory} from 'service/utils/preference_utils';
export function loadChannelsIfNecessary(teamId) {
return async (dispatch, getState) => {
- const channels = getState().entities.channels.channels;
+ const {channels} = getState().entities.channels;
let hasChannelsForTeam = false;
for (const channel of Object.values(channels)) {
@@ -129,19 +130,19 @@ export function selectInitialChannel(teamId) {
if (currentChannel && myMembers[currentChannelId] &&
(currentChannel.team_id === teamId || (currentChannel.type === Constants.DM_CHANNEL &&
isDirectChannelVisible(currentUserId, myPreferences, currentChannel)))) {
- await selectChannel(currentChannelId)(dispatch, getState);
+ await handleSelectChannel(currentChannelId)(dispatch, getState);
return;
}
const channel = Object.values(channels).find((c) => c.team_id === teamId && c.name === Constants.DEFAULT_CHANNEL);
if (channel) {
- await selectChannel(channel.id)(dispatch, getState);
+ await handleSelectChannel(channel.id)(dispatch, getState);
} else {
// Handle case when the default channel cannot be found
// so we need to get the first available channel of the team
const channelsInTeam = Object.values(channels).filter((c) => c.team_id === teamId);
const firstChannel = channelsInTeam[0].id;
- await selectChannel(firstChannel.id)(dispatch, getState);
+ await handleSelectChannel(firstChannel.id)(dispatch, getState);
}
};
}
@@ -152,6 +153,8 @@ export function handleSelectChannel(channelId) {
await updateStorage(currentTeamId, {currentChannelId: channelId});
await selectChannel(channelId)(dispatch, getState);
+ await getChannelStats(currentTeamId, channelId)(dispatch, getState);
+
setTimeout(async () => {
await closeDrawers()(dispatch, getState); // trying to smooth out channel switch transitions
}, 200);
diff --git a/app/actions/views/root.js b/app/actions/views/root.js
index df34bd5e4..361230c06 100644
--- a/app/actions/views/root.js
+++ b/app/actions/views/root.js
@@ -6,6 +6,7 @@ import Routes from 'app/navigation/routes';
import Client from 'service/client';
import {loadMe} from 'service/actions/users';
+import {getClientConfig, getLicenseConfig} from 'service/actions/general';
export function goToSelectServer() {
return async (dispatch, getState) => {
@@ -22,6 +23,8 @@ export function setStoreFromLocalData(data) {
Client.setToken(data.token);
Client.setUrl(data.url);
+ await getClientConfig()(dispatch, getState);
+ await getLicenseConfig()(dispatch, getState);
return loadMe()(dispatch, getState);
};
}
diff --git a/app/components/post_textbox/post_textbox.js b/app/components/post_textbox/post_textbox.js
index 40efd65d7..7e1ea0a3c 100644
--- a/app/components/post_textbox/post_textbox.js
+++ b/app/components/post_textbox/post_textbox.js
@@ -1,15 +1,16 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import React from 'react';
+import React, {PropTypes, PureComponent} from 'react';
import {
Platform,
TouchableHighlight,
- View
+ View, Text
} from 'react-native';
import Icon from 'react-native-vector-icons/FontAwesome';
import Autocomplete from 'app/components/autocomplete';
+import FormattedText from 'app/components/formatted_text';
import TextInputWithLocalizedPlaceholder from 'app/components/text_input_with_localized_placeholder';
import {changeOpacity} from 'app/utils/theme';
@@ -17,17 +18,19 @@ import {changeOpacity} from 'app/utils/theme';
const MAX_CONTENT_HEIGHT = 100;
-export default class PostTextbox extends React.PureComponent {
+export default class PostTextbox extends PureComponent {
static propTypes = {
- currentUserId: React.PropTypes.string.isRequired,
- teamId: React.PropTypes.string.isRequired,
- channelId: React.PropTypes.string.isRequired,
- rootId: React.PropTypes.string,
- value: React.PropTypes.string.isRequired,
- onChangeText: React.PropTypes.func.isRequired,
- theme: React.PropTypes.object.isRequired,
- actions: React.PropTypes.shape({
- createPost: React.PropTypes.func.isRequired
+ currentUserId: PropTypes.string.isRequired,
+ typing: PropTypes.array.isRequired,
+ teamId: PropTypes.string.isRequired,
+ channelId: PropTypes.string.isRequired,
+ rootId: PropTypes.string,
+ value: PropTypes.string.isRequired,
+ onChangeText: PropTypes.func.isRequired,
+ theme: PropTypes.object.isRequired,
+ actions: PropTypes.shape({
+ createPost: PropTypes.func.isRequired,
+ userTyping: PropTypes.func.isRequired
}).isRequired
};
@@ -72,21 +75,62 @@ export default class PostTextbox extends React.PureComponent {
};
handleTextChange = (text) => {
- this.props.onChangeText(text);
- }
+ const {
+ onChangeText,
+ channelId,
+ rootId,
+ actions
+ } = this.props;
+
+ onChangeText(text);
+ actions.userTyping(channelId, rootId);
+ };
handleSelectionChange = (event) => {
if (this.autocomplete) {
this.autocomplete.handleSelectionChange(event);
}
- }
+ };
attachAutocomplete = (c) => {
this.autocomplete = c;
- }
+ };
+
+ renderTyping = () => {
+ const {typing} = this.props;
+ const numUsers = typing.length;
+
+ switch (numUsers) {
+ case 0:
+ return null;
+ case 1:
+ return (
+
+ );
+ default: {
+ const last = typing.pop();
+ return (
+
+ );
+ }
+ }
+ };
render() {
- const theme = this.props.theme;
+ const {theme} = this.props;
let placeholder;
if (this.props.rootId) {
@@ -97,6 +141,20 @@ export default class PostTextbox extends React.PureComponent {
return (
+
+
+ {this.renderTyping()}
+
+
{
this.props.actions.goToThread(post.channel_id, post.root_id || post.id);
- }
+ };
render() {
if (!this.props.posts) {
diff --git a/app/scenes/thread/thread.js b/app/scenes/thread/thread.js
index 671a1c221..c69c0effd 100644
--- a/app/scenes/thread/thread.js
+++ b/app/scenes/thread/thread.js
@@ -1,7 +1,7 @@
// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import React from 'react';
+import React, {PropTypes, PureComponent} from 'react';
import {StatusBar, StyleSheet} from 'react-native';
import KeyboardLayout from 'app/components/layout/keyboard_layout';
@@ -18,19 +18,24 @@ const getStyle = makeStyleSheetFromTheme((theme) => {
});
});
-export default class Thread extends React.Component {
+export default class Thread extends PureComponent {
static propTypes = {
actions: React.PropTypes.shape({
- handleCommentDraftChanged: React.PropTypes.func.isRequired
+ handleCommentDraftChanged: PropTypes.func.isRequired,
+ selectPost: PropTypes.func.isRequired
}).isRequired,
- teamId: React.PropTypes.string.isRequired,
- channelId: React.PropTypes.string.isRequired,
- rootId: React.PropTypes.string.isRequired,
- draft: React.PropTypes.string.isRequired,
- theme: React.PropTypes.object.isRequired,
- posts: React.PropTypes.array.isRequired
+ teamId: PropTypes.string.isRequired,
+ channelId: PropTypes.string.isRequired,
+ rootId: PropTypes.string.isRequired,
+ draft: PropTypes.string.isRequired,
+ theme: PropTypes.object.isRequired,
+ posts: PropTypes.array.isRequired
};
+ componentWillUnmount() {
+ this.props.actions.selectPost('');
+ }
+
handleDraftChanged = (value) => {
this.props.actions.handleCommentDraftChanged(this.props.rootId, value);
};
diff --git a/app/scenes/thread/thread_container.js b/app/scenes/thread/thread_container.js
index 54f19b546..3485dd426 100644
--- a/app/scenes/thread/thread_container.js
+++ b/app/scenes/thread/thread_container.js
@@ -4,6 +4,7 @@
import {bindActionCreators} from 'redux';
import {handleCommentDraftChanged} from 'app/actions/views/thread';
+import {selectPost} from 'service/actions/posts';
import {makeGetPostsForThread} from 'service/selectors/entities/posts';
import {getTheme} from 'service/selectors/entities/preferences';
@@ -34,7 +35,8 @@ function makeMapStateToProps() {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
- handleCommentDraftChanged
+ handleCommentDraftChanged,
+ selectPost
}, dispatch)
};
}
diff --git a/service/actions/posts.js b/service/actions/posts.js
index 6410dc257..0df64de76 100644
--- a/service/actions/posts.js
+++ b/service/actions/posts.js
@@ -227,6 +227,15 @@ export function getPostsAfter(teamId, channelId, postId, offset = 0, limit = Con
};
}
+export function selectPost(postId) {
+ return async (dispatch, getState) => {
+ dispatch({
+ type: PostsTypes.RECEIVED_POST_SELECTED,
+ data: postId
+ }, getState);
+ };
+}
+
export default {
createPost,
editPost,
@@ -236,5 +245,6 @@ export default {
getPosts,
getPostsSince,
getPostsBefore,
- getPostsAfter
+ getPostsAfter,
+ selectPost
};
diff --git a/service/actions/websocket.js b/service/actions/websocket.js
index d8ca7740a..01951a9f0 100644
--- a/service/actions/websocket.js
+++ b/service/actions/websocket.js
@@ -1,9 +1,10 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
-import {getProfilesByIds, getStatusesByIds} from 'service/actions/users';
+import {batchActions} from 'redux-batched-actions';
import Client from 'service/client';
import websocketClient from 'service/client/websocket_client';
+import {getProfilesByIds, getStatusesByIds} from 'service/actions/users';
import {
fetchMyChannelsAndMembers,
getChannel,
@@ -28,6 +29,7 @@ import {
UsersTypes,
WebsocketEvents
} from 'service/constants';
+import {getCurrentChannelStats} from 'service/selectors/entities/channels';
import {getUserIdFromChannelName} from 'service/utils/channel_utils';
import EventEmitter from 'service/utils/event_emitter';
@@ -136,6 +138,9 @@ function handleEvent(msg, dispatch, getState) {
case WebsocketEvents.STATUS_CHANGED:
handleStatusChangedEvent(msg, dispatch, getState);
break;
+ case WebsocketEvents.TYPING:
+ handleUserTypingEvent(msg, dispatch, getState);
+ break;
}
}
@@ -192,16 +197,25 @@ async function handleNewPostEvent(msg, dispatch, getState) {
});
}
- dispatch({
- type: PostsTypes.RECEIVED_POSTS,
- data: {
- order: [],
- posts: {
- [post.id]: post
- }
+ dispatch(batchActions([
+ {
+ type: PostsTypes.RECEIVED_POSTS,
+ data: {
+ order: [],
+ posts: {
+ [post.id]: post
+ }
+ },
+ channelId: post.channel_id
},
- channelId: post.channel_id
- }, getState);
+ {
+ type: WebsocketEvents.STOP_TYPING,
+ data: {
+ id: post.channel_id + post.root_id,
+ userId: post.user_id
+ }
+ }
+ ]), getState);
if (userId === users.currentId || post.channel_id === currentChannelId) {
markChannelAsRead(post.channel_id);
@@ -343,6 +357,52 @@ function handleStatusChangedEvent(msg, dispatch, getState) {
}, getState);
}
+const typingUsers = {};
+function handleUserTypingEvent(msg, dispatch, getState) {
+ const state = getState();
+ const {profiles, statuses} = state.entities.users;
+ const {config} = state.entities.general;
+ const userId = msg.data.user_id;
+ const id = msg.broadcast.channel_id + msg.data.parent_id;
+ const data = {id, userId};
+
+ // Create entry
+ if (!typingUsers[id]) {
+ typingUsers[id] = {};
+ }
+
+ // If we already have this user, clear it's timeout to be deleted
+ if (typingUsers[id][userId]) {
+ clearTimeout(typingUsers[id][userId].timeout);
+ }
+
+ // Set the user and a timeout to remove it
+ typingUsers[id][userId] = setTimeout(() => {
+ Reflect.deleteProperty(typingUsers[id], userId);
+ if (typingUsers[id] === {}) {
+ Reflect.deleteProperty(typingUsers, id);
+ }
+ dispatch({
+ type: WebsocketEvents.STOP_TYPING,
+ data
+ }, getState);
+ }, parseInt(config.TimeBetweenUserTypingUpdatesMilliseconds, 10));
+
+ dispatch({
+ type: WebsocketEvents.TYPING,
+ data
+ }, getState);
+
+ if (!profiles[userId]) {
+ getProfilesByIds([userId])(dispatch, getState);
+ }
+
+ const status = statuses[userId];
+ if (status !== Constants.ONLINE) {
+ getStatusesByIds([userId])(dispatch, getState);
+ }
+}
+
// Helpers
function loadPostsHelper(teamId, channelId, dispatch, getState) {
@@ -361,3 +421,19 @@ function loadPostsHelper(teamId, channelId, dispatch, getState) {
getPostsSince(teamId, channelId, latestPostTime)(dispatch, getState);
}
}
+
+let lastTimeTypingSent = 0;
+export function userTyping(channelId, parentPostId) {
+ return async (dispatch, getState) => {
+ const state = getState();
+ const config = state.entities.general.config;
+ const t = Date.now();
+ const membersInChannel = getCurrentChannelStats(state).member_count;
+
+ if (((t - lastTimeTypingSent) > config.TimeBetweenUserTypingUpdatesMilliseconds) &&
+ (membersInChannel < config.MaxNotificationsPerChannel) && (config.EnableUserTypingMessages === 'true')) {
+ websocketClient.userTyping(channelId, parentPostId);
+ lastTimeTypingSent = t;
+ }
+ };
+}
diff --git a/service/constants/websocket.js b/service/constants/websocket.js
index 98d874370..53edf29db 100644
--- a/service/constants/websocket.js
+++ b/service/constants/websocket.js
@@ -12,6 +12,7 @@ const WebsocketEvents = {
USER_REMOVED: 'user_removed',
USER_UPDATED: 'user_updated',
TYPING: 'typing',
+ STOP_TYPING: 'stop_typing',
PREFERENCE_CHANGED: 'preference_changed',
EPHEMERAL_MESSAGE: 'ephemeral_message',
STATUS_CHANGED: 'status_change',
diff --git a/service/reducers/entities/channels.js b/service/reducers/entities/channels.js
index 9f3e0df58..259841502 100644
--- a/service/reducers/entities/channels.js
+++ b/service/reducers/entities/channels.js
@@ -150,6 +150,6 @@ export default combineReducers({
//object where every key is the channel id and has and object with the channel members detail
myMembers,
- // object where every key is the team id and has an object with the team stats
+ // object where every key is the channel id and has an object with the channel stats
stats
});
diff --git a/service/reducers/entities/index.js b/service/reducers/entities/index.js
index 7f90ef8ae..7eeb40763 100644
--- a/service/reducers/entities/index.js
+++ b/service/reducers/entities/index.js
@@ -10,6 +10,7 @@ import teams from './teams';
import posts from './posts';
import files from './files';
import preferences from './preferences';
+import typing from './typing';
export default combineReducers({
general,
@@ -18,5 +19,6 @@ export default combineReducers({
channels,
posts,
files,
- preferences
+ preferences,
+ typing
});
diff --git a/service/reducers/entities/posts.js b/service/reducers/entities/posts.js
index 70403d330..d34e4e5b2 100644
--- a/service/reducers/entities/posts.js
+++ b/service/reducers/entities/posts.js
@@ -152,6 +152,8 @@ function handlePosts(posts = {}, postsByChannel = {}, action) {
function selectedPostId(state = '', action) {
switch (action.type) {
+ case PostsTypes.RECEIVED_POST_SELECTED:
+ return action.data;
case UsersTypes.LOGOUT_SUCCESS:
return '';
default:
diff --git a/service/reducers/entities/typing.js b/service/reducers/entities/typing.js
new file mode 100644
index 000000000..290c018ac
--- /dev/null
+++ b/service/reducers/entities/typing.js
@@ -0,0 +1,38 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {WebsocketEvents} from 'service/constants';
+
+export default function typing(state = {}, action) {
+ const {data, type} = action;
+ switch (type) {
+ case WebsocketEvents.TYPING: {
+ const {id, userId} = data;
+
+ return {
+ ...state,
+ [id]: {
+ ...state[id],
+ [userId]: true
+ }
+ };
+ }
+ case WebsocketEvents.STOP_TYPING: {
+ const nextState = {...state};
+ const {id, userId} = data;
+ const users = {...nextState[id]};
+ if (users) {
+ Reflect.deleteProperty(users, userId);
+ }
+
+ nextState[id] = users;
+ if (!Object.keys(nextState[id]).length) {
+ Reflect.deleteProperty(nextState, id);
+ }
+
+ return nextState;
+ }
+ default:
+ return state;
+ }
+}
diff --git a/service/selectors/entities/typing.js b/service/selectors/entities/typing.js
new file mode 100644
index 000000000..f072482d7
--- /dev/null
+++ b/service/selectors/entities/typing.js
@@ -0,0 +1,31 @@
+// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved.
+// See License.txt for license information.
+
+import {createSelector} from 'reselect';
+import {getCurrentChannelId} from './channels';
+import {getMyPreferences} from './preferences';
+import {getUsers} from './users';
+import {displayUsername} from 'service/utils/user_utils';
+
+export const getUsersTyping = createSelector(
+ getUsers,
+ getMyPreferences,
+ getCurrentChannelId,
+ (state) => state.entities.posts.selectedPostId,
+ (state) => state.entities.typing,
+ (profiles, preferences, channelId, parentPostId, typing) => {
+ const id = channelId + parentPostId;
+
+ if (typing[id]) {
+ const users = Object.keys(typing[id]);
+
+ if (users.length) {
+ return users.map((userId) => {
+ return displayUsername(profiles[userId], preferences);
+ });
+ }
+ }
+
+ return [];
+ }
+);