Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: lazy loading history #301

Merged
merged 5 commits into from
Oct 28, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
Expand Down Expand Up @@ -126,6 +128,7 @@ export default class Application extends React.Component {
<Messages
docked={this.state.sidebarDocked}
messages={messages}
currentChannel={this.props.currentChannel}
local={local}
language={this.props.local.get('language') ? this.props.local.get('language') : 'en' }
{...actions}
Expand Down
17 changes: 17 additions & 0 deletions app/actions/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ export function joinToChannel(channelId) {
}


export function loadChannelHistory(payload) {
return {
type: CS.LOAD_CHANNEL_HISTORY,
payload: payload,
send: true,
};
}


export function setLoadingStatus(payload) {
return {
type: A.SET_LOADING_STATUS,
payload: payload,
};
}


export function replaceDirtyChannel(channel) {
return {
type: A.REPLACE_DIRTY_CHANNEL,
Expand Down
4 changes: 2 additions & 2 deletions app/actions/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ export function addMessage(message) {
}


export function loadChannelHistory(data) {
export function setChannelHistory(data) {
return {
type: A.LOAD_CHANNEL_HISTORY,
type: A.SET_CHANNEL_HISTORY,
payload: data,
};
}
Expand Down
52 changes: 51 additions & 1 deletion app/components/Messages/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ export default class Messages extends React.Component {
static propTypes = {
messages: PropTypes.instanceOf(List).isRequired,
local: PropTypes.instanceOf(Map).isRequired,
currentChannel: PropTypes.instanceOf(Map),
docked: PropTypes.bool.isRequired,
language: PropTypes.string.isRequired,
loadChannelHistory: PropTypes.func.isRequired,
}


Expand All @@ -25,6 +27,18 @@ export default class Messages extends React.Component {

componentDidMount = () => {
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) {
Expand All @@ -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;
}
}


Expand Down
18 changes: 11 additions & 7 deletions app/core/socket.js
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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})));
});


Expand All @@ -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));
});


Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions app/reducers/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions app/reducers/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 5 additions & 0 deletions app/selectors/channelsSelector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'))
);
4 changes: 3 additions & 1 deletion app/selectors/messagesSelector.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 5 additions & 5 deletions app/tests/reducers/messages_spec.js
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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(
Expand All @@ -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);
});
Expand Down
4 changes: 3 additions & 1 deletion constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
};
Expand All @@ -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 = {
Expand Down
10 changes: 8 additions & 2 deletions server/db/db_core.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
5 changes: 1 addition & 4 deletions server/initial-state.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,15 @@
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) => {
const state = {};
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) => {
Expand All @@ -35,7 +33,6 @@ export default function getInitState(sessionId) {
});

state.channels = channelObjects;
state.messages = messages.map((message) => message.toObject());
state.local = {
userId,
sessionId,
Expand Down
17 changes: 11 additions & 6 deletions server/socket.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
});
});
});

Expand Down