From 0ad3eedd4cf6d1d7f7ceacc2c4412dbd8ee79cef Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Sun, 27 May 2018 19:10:37 +0200 Subject: [Glitch] Allow clients to fetch statuses made while they were offline Port 9a1a55ce526c956ac6b35897d483c316b7ad4394 to glitch-soc --- app/javascript/flavours/glitch/actions/streaming.js | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) (limited to 'app/javascript/flavours/glitch/actions/streaming.js') diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index ae51e8349..8d623af75 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -2,8 +2,7 @@ import { connectStream } from 'flavours/glitch/util/stream'; import { updateTimeline, deleteFromTimelines, - refreshHomeTimeline, - connectTimeline, + expandHomeTimeline, disconnectTimeline, } from './timelines'; import { updateNotifications, refreshNotifications } from './notifications'; @@ -16,10 +15,6 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) return connectStream (path, pollingRefresh, (dispatch, getState) => { const locale = getState().getIn(['meta', 'locale']); return { - onConnect() { - dispatch(connectTimeline(timelineId)); - }, - onDisconnect() { dispatch(disconnectTimeline(timelineId)); }, @@ -42,7 +37,7 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) } function refreshHomeTimelineAndNotification (dispatch) { - dispatch(refreshHomeTimeline()); + dispatch(expandHomeTimeline()); dispatch(refreshNotifications()); } -- cgit From 4eba8c50c31d70f466a68d48445f86d644e4a889 Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Sun, 27 May 2018 19:30:52 +0200 Subject: [Glitch] Allow clients to fetch notifications made while they were offline Port cbf97c03bba35a642e6f1d1a698aad7a69ad13a3 to glitch-soc --- .../flavours/glitch/actions/notifications.js | 69 +--------------------- .../flavours/glitch/actions/streaming.js | 4 +- .../glitch/features/notifications/index.js | 48 ++++++++++++--- .../flavours/glitch/features/ui/index.js | 4 +- .../flavours/glitch/reducers/notifications.js | 68 ++++++++++----------- 5 files changed, 81 insertions(+), 112 deletions(-) (limited to 'app/javascript/flavours/glitch/actions/streaming.js') diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 0d52100b9..8185a4ee2 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -1,5 +1,4 @@ import api, { getLinks } from 'flavours/glitch/util/api'; -import { List as ImmutableList } from 'immutable'; import IntlMessageFormat from 'intl-messageformat'; import { fetchRelationships } from './accounts'; import { defineMessages } from 'react-intl'; @@ -18,10 +17,6 @@ export const NOTIFICATIONS_UNMARK_ALL_FOR_DELETE = 'NOTIFICATIONS_UNMARK_ALL_FOR // Mark one for delete export const NOTIFICATION_MARK_FOR_DELETE = 'NOTIFICATION_MARK_FOR_DELETE'; -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'; @@ -72,73 +67,15 @@ 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(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, - accounts: notifications.map(item => item.account), - statuses: notifications.map(item => item.status).filter(status => !!status), - 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()), }; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index 8d623af75..ac69c8b43 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -5,7 +5,7 @@ import { expandHomeTimeline, disconnectTimeline, } from './timelines'; -import { updateNotifications, refreshNotifications } from './notifications'; +import { updateNotifications, expandNotifications } from './notifications'; import { getLocale } from 'mastodon/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); diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js index c27d3ffc9..f1319af45 100644 --- a/app/javascript/flavours/glitch/features/notifications/index.js +++ b/app/javascript/flavours/glitch/features/notifications/index.js @@ -17,6 +17,7 @@ import { createSelector } from 'reselect'; import { List as ImmutableList } from 'immutable'; import { debounce } from 'lodash'; import ScrollableList from 'flavours/glitch/components/scrollable_list'; +import LoadMore from 'flavours/glitch/components/load_more'; const messages = defineMessages({ title: { id: 'column.notifications', defaultMessage: 'Notifications' }, @@ -25,14 +26,32 @@ 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 ; + } + +} const mapStateToProps = state => ({ notifications: getNotifications(state), localSettings: state.get('local_settings'), isLoading: state.getIn(['notifications', 'isLoading'], true), isUnread: state.getIn(['notifications', 'unread']) > 0, - hasMore: !!state.getIn(['notifications', 'next']), + hasMore: state.getIn(['notifications', 'hasMore']), notifCleaningActive: state.getIn(['notifications', 'cleaningMode']), }); @@ -67,9 +86,13 @@ export default class Notifications extends React.PureComponent { trackScroll: true, }; - handleLoadMore = debounce(() => { - this.props.dispatch(scrollTopNotifications(false)); - 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(() => { @@ -104,12 +127,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); } @@ -131,7 +154,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 ? ( + 0 ? notifications.getIn([index - 1, 'id']) : null} + onClick={this.handleLoadGap} + /> + ) : ( { }); }; -const normalizeNotifications = (state, notifications, next) => { - let items = ImmutableList(); - const loaded = state.get('loaded'); +const newer = (m, n) => { + const mId = m.get('id'); + const nId = n.get('id'); - notifications.forEach((n, i) => { - items = items.set(i, notificationToMap(state, n)); - }); - - if (state.get('next') === null) { - state = state.set('next', next); - } - - return state - .update('items', list => loaded ? items.concat(list) : list.concat(items)) - .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(state, 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) => { @@ -137,29 +139,29 @@ export default function notifications(state = initialState, action) { let st; switch(action.type) { - case NOTIFICATIONS_REFRESH_REQUEST: case NOTIFICATIONS_EXPAND_REQUEST: case NOTIFICATIONS_DELETE_MARKED_REQUEST: return state.set('isLoading', true); case NOTIFICATIONS_DELETE_MARKED_FAIL: - 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; case NOTIFICATION_MARK_FOR_DELETE: return markForDelete(state, action.id, action.yes); -- cgit From cc589d6ec06e562c0bf43c415f816b2b922ebd0c Mon Sep 17 00:00:00 2001 From: Thibaut Girka Date: Sun, 27 May 2018 19:42:45 +0200 Subject: [Glitch] Use randomized setTimeout when fallback-polling and re-add since_id Port dafd7afc5ef94fc9513efa341910f7e3c31b909a to glitch-soc --- .../flavours/glitch/actions/notifications.js | 15 +++++++++++-- .../flavours/glitch/actions/streaming.js | 7 +++--- .../flavours/glitch/actions/timelines.js | 25 +++++++++++++++------- app/javascript/flavours/glitch/util/stream.js | 15 ++++++++----- 4 files changed, 43 insertions(+), 19 deletions(-) (limited to 'app/javascript/flavours/glitch/actions/streaming.js') diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js index 8185a4ee2..68c46a732 100644 --- a/app/javascript/flavours/glitch/actions/notifications.js +++ b/app/javascript/flavours/glitch/actions/notifications.js @@ -68,9 +68,14 @@ export function updateNotifications(notification, intlMessages, intlLocale) { const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); -export function expandNotifications({ maxId } = {}) { +const noOp = () => {}; + +export function expandNotifications({ maxId } = {}, done = noOp) { return (dispatch, getState) => { - if (getState().getIn(['notifications', 'isLoading'])) { + const notifications = getState().get('notifications'); + + if (notifications.get('isLoading')) { + done(); return; } @@ -79,14 +84,20 @@ export function expandNotifications({ maxId } = {}) { exclude_types: excludeTypesFromSettings(getState()), }; + if (!maxId && notifications.get('items').size > 0) { + params.since_id = notifications.getIn(['items', 0]); + } + dispatch(expandNotificationsRequest()); api(getState).get('/api/v1/notifications', { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null)); fetchRelatedRelationships(dispatch, response.data); + done(); }).catch(error => { dispatch(expandNotificationsFail(error)); + done(); }); }; }; diff --git a/app/javascript/flavours/glitch/actions/streaming.js b/app/javascript/flavours/glitch/actions/streaming.js index ac69c8b43..6e34d0be6 100644 --- a/app/javascript/flavours/glitch/actions/streaming.js +++ b/app/javascript/flavours/glitch/actions/streaming.js @@ -36,10 +36,9 @@ export function connectTimelineStream (timelineId, path, pollingRefresh = null) }); } -function refreshHomeTimelineAndNotification (dispatch) { - dispatch(expandHomeTimeline()); - dispatch(expandNotifications()); -} +const refreshHomeTimelineAndNotification = (dispatch, done) => { + dispatch(expandHomeTimeline({}, () => dispatch(expandNotifications({}, done)))); +}; export const connectUserStream = () => connectTimelineStream('home', 'user', refreshHomeTimelineAndNotification); export const connectCommunityStream = () => connectTimelineStream('community', 'public:local'); diff --git a/app/javascript/flavours/glitch/actions/timelines.js b/app/javascript/flavours/glitch/actions/timelines.js index 06f422be1..9597fe89d 100644 --- a/app/javascript/flavours/glitch/actions/timelines.js +++ b/app/javascript/flavours/glitch/actions/timelines.js @@ -1,5 +1,5 @@ import api, { getLinks } from 'flavours/glitch/util/api'; -import { Map as ImmutableMap } from 'immutable'; +import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; export const TIMELINE_UPDATE = 'TIMELINE_UPDATE'; export const TIMELINE_DELETE = 'TIMELINE_DELETE'; @@ -61,34 +61,43 @@ export function deleteFromTimelines(id) { }; }; -export function expandTimeline(timelineId, path, params = {}) { +const noOp = () => {}; + +export function expandTimeline(timelineId, path, params = {}, done = noOp) { return (dispatch, getState) => { const timeline = getState().getIn(['timelines', timelineId], ImmutableMap()); if (timeline.get('isLoading')) { + done(); return; } + if (!params.max_id && timeline.get('items', ImmutableList()).size > 0) { + params.since_id = timeline.getIn(['items', 0]); + } + dispatch(expandTimelineRequest(timelineId)); api(getState).get(path, { params }).then(response => { const next = getLinks(response).refs.find(link => link.rel === 'next'); dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206)); + done(); }).catch(error => { dispatch(expandTimelineFail(timelineId, error)); + done(); }); }; }; -export const expandHomeTimeline = ({ maxId } = {}) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }); -export const expandPublicTimeline = ({ maxId } = {}) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }); -export const expandCommunityTimeline = ({ maxId } = {}) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }); -export const expandDirectTimeline = ({ maxId } = {}) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }); +export const expandHomeTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('home', '/api/v1/timelines/home', { max_id: maxId }, done); +export const expandPublicTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('public', '/api/v1/timelines/public', { max_id: maxId }, done); +export const expandCommunityTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('community', '/api/v1/timelines/public', { local: true, max_id: maxId }, done); +export const expandDirectTimeline = ({ maxId } = {}, done = noOp) => expandTimeline('direct', '/api/v1/timelines/direct', { max_id: maxId }, done); export const expandAccountTimeline = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId }); export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true }); export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true }); -export const expandHashtagTimeline = (hashtag, { maxId } = {}) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }); -export const expandListTimeline = (id, { maxId } = {}) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }); +export const expandHashtagTimeline = (hashtag, { maxId } = {}, done = noOp) => expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, { max_id: maxId }, done); +export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done); export function expandTimelineRequest(timeline) { return { diff --git a/app/javascript/flavours/glitch/util/stream.js b/app/javascript/flavours/glitch/util/stream.js index 6c67ba275..9928d0dd7 100644 --- a/app/javascript/flavours/glitch/util/stream.js +++ b/app/javascript/flavours/glitch/util/stream.js @@ -1,21 +1,24 @@ import WebSocketClient from 'websocket.js'; +const randomIntUpTo = max => Math.floor(Math.random() * Math.floor(max)); + export function connectStream(path, pollingRefresh = null, callbacks = () => ({ onDisconnect() {}, onReceive() {} })) { return (dispatch, getState) => { const streamingAPIBaseURL = getState().getIn(['meta', 'streaming_api_base_url']); const accessToken = getState().getIn(['meta', 'access_token']); const { onDisconnect, onReceive } = callbacks(dispatch, getState); + let polling = null; const setupPolling = () => { - polling = setInterval(() => { - pollingRefresh(dispatch); - }, 20000); + pollingRefresh(dispatch, () => { + polling = setTimeout(() => setupPolling(), 20000 + randomIntUpTo(20000)); + }); }; const clearPolling = () => { if (polling) { - clearInterval(polling); + clearTimeout(polling); polling = null; } }; @@ -29,8 +32,9 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ disconnected () { if (pollingRefresh) { - setupPolling(); + polling = setTimeout(() => setupPolling(), randomIntUpTo(40000)); } + onDisconnect(); }, @@ -51,6 +55,7 @@ export function connectStream(path, pollingRefresh = null, callbacks = () => ({ if (subscription) { subscription.close(); } + clearPolling(); }; -- cgit