Merge pull request #91 from RocketChat/typing

[NEW] Typing
This commit is contained in:
Diego Mello 2017-11-21 17:27:00 -02:00 committed by GitHub
commit 6fbabc87d8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 210 additions and 67 deletions

View File

@ -27,6 +27,7 @@ export const FORGOT_PASSWORD = createRequestTypes('FORGOT_PASSWORD', [
'INIT' 'INIT'
]); ]);
export const ROOMS = createRequestTypes('ROOMS'); export const ROOMS = createRequestTypes('ROOMS');
export const ROOM = createRequestTypes('ROOM', ['ADD_USER_TYPING', 'REMOVE_USER_TYPING', 'SOMEONE_TYPING', 'OPEN', 'USER_TYPING']);
export const APP = createRequestTypes('APP', ['READY', 'INIT']); export const APP = createRequestTypes('APP', ['READY', 'INIT']);
export const MESSAGES = createRequestTypes('MESSAGES', [ export const MESSAGES = createRequestTypes('MESSAGES', [
...defaultTypes, ...defaultTypes,

37
app/actions/room.js Normal file
View File

@ -0,0 +1,37 @@
import * as types from './actionsTypes';
export function removeUserTyping(username) {
return {
type: types.ROOM.REMOVE_USER_TYPING,
username
};
}
export function someoneTyping(data) {
return {
type: types.ROOM.SOMEONE_TYPING,
...data
};
}
export function addUserTyping(username) {
return {
type: types.ROOM.ADD_USER_TYPING,
username
};
}
export function openRoom(room) {
return {
type: types.ROOM.OPEN,
room
};
}
export function userTyping(status = true) {
return {
type: types.ROOM.USER_TYPING,
status
};
}

View File

@ -1,5 +1,6 @@
import * as types from './actionsTypes'; import * as types from './actionsTypes';
export function roomsRequest() { export function roomsRequest() {
return { return {
type: types.ROOMS.REQUEST type: types.ROOMS.REQUEST

View File

@ -4,12 +4,14 @@ import { View, TextInput, StyleSheet, SafeAreaView } from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons'; import Icon from 'react-native-vector-icons/MaterialIcons';
import ImagePicker from 'react-native-image-picker'; import ImagePicker from 'react-native-image-picker';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { userTyping } from '../actions/room';
import RocketChat from '../lib/rocketchat'; import RocketChat from '../lib/rocketchat';
import { editRequest } from '../actions/messages'; import { editRequest } from '../actions/messages';
const styles = StyleSheet.create({ const styles = StyleSheet.create({
textBox: { textBox: {
paddingTop: 1, paddingTop: 1,
paddingHorizontal: 15,
borderTopWidth: 1, borderTopWidth: 1,
borderTopColor: '#ccc', borderTopColor: '#ccc',
backgroundColor: '#fff' backgroundColor: '#fff'
@ -25,8 +27,6 @@ const styles = StyleSheet.create({
}, },
fileButton: { fileButton: {
color: '#aaa', color: '#aaa',
paddingLeft: 23,
paddingRight: 20,
paddingTop: 10, paddingTop: 10,
paddingBottom: 10, paddingBottom: 10,
fontSize: 20 fontSize: 20
@ -40,7 +40,8 @@ const styles = StyleSheet.create({
message: state.messages.message, message: state.messages.message,
editing: state.messages.editing editing: state.messages.editing
}), dispatch => ({ }), dispatch => ({
editRequest: message => dispatch(editRequest(message)) editRequest: message => dispatch(editRequest(message)),
typing: status => dispatch(userTyping(status))
})) }))
export default class MessageBox extends React.Component { export default class MessageBox extends React.Component {
static propTypes = { static propTypes = {
@ -48,7 +49,8 @@ export default class MessageBox extends React.Component {
rid: PropTypes.string.isRequired, rid: PropTypes.string.isRequired,
editRequest: PropTypes.func.isRequired, editRequest: PropTypes.func.isRequired,
message: PropTypes.object, message: PropTypes.object,
editing: PropTypes.bool editing: PropTypes.bool,
typing: PropTypes.bool
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
@ -106,7 +108,6 @@ export default class MessageBox extends React.Component {
return ( return (
<View style={[styles.textBox, (this.props.editing ? styles.editing : null)]}> <View style={[styles.textBox, (this.props.editing ? styles.editing : null)]}>
<SafeAreaView style={styles.safeAreaView}> <SafeAreaView style={styles.safeAreaView}>
<Icon style={styles.fileButton} name='add-circle-outline' onPress={this.addFile} />
<TextInput <TextInput
ref={component => this.component = component} ref={component => this.component = component}
style={styles.textBoxInput} style={styles.textBoxInput}
@ -114,9 +115,11 @@ export default class MessageBox extends React.Component {
onSubmitEditing={event => this.submit(event.nativeEvent.text)} onSubmitEditing={event => this.submit(event.nativeEvent.text)}
blurOnSubmit={false} blurOnSubmit={false}
placeholder='New message' placeholder='New message'
onChangeText={text => this.props.typing(text.length > 0)}
underlineColorAndroid='transparent' underlineColorAndroid='transparent'
defaultValue='' defaultValue=''
/> />
<Icon style={styles.fileButton} name='add-circle-outline' onPress={this.addFile} />
</SafeAreaView> </SafeAreaView>
</View> </View>
); );

View File

@ -8,6 +8,7 @@ import reduxStore from './createStore';
import settingsType from '../constants/settings'; import settingsType from '../constants/settings';
import realm from './realm'; import realm from './realm';
import * as actions from '../actions'; import * as actions from '../actions';
import { someoneTyping } from '../actions/room';
import { disconnect, connectSuccess } from '../actions/connect'; import { disconnect, connectSuccess } from '../actions/connect';
export { Accounts } from 'react-native-meteor'; export { Accounts } from 'react-native-meteor';
@ -61,11 +62,11 @@ const RocketChat = {
}); });
Meteor.ddp.on('connected', async() => { Meteor.ddp.on('connected', async() => {
Meteor.ddp.on('changed', (ddbMessage) => { Meteor.ddp.on('changed', (ddpMessage) => {
const server = { id: reduxStore.getState().server.server }; const server = { id: reduxStore.getState().server.server };
if (ddbMessage.collection === 'stream-room-messages') { if (ddpMessage.collection === 'stream-room-messages') {
realm.write(() => { return realm.write(() => {
const message = ddbMessage.fields.args[0]; const message = ddpMessage.fields.args[0];
message.temp = false; message.temp = false;
message._server = server; message._server = server;
message.attachments = message.attachments || []; message.attachments = message.attachments || [];
@ -73,10 +74,16 @@ const RocketChat = {
realm.create('messages', message, true); realm.create('messages', message, true);
}); });
} }
if (ddpMessage.collection === 'stream-notify-room') {
if (ddbMessage.collection === 'stream-notify-user') { const [_rid, ev] = ddpMessage.fields.eventName.split('/');
const [type, data] = ddbMessage.fields.args; if (ev !== 'typing') {
const [, ev] = ddbMessage.fields.eventName.split('/'); return;
}
return reduxStore.dispatch(someoneTyping({ _rid, username: ddpMessage.fields.args[0], typing: ddpMessage.fields.args[1] }));
}
if (ddpMessage.collection === 'stream-notify-user') {
const [type, data] = ddpMessage.fields.args;
const [, ev] = ddpMessage.fields.eventName.split('/');
if (/subscriptions/.test(ev)) { if (/subscriptions/.test(ev)) {
switch (type) { switch (type) {
case 'inserted': case 'inserted':
@ -265,7 +272,6 @@ const RocketChat = {
} }
} }
resolve(); resolve();
Meteor.subscribe('stream-room-messages', rid, false);
}); });
}); });
}, },
@ -446,40 +452,27 @@ const RocketChat = {
return call('pinMessage', message); return call('pinMessage', message);
}, },
getRoom(rid) { getRoom(rid) {
return new Promise((resolve, reject) => { const result = realm.objects('subscriptions').filtered('rid = $0', rid);
const result = realm.objects('subscriptions').filtered('rid = $0', rid); if (result.length === 0) {
return Promise.reject(new Error('Room not found'));
if (result.length === 0) { }
return reject(new Error('Room not found')); return Promise.resolve(result[0]);
}
return resolve(result[0]);
});
}, },
async getPermalink(message) { async getPermalink(message) {
return new Promise(async(resolve, reject) => { const room = await RocketChat.getRoom(message.rid);
let room; const roomType = {
try { p: 'group',
room = await RocketChat.getRoom(message.rid); c: 'channel',
} catch (error) { d: 'direct'
return reject(error); }[room.t];
} return `${ room._server.id }/${ roomType }/${ room.name }?msg=${ message._id }`;
},
let roomType; subscribe(...args) {
switch (room.t) { return Meteor.subscribe(...args);
case 'p': },
roomType = 'group'; emitTyping(room, t = true) {
break; const { login } = reduxStore.getState();
case 'c': return call('stream-notify-room', `${ room }/typing`, login.user.username, t);
roomType = 'channel';
break;
case 'd':
roomType = 'direct';
break;
default:
break;
}
return resolve(`${ room._server.id }/${ roomType }/${ room.name }?msg=${ message._id }`);
});
} }
}; };

View File

@ -3,6 +3,7 @@ import settings from './reducers';
import login from './login'; import login from './login';
import meteor from './connect'; import meteor from './connect';
import messages from './messages'; import messages from './messages';
import room from './room';
import server from './server'; import server from './server';
import navigator from './navigator'; import navigator from './navigator';
import createChannel from './createChannel'; import createChannel from './createChannel';
@ -10,5 +11,5 @@ import app from './app';
export default combineReducers({ export default combineReducers({
settings, login, meteor, messages, server, navigator, createChannel, app settings, login, meteor, messages, server, navigator, createChannel, app, room
}); });

27
app/reducers/room.js Normal file
View File

@ -0,0 +1,27 @@
import * as types from '../actions/actionsTypes';
const initialState = {
usersTyping: []
};
export default function room(state = initialState, action) {
switch (action.type) {
case types.ROOM.OPEN:
return {
...initialState,
...action.room
};
case types.ROOM.ADD_USER_TYPING:
return {
...state,
usersTyping: [...state.usersTyping.filter(user => user !== action.username), action.username]
};
case types.ROOM.REMOVE_USER_TYPING:
return {
...state,
usersTyping: [...state.usersTyping.filter(user => user !== action.username)]
};
default:
return state;
}
}

View File

@ -29,7 +29,6 @@ const get = function* get({ rid }) {
} }
try { try {
yield RocketChat.loadMessagesForRoom(rid, null); yield RocketChat.loadMessagesForRoom(rid, null);
yield RocketChat.readMessages(rid);
yield put(messagesSuccess()); yield put(messagesSuccess());
} catch (err) { } catch (err) {
console.log(err); console.log(err);

View File

@ -1,6 +1,9 @@
import { put, call, takeEvery } from 'redux-saga/effects'; import { put, call, takeLatest, take, select, race, fork, cancel } from 'redux-saga/effects';
import { delay } from 'redux-saga';
import * as types from '../actions/actionsTypes'; import * as types from '../actions/actionsTypes';
import { roomsSuccess, roomsFailure } from '../actions/rooms'; import { roomsSuccess, roomsFailure } from '../actions/rooms';
import { addUserTyping, removeUserTyping } from '../actions/room';
import { messagesRequest } from '../actions/messages';
import RocketChat from '../lib/rocketchat'; import RocketChat from '../lib/rocketchat';
const getRooms = function* getRooms() { const getRooms = function* getRooms() {
@ -9,15 +12,86 @@ const getRooms = function* getRooms() {
const watchRoomsRequest = function* watchRoomsRequest() { const watchRoomsRequest = function* watchRoomsRequest() {
try { try {
console.log('getRooms');
yield call(getRooms); yield call(getRooms);
yield put(roomsSuccess()); yield put(roomsSuccess());
} catch (err) { } catch (err) {
console.log(err);
yield put(roomsFailure(err.status)); yield put(roomsFailure(err.status));
} }
}; };
const cancelTyping = function* cancelTyping(username) {
while (true) {
const { typing, timeout } = yield race({
typing: take(types.ROOM.SOMEONE_TYPING),
timeout: yield call(delay, 5000)
});
if (timeout || (typing.username === username && !typing.typing)) {
return yield put(removeUserTyping(username));
}
}
};
const usersTyping = function* usersTyping({ rid }) {
while (true) {
const { _rid, username, typing } = yield take(types.ROOM.SOMEONE_TYPING);
if (_rid === rid) {
yield (typing ? put(addUserTyping(username)) : put(removeUserTyping(username)));
if (typing) {
fork(cancelTyping, username);
}
}
}
};
const watchRoomOpen = function* watchRoomOpen({ room }) {
const auth = yield select(state => state.login.isAuthenticated);
if (!auth) {
yield take(types.LOGIN.SUCCESS);
}
const subscriptions = [];
yield put(messagesRequest({ rid: room.rid }));
const { open } = yield race({
messages: take(types.MESSAGES.SUCCESS),
open: take(types.ROOM.OPEN)
});
if (open) {
return;
}
RocketChat.readMessages(room.rid);
subscriptions.push(RocketChat.subscribe('stream-room-messages', room.rid, false));
subscriptions.push(RocketChat.subscribe('stream-notify-room', `${ room.rid }/typing`, false));
const thread = yield fork(usersTyping, { rid: room.rid });
yield take(types.ROOM.OPEN);
cancel(thread);
subscriptions.forEach(sub => sub.stop());
};
const watchuserTyping = function* watchuserTyping({ status }) {
const auth = yield select(state => state.login.isAuthenticated);
if (!auth) {
yield take(types.LOGIN.SUCCESS);
}
const room = yield select(state => state.room);
if (!room) {
return;
}
yield RocketChat.emitTyping(room.rid, status);
if (status) {
yield call(delay, 5000);
yield RocketChat.emitTyping(room.rid, false);
}
};
const root = function* root() { const root = function* root() {
yield takeEvery(types.LOGIN.SUCCESS, watchRoomsRequest); yield takeLatest(types.ROOM.USER_TYPING, watchuserTyping);
yield takeLatest(types.LOGIN.SUCCESS, watchRoomsRequest);
yield takeLatest(types.ROOM.OPEN, watchRoomOpen);
}; };
export default root; export default root;

View File

@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import * as actions from '../actions'; import * as actions from '../actions';
import { messagesRequest } from '../actions/messages'; import { openRoom } from '../actions/room';
import realm from '../lib/realm'; import realm from '../lib/realm';
import RocketChat from '../lib/rocketchat'; import RocketChat from '../lib/rocketchat';
import Message from '../containers/message'; import Message from '../containers/message';
@ -15,6 +15,7 @@ import KeyboardView from '../presentation/KeyboardView';
const ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1._id !== r2._id }); const ds = new ListView.DataSource({ rowHasChanged: (r1, r2) => r1._id !== r2._id });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
typing: { fontWeight: 'bold', paddingHorizontal: 15, height: 25 },
container: { container: {
flex: 1, flex: 1,
backgroundColor: '#fff' backgroundColor: '#fff'
@ -48,6 +49,8 @@ const styles = StyleSheet.create({
@connect( @connect(
state => ({ state => ({
username: state.login.user.username,
usersTyping: state.room.usersTyping,
server: state.server.server, server: state.server.server,
Site_Url: state.settings.Site_Url, Site_Url: state.settings.Site_Url,
Message_TimeFormat: state.settings.Message_TimeFormat, Message_TimeFormat: state.settings.Message_TimeFormat,
@ -55,20 +58,22 @@ const styles = StyleSheet.create({
}), }),
dispatch => ({ dispatch => ({
actions: bindActionCreators(actions, dispatch), actions: bindActionCreators(actions, dispatch),
getMessages: rid => dispatch(messagesRequest({ rid })) openRoom: room => dispatch(openRoom(room))
}) })
) )
export default class RoomView extends React.Component { export default class RoomView extends React.Component {
static propTypes = { static propTypes = {
navigation: PropTypes.object.isRequired, navigation: PropTypes.object.isRequired,
getMessages: PropTypes.func.isRequired, openRoom: PropTypes.func.isRequired,
rid: PropTypes.string, rid: PropTypes.string,
sid: PropTypes.string, sid: PropTypes.string,
name: PropTypes.string, name: PropTypes.string,
server: PropTypes.string, server: PropTypes.string,
Site_Url: PropTypes.string, Site_Url: PropTypes.string,
Message_TimeFormat: PropTypes.string, Message_TimeFormat: PropTypes.string,
loading: PropTypes.bool loading: PropTypes.bool,
usersTyping: PropTypes.array,
username: PropTypes.string
}; };
constructor(props) { constructor(props) {
@ -100,7 +105,7 @@ export default class RoomView extends React.Component {
realm.objectForPrimaryKey('subscriptions', this.sid).name realm.objectForPrimaryKey('subscriptions', this.sid).name
}); });
this.timer = setTimeout(() => this.setState({ slow: true }), 5000); this.timer = setTimeout(() => this.setState({ slow: true }), 5000);
this.props.getMessages(this.rid); this.props.openRoom({ rid: this.rid });
this.data.addListener(this.updateState); this.data.addListener(this.updateState);
} }
componentDidMount() { componentDidMount() {
@ -134,7 +139,12 @@ export default class RoomView extends React.Component {
}); });
}); });
} }
}; }
get usersTyping() {
const users = this.props.usersTyping.filter(_username => this.props.username !== _username);
return users.length ? `${ users.join(' ,') } ${ users.length > 1 ? 'are' : 'is' } typing` : null;
}
updateState = () => { updateState = () => {
this.setState({ this.setState({
@ -189,8 +199,7 @@ export default class RoomView extends React.Component {
if (this.state.end) { if (this.state.end) {
return <Text style={styles.loadingMore}>Start of conversation</Text>; return <Text style={styles.loadingMore}>Start of conversation</Text>;
} }
}; }
render() { render() {
const { height } = Dimensions.get('window'); const { height } = Dimensions.get('window');
return ( return (
@ -209,6 +218,7 @@ export default class RoomView extends React.Component {
/> />
</SafeAreaView> </SafeAreaView>
{this.renderFooter()} {this.renderFooter()}
<Text style={styles.typing}>{this.usersTyping}</Text>
</KeyboardView> </KeyboardView>
); );
} }

View File

@ -100,22 +100,20 @@ export default class RoomsListView extends React.Component {
super(props); super(props);
this.state = { this.state = {
dataSource: [], dataSource: ds.cloneWithRows([]),
searchText: '' searchText: ''
}; };
this.data = realm.objects('subscriptions').filtered('_server.id = $0', this.props.server).sorted('_updatedAt', true); this.data = realm.objects('subscriptions').filtered('_server.id = $0', this.props.server).sorted('_updatedAt', true);
} }
componentWillMount() { componentDidMount() {
this.data.addListener(this.updateState); this.data.addListener(this.updateState);
this.props.navigation.setParams({ this.props.navigation.setParams({
createChannel: () => this._createChannel() createChannel: () => this._createChannel()
}); });
this.setState({ this.updateState();
dataSource: ds.cloneWithRows(this.data)
});
} }
componentWillReceiveProps(props) { componentWillReceiveProps(props) {

3
package-lock.json generated
View File

@ -1853,8 +1853,7 @@
"babel-plugin-transform-remove-console": { "babel-plugin-transform-remove-console": {
"version": "6.8.5", "version": "6.8.5",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.8.5.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.8.5.tgz",
"integrity": "sha512-uuCKvtweCyIvvC8fi92EcWRtO2Kt5KMNMRK6BhpDXdeb3sxvGM7453RSmgeu4DlKns3OlvY9Ep5Q9m5a7RQAgg==", "integrity": "sha512-uuCKvtweCyIvvC8fi92EcWRtO2Kt5KMNMRK6BhpDXdeb3sxvGM7453RSmgeu4DlKns3OlvY9Ep5Q9m5a7RQAgg=="
"dev": true
}, },
"babel-plugin-transform-remove-debugger": { "babel-plugin-transform-remove-debugger": {
"version": "6.8.5", "version": "6.8.5",