diff --git a/app/App.jsx b/app/App.jsx index 3e05229..ab282e1 100644 --- a/app/App.jsx +++ b/app/App.jsx @@ -19,12 +19,13 @@ import {contactsSelector} from 'selectors/contactsSelector'; import i18nMessages from 'i18n/index'; import DocumentTitle from 'react-document-title'; import {localSelector} from 'selectors/localSelector'; -import {indirectChannelsSelector} from 'selectors/channelsSelector'; +import {indirectChannelsSelector, currentChannelsSelector} from 'selectors/channelsSelector'; import {directChannelsSelector} from 'selectors/directChannelsSelector'; @connect(state => ({ messages: messageFilterSelector(state), channels: state.channels, + currentChannel: currentChannelsSelector(state), users: state.users, local: localSelector(state), contacts: contactsSelector(state), @@ -37,6 +38,7 @@ export default class Application extends React.Component { channels: PropTypes.instanceOf(List).isRequired, users: PropTypes.instanceOf(List).isRequired, contacts: PropTypes.instanceOf(List).isRequired, + currentChannel: PropTypes.instanceOf(Map), local: PropTypes.instanceOf(Map).isRequired, dispatch: PropTypes.func.isRequired, children: PropTypes.node, @@ -126,6 +128,7 @@ export default class Application extends React.Component { { this.baseTextareaHeight = null; + this.attachScrollListener(); + } + + + componentWillReceiveProps = nextProps => { + // Load initial data only once, and then rely on loading more data on scroll + if ( + nextProps.currentChannel && + !nextProps.currentChannel.get('loadingStatus') + ) { + nextProps.loadChannelHistory({channelId: nextProps.currentChannel.get('id'), baseDate: nextProps.currentChannel.get('loadingStatus')}); + } } shouldComponentUpdate(nextProps, nextState) { @@ -37,9 +51,45 @@ export default class Messages extends React.Component { ); } + componentWillUnmount() { + this.detachScrollListener(); + } + + scrollListener = () => { + const list = this.refs.list; + if (list.scrollTop === 0) { + this.loadMoreHistory(); + } + } + loadMoreHistory = () => { + if ( + this.props.currentChannel && + this.props.currentChannel.get('loadingStatus') !== 'END' && + this.props.currentChannel.get('loadingStatus') !== 'LOADING' + ) { + // Prevent scroll jump + this.skipNextScroll = true; + this.props.loadChannelHistory({channelId: this.props.currentChannel.get('id'), baseDate: this.props.currentChannel.get('loadingStatus')}); + } + } + attachScrollListener = () => { + const list = this.refs.list; + list.addEventListener('scroll', this.scrollListener); + this.scrollListener(); + } + detachScrollListener = () => { + const list = this.refs.list; + list.removeEventListener('scroll', this.scrollListener); + } + + scrollToBottom = () => { const list = this.refs.list; - list.scrollTop = list.scrollHeight; + if (this.skipNextScroll) { + this.skipNextScroll = false; + } else { + list.scrollTop = list.scrollHeight; + } } diff --git a/app/core/socket.js b/app/core/socket.js index b037c30..7b2a2be 100644 --- a/app/core/socket.js +++ b/app/core/socket.js @@ -1,8 +1,8 @@ import io from 'socket.io-client'; import store from '../store'; import {Map} from 'immutable'; -import {addChannel, addUserToChannel} from '../actions/channels'; -import {addMessage, loadChannelHistory} from '../actions/messages'; +import * as channelActions from '../actions/channels'; +import * as messageActions from '../actions/messages'; import {setUserInfo, joinUser} from 'actions/users'; import {init, initUser, logOut} from '../actions/local'; import {SC} from '../../constants'; @@ -13,12 +13,12 @@ export function socketClient(type = null, socketData) { if (type === 'SOCKET_INIT') { socket.on(SC.ADD_MESSAGE, (data) => { - store.dispatch(addMessage(Map(data))); + store.dispatch(messageActions.addMessage(Map(data))); }); socket.on(SC.ADD_CHANNEL, (data) => { - store.dispatch(addChannel(Map({id: data.id, name: data.name, joined: false}))); + store.dispatch(channelActions.addChannel(Map({id: data.id, name: data.name, joined: false}))); }); @@ -38,7 +38,7 @@ export function socketClient(type = null, socketData) { socket.on(SC.JOIN_TO_CHANNEL, (data) => { - store.dispatch(addUserToChannel(data)); + store.dispatch(channelActions.addUserToChannel(data)); }); @@ -48,12 +48,16 @@ export function socketClient(type = null, socketData) { socket.on(SC.SET_CHANNEL_HISTORY, (data) => { - store.dispatch(loadChannelHistory(data)); + store.dispatch(messageActions.setChannelHistory(data)); + // When we receive less messages then expected, it means that there are no + // more messages on the server for this channel, so we should should stop waiting. + const status = data.messages.length === 20 ? data.messages[data.messages.length - 1].timestamp : 'END'; + store.dispatch(channelActions.setLoadingStatus({channelId: data.messages[data.messages.length - 1].channelId, status})); }); socket.on(SC.ADD_DIRECT_CHANNEL, (data) => { - store.dispatch(addChannel(Map({ + store.dispatch(channelActions.addChannel(Map({ id: data.id, name: data.name, users: data.users, diff --git a/app/reducers/channels.js b/app/reducers/channels.js index 3325252..5f3e21f 100644 --- a/app/reducers/channels.js +++ b/app/reducers/channels.js @@ -54,6 +54,13 @@ export function channels(state = EMPTY_LIST, action = {type: 'DEFAULT'}) { case A.REMOVE_DIRTY_DIRECT_CHANNEL: return state.shift(); + case CS.LOAD_CHANNEL_HISTORY: + const channelIndex2 = state.map(item => item.get('id')).indexOf(action.payload.channelId); + return state.setIn([channelIndex2, 'loadingStatus'], 'LOADING'); + case A.SET_LOADING_STATUS: + const channelIndex3 = state.map(item => item.get('id')).indexOf(action.payload.channelId); + return state.setIn([channelIndex3, 'loadingStatus'], action.payload.status); + default: return state; } diff --git a/app/reducers/messages.js b/app/reducers/messages.js index cf8fdba..6ebd099 100644 --- a/app/reducers/messages.js +++ b/app/reducers/messages.js @@ -6,12 +6,12 @@ const EMPTY_LIST = List(); export function messages(state = EMPTY_LIST, action) { switch (action.type) { case A.INIT: - return fromJS(action.payload.messages); + return EMPTY_LIST; case A.ADD_MESSAGE: return state.push(action.message); case A.LOG_OUT: return EMPTY_LIST; - case A.LOAD_CHANNEL_HISTORY: + case A.SET_CHANNEL_HISTORY: const newMessages = fromJS(action.payload.messages) .filter(message => !state.find(m => m.get('id') === message.get('id'))); return state.concat(newMessages); diff --git a/app/selectors/channelsSelector.js b/app/selectors/channelsSelector.js index c7c620e..e16f47b 100644 --- a/app/selectors/channelsSelector.js +++ b/app/selectors/channelsSelector.js @@ -4,3 +4,8 @@ export const indirectChannelsSelector = createSelector( [state => state, state => state.channels], (state, channels) => channels.filter(c => !c.get('isDirect')) ); + +export const currentChannelsSelector = createSelector( + [state => state, state => state.channels], + (state, channels) => channels.find(c => c.get('id') === state.local.get('currentChannelId')) +); diff --git a/app/selectors/messagesSelector.js b/app/selectors/messagesSelector.js index 8e4f352..87ad658 100644 --- a/app/selectors/messagesSelector.js +++ b/app/selectors/messagesSelector.js @@ -9,7 +9,9 @@ const messagesSelector = state => const currentChannelMessagesSelector = createSelector( [state => state, messagesSelector], - (state, messages) => messages.filter(m => m.get('channelId') === state.local.get('currentChannelId')) + (state, messages) => messages + .filter(m => m.get('channelId') === state.local.get('currentChannelId')) + .sort((a, b) => new Date(a.get('timestamp')) - new Date(b.get('timestamp'))) ); const filterValue = state => state.messagesFilterValue; diff --git a/app/tests/reducers/messages_spec.js b/app/tests/reducers/messages_spec.js index 6a5a1a6..a313926 100644 --- a/app/tests/reducers/messages_spec.js +++ b/app/tests/reducers/messages_spec.js @@ -1,7 +1,7 @@ import {List, Map} from 'immutable'; import {expect} from 'chai'; import {messages} from '../../reducers/messages'; -import {addMessage, loadChannelHistory} from '../../actions/messages'; +import {addMessage, setChannelHistory} from '../../actions/messages'; describe('messages reducer', () => { it('handles ADD_MESSAGE', () => { @@ -18,12 +18,12 @@ describe('messages reducer', () => { ); }); - it('handles LOAD_CHANNEL_HISTORY', () => { + it('handles SET_CHANNEL_HISTORY', () => { const initialState = List.of( Map({id: 123, channelId: 0, text: 'first message'}), Map({id: 124, channelId: 0, text: 'second message'}), ); - const nextState = messages(initialState, loadChannelHistory({ messages: [{id: 125, channelId: 0, text: 'third message'}] })); + const nextState = messages(initialState, setChannelHistory({ messages: [{id: 125, channelId: 0, text: 'third message'}] })); expect(nextState).to.equal( List.of( @@ -34,12 +34,12 @@ describe('messages reducer', () => { ); }); - it('LOAD_CHANNEL_HISTORY shouldn\'t add the existing messages', () => { + it('SET_CHANNEL_HISTORY shouldn\'t add the existing messages', () => { const initialState = List.of( Map({id: 123, channelId: 0, text: 'first message'}), Map({id: 124, channelId: 0, text: 'second message'}), ); - const nextState = messages(initialState, loadChannelHistory({ messages: [{id: 124, channelId: 0, text: 'second message'}] })); + const nextState = messages(initialState, setChannelHistory({ messages: [{id: 124, channelId: 0, text: 'second message'}] })); expect(nextState).to.equal(initialState); }); diff --git a/constants.js b/constants.js index 5e29566..1d1a9f6 100644 --- a/constants.js +++ b/constants.js @@ -20,6 +20,7 @@ const CS = { JOIN_TO_CHANNEL: 'CS_JOIN_TO_CHANNEL', TYPING: 'CS_TYPING', CHANGE_USER_INFO: 'CS_CHANGE_USER_INFO', + LOAD_CHANNEL_HISTORY: 'CS_LOAD_CHANNEL_HISTORY', MARK_AS_READ: 'CS_MARK_AS_READ', ADD_DIRECT_CHANNEL: 'CS_ADD_DIRECT_CHANNEL', }; @@ -42,9 +43,10 @@ const A = { REMOVE_DIRTY_CHANNEL: 'REMOVE_DIRTY_CHANNEL', TYPING: 'TYPING', CHANGE_USER_INFO: 'CHANGE_USER_INFO', - LOAD_CHANNEL_HISTORY: 'LOAD_CHANNEL_HISTORY', + SET_CHANNEL_HISTORY: 'SET_CHANNEL_HISTORY', JOIN_USER: 'JOIN_USER', CHANGE_MESSAGE_FILTER_VALUE: 'CHANGE_MESSAGE_FILTER_VALUE', + SET_LOADING_STATUS: 'SET_LOADING_STATUS', }; module.exports = { diff --git a/server/db/db_core.js b/server/db/db_core.js index 2fab239..26ac3b4 100644 --- a/server/db/db_core.js +++ b/server/db/db_core.js @@ -168,8 +168,14 @@ export function setUserInfo(sessionId, email, name, language, callback) { } -export function loadChannelHistory(channelId, callback) { - return Message.find({ channelId }, (error, messages) => { +export function loadChannelHistory(channelId, baseDate = null, callback) { + const date = baseDate ? new Date(baseDate) : new Date(); + return Message.find({ + channelId, + timestamp: { + $lt: date, + }, + }).sort('-timestamp').limit(20).exec((error, messages) => { if (error) debug(error); callback(messages); }); diff --git a/server/initial-state.js b/server/initial-state.js index 2f79d68..a45357c 100644 --- a/server/initial-state.js +++ b/server/initial-state.js @@ -1,9 +1,7 @@ import getChannelModel from './models/channel'; -import getMessageModel from './models/message'; import getUserModel from './models/user'; const User = getUserModel(); const Channel = getChannelModel(); -const Message = getMessageModel(); export default function getInitState(sessionId) { return new Promise((resolve, reject) => { @@ -11,7 +9,7 @@ export default function getInitState(sessionId) { User.getBySessionId(sessionId) .then(user => Channel.getChannelsByUserId(user._id)) .then(channels => { - Promise.all([Message.getForChannels(channels.map(c => c._id)), User.getAll(), User.findOne({sessionId}).select({sessionId: 1, language: 1}), Channel.getDefaultChannel()]).then(([messages, users, currentUser, defaultChannel]) => { + Promise.all([User.getAll(), User.findOne({sessionId}).select({sessionId: 1, language: 1}), Channel.getDefaultChannel()]).then(([users, currentUser, defaultChannel]) => { const userId = currentUser.id; const channelObjects = channels.map((channel) => { @@ -35,7 +33,6 @@ export default function getInitState(sessionId) { }); state.channels = channelObjects; - state.messages = messages.map((message) => message.toObject()); state.local = { userId, sessionId, diff --git a/server/socket.js b/server/socket.js index e0f4f7e..c06190f 100644 --- a/server/socket.js +++ b/server/socket.js @@ -50,17 +50,22 @@ export default function startSocketServer(http) { }); + socket.on(CS.LOAD_CHANNEL_HISTORY, payload => { + const {channelId, baseDate} = payload; + loadChannelHistory(channelId, baseDate, (messages) => { + if (messages && messages.length) { + const messagesObj = messages.map((message) => message.toObject()); + socket.emit(SC.SET_CHANNEL_HISTORY, { messages: messagesObj }); + } + }); + }); + + socket.on(CS.JOIN_TO_CHANNEL, channelId => { if (!validate(channelId).isString().match(/^[0-9a-fA-F]{24}$/).end()) return; joinToChannel(socket.sessionId, channelId, (userId) => { socket.join(channelId); socket.emit(SC.JOIN_TO_CHANNEL, {channelId, userId}); - loadChannelHistory(channelId, (messages) => { - if (messages.length) { - const messagesObj = messages.map((message) => message.toObject()); - socket.emit(SC.SET_CHANNEL_HISTORY, { messages: messagesObj }); - } - }); }); });