diff --git a/app/actions/actionsTypes.js b/app/actions/actionsTypes.js
index 29a0dd0b..effed182 100644
--- a/app/actions/actionsTypes.js
+++ b/app/actions/actionsTypes.js
@@ -27,6 +27,7 @@ export const FORGOT_PASSWORD = createRequestTypes('FORGOT_PASSWORD', [
'INIT'
]);
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 MESSAGES = createRequestTypes('MESSAGES', [
...defaultTypes,
diff --git a/app/actions/room.js b/app/actions/room.js
new file mode 100644
index 00000000..14e346fc
--- /dev/null
+++ b/app/actions/room.js
@@ -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
+ };
+}
diff --git a/app/actions/rooms.js b/app/actions/rooms.js
index bdbf94d0..e1d1c8fe 100644
--- a/app/actions/rooms.js
+++ b/app/actions/rooms.js
@@ -1,5 +1,6 @@
import * as types from './actionsTypes';
+
export function roomsRequest() {
return {
type: types.ROOMS.REQUEST
diff --git a/app/containers/MessageBox.js b/app/containers/MessageBox.js
index eb9f5656..65740a51 100644
--- a/app/containers/MessageBox.js
+++ b/app/containers/MessageBox.js
@@ -4,12 +4,14 @@ import { View, TextInput, StyleSheet, SafeAreaView } from 'react-native';
import Icon from 'react-native-vector-icons/MaterialIcons';
import ImagePicker from 'react-native-image-picker';
import { connect } from 'react-redux';
+import { userTyping } from '../actions/room';
import RocketChat from '../lib/rocketchat';
import { editRequest } from '../actions/messages';
const styles = StyleSheet.create({
textBox: {
paddingTop: 1,
+ paddingHorizontal: 15,
borderTopWidth: 1,
borderTopColor: '#ccc',
backgroundColor: '#fff'
@@ -25,8 +27,6 @@ const styles = StyleSheet.create({
},
fileButton: {
color: '#aaa',
- paddingLeft: 23,
- paddingRight: 20,
paddingTop: 10,
paddingBottom: 10,
fontSize: 20
@@ -40,7 +40,8 @@ const styles = StyleSheet.create({
message: state.messages.message,
editing: state.messages.editing
}), dispatch => ({
- editRequest: message => dispatch(editRequest(message))
+ editRequest: message => dispatch(editRequest(message)),
+ typing: status => dispatch(userTyping(status))
}))
export default class MessageBox extends React.Component {
static propTypes = {
@@ -48,7 +49,8 @@ export default class MessageBox extends React.Component {
rid: PropTypes.string.isRequired,
editRequest: PropTypes.func.isRequired,
message: PropTypes.object,
- editing: PropTypes.bool
+ editing: PropTypes.bool,
+ typing: PropTypes.bool
}
componentWillReceiveProps(nextProps) {
@@ -106,7 +108,6 @@ export default class MessageBox extends React.Component {
return (
-
this.component = component}
style={styles.textBoxInput}
@@ -114,9 +115,11 @@ export default class MessageBox extends React.Component {
onSubmitEditing={event => this.submit(event.nativeEvent.text)}
blurOnSubmit={false}
placeholder='New message'
+ onChangeText={text => this.props.typing(text.length > 0)}
underlineColorAndroid='transparent'
defaultValue=''
/>
+
);
diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js
index da645a95..b58f4984 100644
--- a/app/lib/rocketchat.js
+++ b/app/lib/rocketchat.js
@@ -8,6 +8,7 @@ import reduxStore from './createStore';
import settingsType from '../constants/settings';
import realm from './realm';
import * as actions from '../actions';
+import { someoneTyping } from '../actions/room';
import { disconnect, connectSuccess } from '../actions/connect';
export { Accounts } from 'react-native-meteor';
@@ -61,11 +62,11 @@ const RocketChat = {
});
Meteor.ddp.on('connected', async() => {
- Meteor.ddp.on('changed', (ddbMessage) => {
+ Meteor.ddp.on('changed', (ddpMessage) => {
const server = { id: reduxStore.getState().server.server };
- if (ddbMessage.collection === 'stream-room-messages') {
- realm.write(() => {
- const message = ddbMessage.fields.args[0];
+ if (ddpMessage.collection === 'stream-room-messages') {
+ return realm.write(() => {
+ const message = ddpMessage.fields.args[0];
message.temp = false;
message._server = server;
message.attachments = message.attachments || [];
@@ -73,10 +74,16 @@ const RocketChat = {
realm.create('messages', message, true);
});
}
-
- if (ddbMessage.collection === 'stream-notify-user') {
- const [type, data] = ddbMessage.fields.args;
- const [, ev] = ddbMessage.fields.eventName.split('/');
+ if (ddpMessage.collection === 'stream-notify-room') {
+ const [_rid, ev] = ddpMessage.fields.eventName.split('/');
+ if (ev !== 'typing') {
+ 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)) {
switch (type) {
case 'inserted':
@@ -265,7 +272,6 @@ const RocketChat = {
}
}
resolve();
- Meteor.subscribe('stream-room-messages', rid, false);
});
});
},
@@ -446,40 +452,27 @@ const RocketChat = {
return call('pinMessage', message);
},
getRoom(rid) {
- return new Promise((resolve, reject) => {
- const result = realm.objects('subscriptions').filtered('rid = $0', rid);
-
- if (result.length === 0) {
- return reject(new Error('Room not found'));
- }
- return resolve(result[0]);
- });
+ const result = realm.objects('subscriptions').filtered('rid = $0', rid);
+ if (result.length === 0) {
+ return Promise.reject(new Error('Room not found'));
+ }
+ return Promise.resolve(result[0]);
},
async getPermalink(message) {
- return new Promise(async(resolve, reject) => {
- let room;
- try {
- room = await RocketChat.getRoom(message.rid);
- } catch (error) {
- return reject(error);
- }
-
- let roomType;
- switch (room.t) {
- case 'p':
- roomType = 'group';
- break;
- case 'c':
- roomType = 'channel';
- break;
- case 'd':
- roomType = 'direct';
- break;
- default:
- break;
- }
- return resolve(`${ room._server.id }/${ roomType }/${ room.name }?msg=${ message._id }`);
- });
+ const room = await RocketChat.getRoom(message.rid);
+ const roomType = {
+ p: 'group',
+ c: 'channel',
+ d: 'direct'
+ }[room.t];
+ return `${ room._server.id }/${ roomType }/${ room.name }?msg=${ message._id }`;
+ },
+ subscribe(...args) {
+ return Meteor.subscribe(...args);
+ },
+ emitTyping(room, t = true) {
+ const { login } = reduxStore.getState();
+ return call('stream-notify-room', `${ room }/typing`, login.user.username, t);
}
};
diff --git a/app/reducers/index.js b/app/reducers/index.js
index 3321b848..15a063a6 100644
--- a/app/reducers/index.js
+++ b/app/reducers/index.js
@@ -3,6 +3,7 @@ import settings from './reducers';
import login from './login';
import meteor from './connect';
import messages from './messages';
+import room from './room';
import server from './server';
import navigator from './navigator';
import createChannel from './createChannel';
@@ -10,5 +11,5 @@ import app from './app';
export default combineReducers({
- settings, login, meteor, messages, server, navigator, createChannel, app
+ settings, login, meteor, messages, server, navigator, createChannel, app, room
});
diff --git a/app/reducers/room.js b/app/reducers/room.js
new file mode 100644
index 00000000..08b95b97
--- /dev/null
+++ b/app/reducers/room.js
@@ -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;
+ }
+}
diff --git a/app/sagas/messages.js b/app/sagas/messages.js
index 336f9026..b3d54db3 100644
--- a/app/sagas/messages.js
+++ b/app/sagas/messages.js
@@ -29,7 +29,6 @@ const get = function* get({ rid }) {
}
try {
yield RocketChat.loadMessagesForRoom(rid, null);
- yield RocketChat.readMessages(rid);
yield put(messagesSuccess());
} catch (err) {
console.log(err);
diff --git a/app/sagas/rooms.js b/app/sagas/rooms.js
index 472f3e5d..4a4f88ef 100644
--- a/app/sagas/rooms.js
+++ b/app/sagas/rooms.js
@@ -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 { roomsSuccess, roomsFailure } from '../actions/rooms';
+import { addUserTyping, removeUserTyping } from '../actions/room';
+import { messagesRequest } from '../actions/messages';
import RocketChat from '../lib/rocketchat';
const getRooms = function* getRooms() {
@@ -9,15 +12,86 @@ const getRooms = function* getRooms() {
const watchRoomsRequest = function* watchRoomsRequest() {
try {
- console.log('getRooms');
yield call(getRooms);
yield put(roomsSuccess());
} catch (err) {
- console.log(err);
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() {
- 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;
diff --git a/app/views/RoomView.js b/app/views/RoomView.js
index 41b02dcf..8faf1124 100644
--- a/app/views/RoomView.js
+++ b/app/views/RoomView.js
@@ -6,7 +6,7 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as actions from '../actions';
-import { messagesRequest } from '../actions/messages';
+import { openRoom } from '../actions/room';
import realm from '../lib/realm';
import RocketChat from '../lib/rocketchat';
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 styles = StyleSheet.create({
+ typing: { fontWeight: 'bold', paddingHorizontal: 15, height: 25 },
container: {
flex: 1,
backgroundColor: '#fff'
@@ -48,6 +49,8 @@ const styles = StyleSheet.create({
@connect(
state => ({
+ username: state.login.user.username,
+ usersTyping: state.room.usersTyping,
server: state.server.server,
Site_Url: state.settings.Site_Url,
Message_TimeFormat: state.settings.Message_TimeFormat,
@@ -55,20 +58,22 @@ const styles = StyleSheet.create({
}),
dispatch => ({
actions: bindActionCreators(actions, dispatch),
- getMessages: rid => dispatch(messagesRequest({ rid }))
+ openRoom: room => dispatch(openRoom(room))
})
)
export default class RoomView extends React.Component {
static propTypes = {
navigation: PropTypes.object.isRequired,
- getMessages: PropTypes.func.isRequired,
+ openRoom: PropTypes.func.isRequired,
rid: PropTypes.string,
sid: PropTypes.string,
name: PropTypes.string,
server: PropTypes.string,
Site_Url: PropTypes.string,
Message_TimeFormat: PropTypes.string,
- loading: PropTypes.bool
+ loading: PropTypes.bool,
+ usersTyping: PropTypes.array,
+ username: PropTypes.string
};
constructor(props) {
@@ -100,7 +105,7 @@ export default class RoomView extends React.Component {
realm.objectForPrimaryKey('subscriptions', this.sid).name
});
this.timer = setTimeout(() => this.setState({ slow: true }), 5000);
- this.props.getMessages(this.rid);
+ this.props.openRoom({ rid: this.rid });
this.data.addListener(this.updateState);
}
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 = () => {
this.setState({
@@ -189,8 +199,7 @@ export default class RoomView extends React.Component {
if (this.state.end) {
return Start of conversation;
}
- };
-
+ }
render() {
const { height } = Dimensions.get('window');
return (
@@ -209,6 +218,7 @@ export default class RoomView extends React.Component {
/>
{this.renderFooter()}
+ {this.usersTyping}
);
}
diff --git a/app/views/RoomsListView.js b/app/views/RoomsListView.js
index dde25bee..e81b4167 100644
--- a/app/views/RoomsListView.js
+++ b/app/views/RoomsListView.js
@@ -100,22 +100,20 @@ export default class RoomsListView extends React.Component {
super(props);
this.state = {
- dataSource: [],
+ dataSource: ds.cloneWithRows([]),
searchText: ''
};
this.data = realm.objects('subscriptions').filtered('_server.id = $0', this.props.server).sorted('_updatedAt', true);
}
- componentWillMount() {
+ componentDidMount() {
this.data.addListener(this.updateState);
this.props.navigation.setParams({
createChannel: () => this._createChannel()
});
- this.setState({
- dataSource: ds.cloneWithRows(this.data)
- });
+ this.updateState();
}
componentWillReceiveProps(props) {
diff --git a/package-lock.json b/package-lock.json
index bfe469d7..ffe97482 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1853,8 +1853,7 @@
"babel-plugin-transform-remove-console": {
"version": "6.8.5",
"resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.8.5.tgz",
- "integrity": "sha512-uuCKvtweCyIvvC8fi92EcWRtO2Kt5KMNMRK6BhpDXdeb3sxvGM7453RSmgeu4DlKns3OlvY9Ep5Q9m5a7RQAgg==",
- "dev": true
+ "integrity": "sha512-uuCKvtweCyIvvC8fi92EcWRtO2Kt5KMNMRK6BhpDXdeb3sxvGM7453RSmgeu4DlKns3OlvY9Ep5Q9m5a7RQAgg=="
},
"babel-plugin-transform-remove-debugger": {
"version": "6.8.5",