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",