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 []; + } +);