PLT-5510 Show user is typing (#278)

* PLT-5510 Show user is typing

* Move autocomplete below the user typing
This commit is contained in:
enahum 2017-02-20 17:39:20 -03:00 committed by GitHub
parent d0209aa8c3
commit e74ecfa5f2
17 changed files with 293 additions and 54 deletions

View file

@ -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: {

View file

@ -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);

View file

@ -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);
};
}

View file

@ -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 (
<FormattedText
id='msg_typing.isTyping'
defaultMessage='{user} is typing...'
values={{
user: typing[0]
}}
/>
);
default: {
const last = typing.pop();
return (
<FormattedText
id='msg_typing.areTyping'
defaultMessage='{users} and {last} are typing...'
values={{
users: (typing.join(', ')),
last
}}
/>
);
}
}
};
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 (
<View style={{padding: 7}}>
<View>
<Text
style={{
opacity: 0.7,
fontSize: 11,
marginBottom: 5,
color: theme.centerChannelColor
}}
ellipsizeMode='tail'
numberOfLines={1}
>
{this.renderTyping()}
</Text>
</View>
<Autocomplete ref={this.attachAutocomplete}/>
<View
style={{

View file

@ -5,8 +5,10 @@ import {bindActionCreators} from 'redux';
import {connect} from 'react-redux';
import {createPost} from 'service/actions/posts';
import {userTyping} from 'service/actions/websocket';
import {getTheme} from 'service/selectors/entities/preferences';
import {getCurrentUserId} from 'service/selectors/entities/users';
import {getUsersTyping} from 'service/selectors/entities/typing';
import PostTextbox from './post_textbox';
@ -14,6 +16,7 @@ function mapStateToProps(state, ownProps) {
return {
...ownProps,
currentUserId: getCurrentUserId(state),
typing: getUsersTyping(state),
theme: getTheme(state)
};
}
@ -21,7 +24,8 @@ function mapStateToProps(state, ownProps) {
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
createPost
createPost,
userTyping
}, dispatch)
};
}

View file

@ -46,7 +46,8 @@ const state = {
},
preferences: {
myPreferences: {}
}
},
typing: {}
},
requests: {
channels: {

View file

@ -1,18 +1,18 @@
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import React, {PropTypes, PureComponent} from 'react';
import PostList from 'app/components/post_list';
export default class ChannelPostList extends React.Component {
export default class ChannelPostList extends PureComponent {
static propTypes = {
actions: React.PropTypes.shape({
loadPostsIfNecessary: React.PropTypes.func.isRequired,
goToThread: React.PropTypes.func.isRequired
actions: PropTypes.shape({
loadPostsIfNecessary: PropTypes.func.isRequired,
goToThread: PropTypes.func.isRequired
}).isRequired,
channel: React.PropTypes.object.isRequired,
posts: React.PropTypes.array.isRequired
channel: PropTypes.object.isRequired,
posts: PropTypes.array.isRequired
};
componentDidMount() {
@ -27,7 +27,7 @@ export default class ChannelPostList extends React.Component {
goToThread = (post) => {
this.props.actions.goToThread(post.channel_id, post.root_id || post.id);
}
};
render() {
if (!this.props.posts) {

View file

@ -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);
};

View file

@ -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)
};
}

View file

@ -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
};

View file

@ -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;
}
};
}

View file

@ -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',

View file

@ -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
});

View file

@ -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
});

View file

@ -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:

View file

@ -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;
}
}

View file

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