Skip to content
Snippets Groups Projects
Commit cbf97c03 authored by Akihiko Odaki's avatar Akihiko Odaki Committed by Eugen Rochko
Browse files

Allow clients to fetch notifications made while they were offline (#6886)

parent 9a1a55ce
No related branches found
No related tags found
No related merge requests found
import api, { getLinks } from '../api';
import { List as ImmutableList } from 'immutable';
import IntlMessageFormat from 'intl-messageformat';
import { fetchRelationships } from './accounts';
import {
......@@ -12,10 +11,6 @@ import { defineMessages } from 'react-intl';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_REFRESH_REQUEST = 'NOTIFICATIONS_REFRESH_REQUEST';
export const NOTIFICATIONS_REFRESH_SUCCESS = 'NOTIFICATIONS_REFRESH_SUCCESS';
export const NOTIFICATIONS_REFRESH_FAIL = 'NOTIFICATIONS_REFRESH_FAIL';
export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
export const NOTIFICATIONS_EXPAND_FAIL = 'NOTIFICATIONS_EXPAND_FAIL';
......@@ -74,74 +69,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
export function refreshNotifications() {
return (dispatch, getState) => {
const params = {};
const ids = getState().getIn(['notifications', 'items']);
let skipLoading = false;
if (ids.size > 0) {
params.since_id = ids.first().get('id');
}
if (getState().getIn(['notifications', 'loaded'])) {
skipLoading = true;
}
params.exclude_types = excludeTypesFromSettings(getState());
dispatch(refreshNotificationsRequest(skipLoading));
api(getState).get('/api/v1/notifications', { params }).then(response => {
const next = getLinks(response).refs.find(link => link.rel === 'next');
dispatch(importFetchedAccounts(response.data.map(item => item.account)));
dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
dispatch(refreshNotificationsSuccess(response.data, skipLoading, next ? next.uri : null));
fetchRelatedRelationships(dispatch, response.data);
}).catch(error => {
dispatch(refreshNotificationsFail(error, skipLoading));
});
};
};
export function refreshNotificationsRequest(skipLoading) {
return {
type: NOTIFICATIONS_REFRESH_REQUEST,
skipLoading,
};
};
export function refreshNotificationsSuccess(notifications, skipLoading, next) {
return {
type: NOTIFICATIONS_REFRESH_SUCCESS,
notifications,
skipLoading,
next,
};
};
export function refreshNotificationsFail(error, skipLoading) {
return {
type: NOTIFICATIONS_REFRESH_FAIL,
error,
skipLoading,
};
};
export function expandNotifications() {
export function expandNotifications({ maxId } = {}) {
return (dispatch, getState) => {
const items = getState().getIn(['notifications', 'items'], ImmutableList());
if (getState().getIn(['notifications', 'isLoading']) || items.size === 0) {
if (getState().getIn(['notifications', 'isLoading'])) {
return;
}
const params = {
max_id: items.last().get('id'),
limit: 20,
max_id: maxId,
exclude_types: excludeTypesFromSettings(getState()),
};
......
......@@ -5,7 +5,7 @@ import {
expandHomeTimeline,
disconnectTimeline,
} from './timelines';
import { updateNotifications, refreshNotifications } from './notifications';
import { updateNotifications, expandNotifications } from './notifications';
import { getLocale } from '../locales';
const { messages } = getLocale();
......@@ -38,7 +38,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null)
function refreshHomeTimelineAndNotification (dispatch) {
dispatch(expandHomeTimeline());
dispatch(refreshNotifications());
dispatch(expandNotifications());
}
export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification);
......
......@@ -13,6 +13,7 @@ import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash';
import ScrollableList from '../../components/scrollable_list';
import LoadMore from '../../components/load_more';
const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' },
......@@ -21,13 +22,31 @@ const messages = defineMessages({
const getNotifications = createSelector([
state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
state => state.getIn(['notifications', 'items']),
], (excludedTypes, notifications) => notifications.filterNot(item => excludedTypes.includes(item.get('type'))));
], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))));
class LoadGap extends React.PureComponent {
static propTypes = {
disabled: PropTypes.bool,
maxId: PropTypes.string,
onClick: PropTypes.func.isRequired,
};
handleClick = () => {
this.props.onClick(this.props.maxId);
}
render () {
return <LoadMore onClick={this.handleClick} disabled={this.props.disabled} />;
}
}
const mapStateToProps = state => ({
notifications: getNotifications(state),
isLoading: state.getIn(['notifications', 'isLoading'], true),
isUnread: state.getIn(['notifications', 'unread']) > 0,
hasMore: !!state.getIn(['notifications', 'next']),
hasMore: state.getIn(['notifications', 'hasMore']),
});
@connect(mapStateToProps)
......@@ -51,14 +70,19 @@ export default class Notifications extends React.PureComponent {
};
componentWillUnmount () {
this.handleLoadMore.cancel();
this.handleLoadOlder.cancel();
this.handleScrollToTop.cancel();
this.handleScroll.cancel();
this.props.dispatch(scrollTopNotifications(false));
}
handleLoadMore = debounce(() => {
this.props.dispatch(expandNotifications());
handleLoadGap = (maxId) => {
this.props.dispatch(expandNotifications({ maxId }));
};
handleLoadOlder = debounce(() => {
const last = this.props.notifications.last();
this.props.dispatch(expandNotifications({ maxId: last && last.get('id') }));
}, 300, { leading: true });
handleScrollToTop = debounce(() => {
......@@ -93,12 +117,12 @@ export default class Notifications extends React.PureComponent {
}
handleMoveUp = id => {
const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) - 1;
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
this._selectChild(elementIndex);
}
handleMoveDown = id => {
const elementIndex = this.props.notifications.findIndex(item => item.get('id') === id) + 1;
const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
this._selectChild(elementIndex);
}
......@@ -120,7 +144,14 @@ export default class Notifications extends React.PureComponent {
if (isLoading && this.scrollableContent) {
scrollableContent = this.scrollableContent;
} else if (notifications.size > 0 || hasMore) {
scrollableContent = notifications.map((item) => (
scrollableContent = notifications.map((item, index) => item === null ? (
<LoadGap
key={'gap:' + notifications.getIn([index + 1, 'id'])}
disabled={isLoading}
maxId={index > 0 ? notifications.getIn([index - 1, 'id']) : null}
onClick={this.handleLoadGap}
/>
) : (
<NotificationContainer
key={item.get('id')}
notification={item}
......@@ -142,7 +173,7 @@ export default class Notifications extends React.PureComponent {
isLoading={isLoading}
hasMore={hasMore}
emptyMessage={emptyMessage}
onLoadMore={this.handleLoadMore}
onLoadMore={this.handleLoadOlder}
onScrollToTop={this.handleScrollToTop}
onScroll={this.handleScroll}
shouldUpdateScroll={shouldUpdateScroll}
......
......@@ -11,7 +11,7 @@ import { isMobile } from '../../is_mobile';
import { debounce } from 'lodash';
import { uploadCompose, resetCompose } from '../../actions/compose';
import { expandHomeTimeline } from '../../actions/timelines';
import { refreshNotifications } from '../../actions/notifications';
import { expandNotifications } from '../../actions/notifications';
import { clearHeight } from '../../actions/height_cache';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area';
......@@ -285,7 +285,7 @@ export default class UI extends React.PureComponent {
}
this.props.dispatch(expandHomeTimeline());
this.props.dispatch(refreshNotifications());
this.props.dispatch(expandNotifications());
}
componentDidMount () {
......
import {
NOTIFICATIONS_UPDATE,
NOTIFICATIONS_REFRESH_SUCCESS,
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_REFRESH_REQUEST,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_REFRESH_FAIL,
NOTIFICATIONS_EXPAND_FAIL,
NOTIFICATIONS_CLEAR,
NOTIFICATIONS_SCROLL_TOP,
......@@ -13,16 +10,15 @@ import {
ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS,
} from '../actions/accounts';
import { TIMELINE_DELETE } from '../actions/timelines';
import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
const initialState = ImmutableMap({
items: ImmutableList(),
next: null,
hasMore: true,
top: true,
unread: 0,
loaded: false,
isLoading: true,
isLoading: false,
});
const notificationToMap = notification => ImmutableMap({
......@@ -48,35 +44,41 @@ const normalizeNotification = (state, notification) => {
});
};
const normalizeNotifications = (state, notifications, next) => {
let newItems = ImmutableList();
const loaded = state.get('loaded');
const newer = (m, n) => {
const mId = m.get('id');
const nId = n.get('id');
notifications.forEach((n, i) => {
newItems = newItems.set(i, notificationToMap(n));
});
if (state.get('next') === null) {
state = state.set('next', next);
}
return state
.update('items', oldItems => loaded ? newItems.concat(oldItems) : oldItems.concat(newItems))
.set('loaded', true)
.set('isLoading', false);
return mId.length === nId.length ? mId > nId : mId.length > nId.length;
};
const appendNormalizedNotifications = (state, notifications, next) => {
const expandNormalizedNotifications = (state, notifications, next) => {
let items = ImmutableList();
notifications.forEach((n, i) => {
items = items.set(i, notificationToMap(n));
});
return state
.update('items', list => list.concat(items))
.set('next', next)
.set('isLoading', false);
return state.withMutations(mutable => {
if (!items.isEmpty()) {
mutable.update('items', list => {
const lastIndex = 1 + list.findLastIndex(
item => item !== null && (newer(item, items.last()) || item.get('id') === items.last().get('id'))
);
const firstIndex = 1 + list.take(lastIndex).findLastIndex(
item => item !== null && newer(item, items.first())
);
return list.take(firstIndex).concat(items, list.skip(lastIndex));
});
}
if (!next) {
mutable.set('hasMore', true);
}
mutable.set('isLoading', false);
});
};
const filterNotifications = (state, relationship) => {
......@@ -97,27 +99,27 @@ const deleteByStatus = (state, statusId) => {
export default function notifications(state = initialState, action) {
switch(action.type) {
case NOTIFICATIONS_REFRESH_REQUEST:
case NOTIFICATIONS_EXPAND_REQUEST:
return state.set('isLoading', true);
case NOTIFICATIONS_REFRESH_FAIL:
case NOTIFICATIONS_EXPAND_FAIL:
return state.set('isLoading', false);
case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top);
case NOTIFICATIONS_UPDATE:
return normalizeNotification(state, action.notification);
case NOTIFICATIONS_REFRESH_SUCCESS:
return normalizeNotifications(state, action.notifications, action.next);
case NOTIFICATIONS_EXPAND_SUCCESS:
return appendNormalizedNotifications(state, action.notifications, action.next);
return expandNormalizedNotifications(state, action.notifications, action.next);
case ACCOUNT_BLOCK_SUCCESS:
case ACCOUNT_MUTE_SUCCESS:
return filterNotifications(state, action.relationship);
case NOTIFICATIONS_CLEAR:
return state.set('items', ImmutableList()).set('next', null);
return state.set('items', ImmutableList()).set('hasMore', false);
case TIMELINE_DELETE:
return deleteByStatus(state, action.id);
case TIMELINE_DISCONNECT:
return action.timeline === 'home' ?
state.update('items', items => items.first() ? items.unshift(null) : items) :
state;
default:
return state;
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment