PLT-5510 Show user is typing (#278)
* PLT-5510 Show user is typing * Move autocomplete below the user typing
This commit is contained in:
parent
d0209aa8c3
commit
e74ecfa5f2
17 changed files with 293 additions and 54 deletions
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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={{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ const state = {
|
|||
},
|
||||
preferences: {
|
||||
myPreferences: {}
|
||||
}
|
||||
},
|
||||
typing: {}
|
||||
},
|
||||
requests: {
|
||||
channels: {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
38
service/reducers/entities/typing.js
Normal file
38
service/reducers/entities/typing.js
Normal 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;
|
||||
}
|
||||
}
|
||||
31
service/selectors/entities/typing.js
Normal file
31
service/selectors/entities/typing.js
Normal 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 [];
|
||||
}
|
||||
);
|
||||
Loading…
Reference in a new issue