@@ -1,5 +1,4 @@ | |||
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 () { | |||
@@ -1,10 +1,7 @@ | |||
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; | |||
} | |||