Add option to disable real-time updates in web UI (#9984)
Fix #9031 Fix #7913
This commit is contained in:
		| @@ -55,6 +55,7 @@ class Settings::PreferencesController < Settings::BaseController | ||||
|       :setting_show_application, | ||||
|       :setting_advanced_layout, | ||||
|       :setting_use_blurhash, | ||||
|       :setting_use_pending_items, | ||||
|       notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account), | ||||
|       interactions: %i(must_be_follower must_be_following must_be_following_dm) | ||||
|     ) | ||||
|   | ||||
| @@ -12,6 +12,8 @@ import { defineMessages } from 'react-intl'; | ||||
| import { List as ImmutableList } from 'immutable'; | ||||
| import { unescapeHTML } from '../utils/html'; | ||||
| import { getFiltersRegex } from '../selectors'; | ||||
| import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; | ||||
| import compareId from 'mastodon/compare_id'; | ||||
|  | ||||
| export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE'; | ||||
| export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; | ||||
| @@ -22,8 +24,9 @@ export const NOTIFICATIONS_EXPAND_FAIL    = 'NOTIFICATIONS_EXPAND_FAIL'; | ||||
|  | ||||
| export const NOTIFICATIONS_FILTER_SET = 'NOTIFICATIONS_FILTER_SET'; | ||||
|  | ||||
| export const NOTIFICATIONS_CLEAR      = 'NOTIFICATIONS_CLEAR'; | ||||
| export const NOTIFICATIONS_SCROLL_TOP = 'NOTIFICATIONS_SCROLL_TOP'; | ||||
| export const NOTIFICATIONS_CLEAR        = 'NOTIFICATIONS_CLEAR'; | ||||
| export const NOTIFICATIONS_SCROLL_TOP   = 'NOTIFICATIONS_SCROLL_TOP'; | ||||
| export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; | ||||
|  | ||||
| defineMessages({ | ||||
|   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, | ||||
| @@ -38,6 +41,10 @@ const fetchRelatedRelationships = (dispatch, notifications) => { | ||||
|   } | ||||
| }; | ||||
|  | ||||
| export const loadPending = () => ({ | ||||
|   type: NOTIFICATIONS_LOAD_PENDING, | ||||
| }); | ||||
|  | ||||
| export function updateNotifications(notification, intlMessages, intlLocale) { | ||||
|   return (dispatch, getState) => { | ||||
|     const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true); | ||||
| @@ -69,6 +76,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { | ||||
|       dispatch({ | ||||
|         type: NOTIFICATIONS_UPDATE, | ||||
|         notification, | ||||
|         usePendingItems: preferPendingItems, | ||||
|         meta: (playSound && !filtered) ? { sound: 'boop' } : undefined, | ||||
|       }); | ||||
|  | ||||
| @@ -122,10 +130,19 @@ export function expandNotifications({ maxId } = {}, done = noOp) { | ||||
|         : excludeTypesFromFilter(activeFilter), | ||||
|     }; | ||||
|  | ||||
|     if (!maxId && notifications.get('items').size > 0) { | ||||
|       params.since_id = notifications.getIn(['items', 0, 'id']); | ||||
|     if (!params.max_id && (notifications.get('items', ImmutableList()).size + notifications.get('pendingItems', ImmutableList()).size) > 0) { | ||||
|       const a = notifications.getIn(['pendingItems', 0, 'id']); | ||||
|       const b = notifications.getIn(['items', 0, 'id']); | ||||
|  | ||||
|       if (a && b && compareId(a, b) > 0) { | ||||
|         params.since_id = a; | ||||
|       } else { | ||||
|         params.since_id = b || a; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const isLoadingRecent = !!params.since_id; | ||||
|  | ||||
|     dispatch(expandNotificationsRequest(isLoadingMore)); | ||||
|  | ||||
|     api(getState).get('/api/v1/notifications', { params }).then(response => { | ||||
| @@ -134,7 +151,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) { | ||||
|       dispatch(importFetchedAccounts(response.data.map(item => item.account))); | ||||
|       dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status))); | ||||
|  | ||||
|       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore)); | ||||
|       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent && preferPendingItems)); | ||||
|       fetchRelatedRelationships(dispatch, response.data); | ||||
|       done(); | ||||
|     }).catch(error => { | ||||
| @@ -151,11 +168,12 @@ export function expandNotificationsRequest(isLoadingMore) { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function expandNotificationsSuccess(notifications, next, isLoadingMore) { | ||||
| export function expandNotificationsSuccess(notifications, next, isLoadingMore, usePendingItems) { | ||||
|   return { | ||||
|     type: NOTIFICATIONS_EXPAND_SUCCESS, | ||||
|     notifications, | ||||
|     next, | ||||
|     usePendingItems, | ||||
|     skipLoading: !isLoadingMore, | ||||
|   }; | ||||
| }; | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| import { importFetchedStatus, importFetchedStatuses } from './importer'; | ||||
| import api, { getLinks } from '../api'; | ||||
| import api, { getLinks } from 'mastodon/api'; | ||||
| import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||
| import compareId from 'mastodon/compare_id'; | ||||
| import { usePendingItems as preferPendingItems } from 'mastodon/initial_state'; | ||||
|  | ||||
| export const TIMELINE_UPDATE  = 'TIMELINE_UPDATE'; | ||||
| export const TIMELINE_DELETE  = 'TIMELINE_DELETE'; | ||||
| @@ -10,10 +12,15 @@ export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST'; | ||||
| export const TIMELINE_EXPAND_SUCCESS = 'TIMELINE_EXPAND_SUCCESS'; | ||||
| export const TIMELINE_EXPAND_FAIL    = 'TIMELINE_EXPAND_FAIL'; | ||||
|  | ||||
| export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP'; | ||||
| export const TIMELINE_SCROLL_TOP   = 'TIMELINE_SCROLL_TOP'; | ||||
| export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING'; | ||||
| export const TIMELINE_DISCONNECT   = 'TIMELINE_DISCONNECT'; | ||||
| export const TIMELINE_CONNECT      = 'TIMELINE_CONNECT'; | ||||
|  | ||||
| export const TIMELINE_CONNECT    = 'TIMELINE_CONNECT'; | ||||
| export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT'; | ||||
| export const loadPending = timeline => ({ | ||||
|   type: TIMELINE_LOAD_PENDING, | ||||
|   timeline, | ||||
| }); | ||||
|  | ||||
| export function updateTimeline(timeline, status, accept) { | ||||
|   return dispatch => { | ||||
| @@ -27,6 +34,7 @@ export function updateTimeline(timeline, status, accept) { | ||||
|       type: TIMELINE_UPDATE, | ||||
|       timeline, | ||||
|       status, | ||||
|       usePendingItems: preferPendingItems, | ||||
|     }); | ||||
|   }; | ||||
| }; | ||||
| @@ -71,8 +79,15 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (!params.max_id && !params.pinned && timeline.get('items', ImmutableList()).size > 0) { | ||||
|       params.since_id = timeline.getIn(['items', 0]); | ||||
|     if (!params.max_id && !params.pinned && (timeline.get('items', ImmutableList()).size + timeline.get('pendingItems', ImmutableList()).size) > 0) { | ||||
|       const a = timeline.getIn(['pendingItems', 0]); | ||||
|       const b = timeline.getIn(['items', 0]); | ||||
|  | ||||
|       if (a && b && compareId(a, b) > 0) { | ||||
|         params.since_id = a; | ||||
|       } else { | ||||
|         params.since_id = b || a; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     const isLoadingRecent = !!params.since_id; | ||||
| @@ -82,7 +97,7 @@ export function expandTimeline(timelineId, path, params = {}, done = noOp) { | ||||
|     api(getState).get(path, { params }).then(response => { | ||||
|       const next = getLinks(response).refs.find(link => link.rel === 'next'); | ||||
|       dispatch(importFetchedStatuses(response.data)); | ||||
|       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore)); | ||||
|       dispatch(expandTimelineSuccess(timelineId, response.data, next ? next.uri : null, response.code === 206, isLoadingRecent, isLoadingMore, isLoadingRecent && preferPendingItems)); | ||||
|       done(); | ||||
|     }).catch(error => { | ||||
|       dispatch(expandTimelineFail(timelineId, error, isLoadingMore)); | ||||
| @@ -115,7 +130,7 @@ export function expandTimelineRequest(timeline, isLoadingMore) { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore) { | ||||
| export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadingRecent, isLoadingMore, usePendingItems) { | ||||
|   return { | ||||
|     type: TIMELINE_EXPAND_SUCCESS, | ||||
|     timeline, | ||||
| @@ -123,6 +138,7 @@ export function expandTimelineSuccess(timeline, statuses, next, partial, isLoadi | ||||
|     next, | ||||
|     partial, | ||||
|     isLoadingRecent, | ||||
|     usePendingItems, | ||||
|     skipLoading: !isLoadingMore, | ||||
|   }; | ||||
| }; | ||||
| @@ -151,9 +167,8 @@ export function connectTimeline(timeline) { | ||||
|   }; | ||||
| }; | ||||
|  | ||||
| export function disconnectTimeline(timeline) { | ||||
|   return { | ||||
|     type: TIMELINE_DISCONNECT, | ||||
|     timeline, | ||||
|   }; | ||||
| }; | ||||
| export const disconnectTimeline = timeline => ({ | ||||
|   type: TIMELINE_DISCONNECT, | ||||
|   timeline, | ||||
|   usePendingItems: preferPendingItems, | ||||
| }); | ||||
|   | ||||
| @@ -1,10 +1,11 @@ | ||||
| export default function compareId(id1, id2) { | ||||
| export default function compareId (id1, id2) { | ||||
|   if (id1 === id2) { | ||||
|     return 0; | ||||
|   } | ||||
|  | ||||
|   if (id1.length === id2.length) { | ||||
|     return id1 > id2 ? 1 : -1; | ||||
|   } else { | ||||
|     return id1.length > id2.length ? 1 : -1; | ||||
|   } | ||||
| } | ||||
| }; | ||||
|   | ||||
							
								
								
									
										22
									
								
								app/javascript/mastodon/components/load_pending.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								app/javascript/mastodon/components/load_pending.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| import React from 'react'; | ||||
| import { FormattedMessage } from 'react-intl'; | ||||
| import PropTypes from 'prop-types'; | ||||
|  | ||||
| export default class LoadPending extends React.PureComponent { | ||||
|  | ||||
|   static propTypes = { | ||||
|     onClick: PropTypes.func, | ||||
|     count: PropTypes.number, | ||||
|   } | ||||
|  | ||||
|   render() { | ||||
|     const { count } = this.props; | ||||
|  | ||||
|     return ( | ||||
|       <button className='load-more load-gap' onClick={this.props.onClick}> | ||||
|         <FormattedMessage id='load_pending' defaultMessage='{count, plural, one {# new item} other {# new items}}' values={{ count }} /> | ||||
|       </button> | ||||
|     ); | ||||
|   } | ||||
|  | ||||
| } | ||||
| @@ -3,6 +3,7 @@ import { ScrollContainer } from 'react-router-scroll-4'; | ||||
| import PropTypes from 'prop-types'; | ||||
| import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container'; | ||||
| import LoadMore from './load_more'; | ||||
| import LoadPending from './load_pending'; | ||||
| import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper'; | ||||
| import { throttle } from 'lodash'; | ||||
| import { List as ImmutableList } from 'immutable'; | ||||
| @@ -21,6 +22,7 @@ export default class ScrollableList extends PureComponent { | ||||
|   static propTypes = { | ||||
|     scrollKey: PropTypes.string.isRequired, | ||||
|     onLoadMore: PropTypes.func, | ||||
|     onLoadPending: PropTypes.func, | ||||
|     onScrollToTop: PropTypes.func, | ||||
|     onScroll: PropTypes.func, | ||||
|     trackScroll: PropTypes.bool, | ||||
| @@ -28,6 +30,7 @@ export default class ScrollableList extends PureComponent { | ||||
|     isLoading: PropTypes.bool, | ||||
|     showLoading: PropTypes.bool, | ||||
|     hasMore: PropTypes.bool, | ||||
|     numPending: PropTypes.number, | ||||
|     prepend: PropTypes.node, | ||||
|     alwaysPrepend: PropTypes.bool, | ||||
|     emptyMessage: PropTypes.node, | ||||
| @@ -225,12 +228,18 @@ export default class ScrollableList extends PureComponent { | ||||
|     this.props.onLoadMore(); | ||||
|   } | ||||
|  | ||||
|   handleLoadPending = e => { | ||||
|     e.preventDefault(); | ||||
|     this.props.onLoadPending(); | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; | ||||
|     const { children, scrollKey, trackScroll, shouldUpdateScroll, showLoading, isLoading, hasMore, numPending, prepend, alwaysPrepend, emptyMessage, onLoadMore } = this.props; | ||||
|     const { fullscreen } = this.state; | ||||
|     const childrenCount = React.Children.count(children); | ||||
|  | ||||
|     const loadMore     = (hasMore && onLoadMore) ? <LoadMore visible={!isLoading} onClick={this.handleLoadMore} /> : null; | ||||
|     const loadPending  = (numPending > 0) ? <LoadPending count={numPending} onClick={this.handleLoadPending} /> : null; | ||||
|     let scrollableArea = null; | ||||
|  | ||||
|     if (showLoading) { | ||||
| @@ -251,6 +260,8 @@ export default class ScrollableList extends PureComponent { | ||||
|           <div role='feed' className='item-list'> | ||||
|             {prepend} | ||||
|  | ||||
|             {loadPending} | ||||
|  | ||||
|             {React.Children.map(this.props.children, (child, index) => ( | ||||
|               <IntersectionObserverArticleContainer | ||||
|                 key={child.key} | ||||
|   | ||||
| @@ -20,7 +20,7 @@ class ColumnSettings extends React.PureComponent { | ||||
|     return ( | ||||
|       <div> | ||||
|         <div className='column-settings__row'> | ||||
|           <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media Only' />} /> | ||||
|           <SettingToggle settings={settings} settingPath={['other', 'onlyMedia']} onChange={onChange} label={<FormattedMessage id='community.column_settings.media_only' defaultMessage='Media only' />} /> | ||||
|         </div> | ||||
|       </div> | ||||
|     ); | ||||
|   | ||||
| @@ -11,6 +11,7 @@ export default class SettingToggle extends React.PureComponent { | ||||
|     settingPath: PropTypes.array.isRequired, | ||||
|     label: PropTypes.node.isRequired, | ||||
|     onChange: PropTypes.func.isRequired, | ||||
|     defaultValue: PropTypes.bool, | ||||
|   } | ||||
|  | ||||
|   onChange = ({ target }) => { | ||||
| @@ -18,12 +19,12 @@ export default class SettingToggle extends React.PureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { prefix, settings, settingPath, label } = this.props; | ||||
|     const { prefix, settings, settingPath, label, defaultValue } = this.props; | ||||
|     const id = ['setting-toggle', prefix, ...settingPath].filter(Boolean).join('-'); | ||||
|  | ||||
|     return ( | ||||
|       <div className='setting-toggle'> | ||||
|         <Toggle id={id} checked={settings.getIn(settingPath)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> | ||||
|         <Toggle id={id} checked={settings.getIn(settingPath, defaultValue)} onChange={this.onChange} onKeyDown={this.onKeyDown} /> | ||||
|         <label htmlFor={id} className='setting-toggle__label'>{label}</label> | ||||
|       </div> | ||||
|     ); | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; | ||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||
| import Column from '../../components/column'; | ||||
| import ColumnHeader from '../../components/column_header'; | ||||
| import { expandNotifications, scrollTopNotifications } from '../../actions/notifications'; | ||||
| import { expandNotifications, scrollTopNotifications, loadPending } from '../../actions/notifications'; | ||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||
| import NotificationContainer from './containers/notification_container'; | ||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||
| @@ -41,6 +41,7 @@ const mapStateToProps = state => ({ | ||||
|   isLoading: state.getIn(['notifications', 'isLoading'], true), | ||||
|   isUnread: state.getIn(['notifications', 'unread']) > 0, | ||||
|   hasMore: state.getIn(['notifications', 'hasMore']), | ||||
|   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, | ||||
| }); | ||||
|  | ||||
| export default @connect(mapStateToProps) | ||||
| @@ -58,6 +59,7 @@ class Notifications extends React.PureComponent { | ||||
|     isUnread: PropTypes.bool, | ||||
|     multiColumn: PropTypes.bool, | ||||
|     hasMore: PropTypes.bool, | ||||
|     numPending: PropTypes.number, | ||||
|   }; | ||||
|  | ||||
|   static defaultProps = { | ||||
| @@ -80,6 +82,10 @@ class Notifications extends React.PureComponent { | ||||
|     this.props.dispatch(expandNotifications({ maxId: last && last.get('id') })); | ||||
|   }, 300, { leading: true }); | ||||
|  | ||||
|   handleLoadPending = () => { | ||||
|     this.props.dispatch(loadPending()); | ||||
|   }; | ||||
|  | ||||
|   handleScrollToTop = debounce(() => { | ||||
|     this.props.dispatch(scrollTopNotifications(true)); | ||||
|   }, 100); | ||||
| @@ -136,7 +142,7 @@ class Notifications extends React.PureComponent { | ||||
|   } | ||||
|  | ||||
|   render () { | ||||
|     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, showFilterBar } = this.props; | ||||
|     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; | ||||
|     const pinned = !!columnId; | ||||
|     const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; | ||||
|  | ||||
| @@ -178,8 +184,10 @@ class Notifications extends React.PureComponent { | ||||
|         isLoading={isLoading} | ||||
|         showLoading={isLoading && notifications.size === 0} | ||||
|         hasMore={hasMore} | ||||
|         numPending={numPending} | ||||
|         emptyMessage={emptyMessage} | ||||
|         onLoadMore={this.handleLoadOlder} | ||||
|         onLoadPending={this.handleLoadPending} | ||||
|         onScrollToTop={this.handleScrollToTop} | ||||
|         onScroll={this.handleScroll} | ||||
|         shouldUpdateScroll={shouldUpdateScroll} | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import { connect } from 'react-redux'; | ||||
| import StatusList from '../../../components/status_list'; | ||||
| import { scrollTopTimeline } from '../../../actions/timelines'; | ||||
| import { scrollTopTimeline, loadPending } from '../../../actions/timelines'; | ||||
| import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||
| import { createSelector } from 'reselect'; | ||||
| import { debounce } from 'lodash'; | ||||
| @@ -37,6 +37,7 @@ const makeMapStateToProps = () => { | ||||
|     isLoading: state.getIn(['timelines', timelineId, 'isLoading'], true), | ||||
|     isPartial: state.getIn(['timelines', timelineId, 'isPartial'], false), | ||||
|     hasMore:   state.getIn(['timelines', timelineId, 'hasMore']), | ||||
|     numPending: state.getIn(['timelines', timelineId, 'pendingItems'], ImmutableList()).size, | ||||
|   }); | ||||
|  | ||||
|   return mapStateToProps; | ||||
| @@ -52,6 +53,8 @@ const mapDispatchToProps = (dispatch, { timelineId }) => ({ | ||||
|     dispatch(scrollTopTimeline(timelineId, false)); | ||||
|   }, 100), | ||||
|  | ||||
|   onLoadPending: () => dispatch(loadPending(timelineId)), | ||||
|  | ||||
| }); | ||||
|  | ||||
| export default connect(makeMapStateToProps, mapDispatchToProps)(StatusList); | ||||
|   | ||||
| @@ -21,5 +21,6 @@ export const profile_directory = getMeta('profile_directory'); | ||||
| export const isStaff = getMeta('is_staff'); | ||||
| export const forceSingleColumn = !getMeta('advanced_layout'); | ||||
| export const useBlurhash = getMeta('use_blurhash'); | ||||
| export const usePendingItems = getMeta('use_pending_items'); | ||||
|  | ||||
| export default initialState; | ||||
|   | ||||
| @@ -6,6 +6,7 @@ import { | ||||
|   NOTIFICATIONS_FILTER_SET, | ||||
|   NOTIFICATIONS_CLEAR, | ||||
|   NOTIFICATIONS_SCROLL_TOP, | ||||
|   NOTIFICATIONS_LOAD_PENDING, | ||||
| } from '../actions/notifications'; | ||||
| import { | ||||
|   ACCOUNT_BLOCK_SUCCESS, | ||||
| @@ -16,6 +17,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||
| import compareId from '../compare_id'; | ||||
|  | ||||
| const initialState = ImmutableMap({ | ||||
|   pendingItems: ImmutableList(), | ||||
|   items: ImmutableList(), | ||||
|   hasMore: true, | ||||
|   top: false, | ||||
| @@ -31,7 +33,11 @@ const notificationToMap = notification => ImmutableMap({ | ||||
|   status: notification.status ? notification.status.id : null, | ||||
| }); | ||||
|  | ||||
| const normalizeNotification = (state, notification) => { | ||||
| const normalizeNotification = (state, notification, usePendingItems) => { | ||||
|   if (usePendingItems) { | ||||
|     return state.update('pendingItems', list => list.unshift(notificationToMap(notification))); | ||||
|   } | ||||
|  | ||||
|   const top = state.get('top'); | ||||
|  | ||||
|   if (!top) { | ||||
| @@ -47,7 +53,7 @@ const normalizeNotification = (state, notification) => { | ||||
|   }); | ||||
| }; | ||||
|  | ||||
| const expandNormalizedNotifications = (state, notifications, next) => { | ||||
| const expandNormalizedNotifications = (state, notifications, next, usePendingItems) => { | ||||
|   let items = ImmutableList(); | ||||
|  | ||||
|   notifications.forEach((n, i) => { | ||||
| @@ -56,7 +62,7 @@ const expandNormalizedNotifications = (state, notifications, next) => { | ||||
|  | ||||
|   return state.withMutations(mutable => { | ||||
|     if (!items.isEmpty()) { | ||||
|       mutable.update('items', list => { | ||||
|       mutable.update(usePendingItems ? 'pendingItems' : 'items', list => { | ||||
|         const lastIndex = 1 + list.findLastIndex( | ||||
|           item => item !== null && (compareId(item.get('id'), items.last().get('id')) > 0 || item.get('id') === items.last().get('id')) | ||||
|         ); | ||||
| @@ -78,7 +84,8 @@ const expandNormalizedNotifications = (state, notifications, next) => { | ||||
| }; | ||||
|  | ||||
| const filterNotifications = (state, relationship) => { | ||||
|   return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id)); | ||||
|   const helper = list => list.filterNot(item => item !== null && item.get('account') === relationship.id); | ||||
|   return state.update('items', helper).update('pendingItems', helper); | ||||
| }; | ||||
|  | ||||
| const updateTop = (state, top) => { | ||||
| @@ -90,34 +97,37 @@ const updateTop = (state, top) => { | ||||
| }; | ||||
|  | ||||
| const deleteByStatus = (state, statusId) => { | ||||
|   return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId)); | ||||
|   const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); | ||||
|   return state.update('items', helper).update('pendingItems', helper); | ||||
| }; | ||||
|  | ||||
| export default function notifications(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case NOTIFICATIONS_LOAD_PENDING: | ||||
|     return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); | ||||
|   case NOTIFICATIONS_EXPAND_REQUEST: | ||||
|     return state.set('isLoading', true); | ||||
|   case NOTIFICATIONS_EXPAND_FAIL: | ||||
|     return state.set('isLoading', false); | ||||
|   case NOTIFICATIONS_FILTER_SET: | ||||
|     return state.set('items', ImmutableList()).set('hasMore', true); | ||||
|     return state.set('items', ImmutableList()).set('pendingItems', ImmutableList()).set('hasMore', true); | ||||
|   case NOTIFICATIONS_SCROLL_TOP: | ||||
|     return updateTop(state, action.top); | ||||
|   case NOTIFICATIONS_UPDATE: | ||||
|     return normalizeNotification(state, action.notification); | ||||
|     return normalizeNotification(state, action.notification, action.usePendingItems); | ||||
|   case NOTIFICATIONS_EXPAND_SUCCESS: | ||||
|     return expandNormalizedNotifications(state, action.notifications, action.next); | ||||
|     return expandNormalizedNotifications(state, action.notifications, action.next, action.usePendingItems); | ||||
|   case ACCOUNT_BLOCK_SUCCESS: | ||||
|     return filterNotifications(state, action.relationship); | ||||
|   case ACCOUNT_MUTE_SUCCESS: | ||||
|     return action.relationship.muting_notifications ? filterNotifications(state, action.relationship) : state; | ||||
|   case NOTIFICATIONS_CLEAR: | ||||
|     return state.set('items', ImmutableList()).set('hasMore', false); | ||||
|     return state.set('items', ImmutableList()).set('pendingItems', 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.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : | ||||
|       state; | ||||
|   default: | ||||
|     return state; | ||||
|   | ||||
| @@ -10,8 +10,6 @@ import uuid from '../uuid'; | ||||
| const initialState = ImmutableMap({ | ||||
|   saved: true, | ||||
|  | ||||
|   onboarded: false, | ||||
|  | ||||
|   skinTone: 1, | ||||
|  | ||||
|   home: ImmutableMap({ | ||||
| @@ -74,10 +72,6 @@ const initialState = ImmutableMap({ | ||||
|       body: '', | ||||
|     }), | ||||
|   }), | ||||
|  | ||||
|   trends: ImmutableMap({ | ||||
|     show: true, | ||||
|   }), | ||||
| }); | ||||
|  | ||||
| const defaultColumns = fromJS([ | ||||
|   | ||||
| @@ -8,6 +8,7 @@ import { | ||||
|   TIMELINE_SCROLL_TOP, | ||||
|   TIMELINE_CONNECT, | ||||
|   TIMELINE_DISCONNECT, | ||||
|   TIMELINE_LOAD_PENDING, | ||||
| } from '../actions/timelines'; | ||||
| import { | ||||
|   ACCOUNT_BLOCK_SUCCESS, | ||||
| @@ -25,10 +26,11 @@ const initialTimeline = ImmutableMap({ | ||||
|   top: true, | ||||
|   isLoading: false, | ||||
|   hasMore: true, | ||||
|   pendingItems: ImmutableList(), | ||||
|   items: ImmutableList(), | ||||
| }); | ||||
|  | ||||
| const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent) => { | ||||
| const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, isLoadingRecent, usePendingItems) => { | ||||
|   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { | ||||
|     mMap.set('isLoading', false); | ||||
|     mMap.set('isPartial', isPartial); | ||||
| @@ -38,7 +40,7 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is | ||||
|     if (timeline.endsWith(':pinned')) { | ||||
|       mMap.set('items', statuses.map(status => status.get('id'))); | ||||
|     } else if (!statuses.isEmpty()) { | ||||
|       mMap.update('items', ImmutableList(), oldIds => { | ||||
|       mMap.update(usePendingItems ? 'pendingItems' : 'items', ImmutableList(), oldIds => { | ||||
|         const newIds = statuses.map(status => status.get('id')); | ||||
|  | ||||
|         const lastIndex = oldIds.findLastIndex(id => id !== null && compareId(id, newIds.last()) >= 0) + 1; | ||||
| @@ -57,7 +59,15 @@ const expandNormalizedTimeline = (state, timeline, statuses, next, isPartial, is | ||||
|   })); | ||||
| }; | ||||
|  | ||||
| const updateTimeline = (state, timeline, status) => { | ||||
| const updateTimeline = (state, timeline, status, usePendingItems) => { | ||||
|   if (usePendingItems) { | ||||
|     if (state.getIn([timeline, 'pendingItems'], ImmutableList()).includes(status.get('id')) || state.getIn([timeline, 'items'], ImmutableList()).includes(status.get('id'))) { | ||||
|       return state; | ||||
|     } | ||||
|  | ||||
|     return state.update(timeline, initialTimeline, map => map.update('pendingItems', list => list.unshift(status.get('id')))); | ||||
|   } | ||||
|  | ||||
|   const top        = state.getIn([timeline, 'top']); | ||||
|   const ids        = state.getIn([timeline, 'items'], ImmutableList()); | ||||
|   const includesId = ids.includes(status.get('id')); | ||||
| @@ -78,8 +88,10 @@ const updateTimeline = (state, timeline, status) => { | ||||
|  | ||||
| const deleteStatus = (state, id, accountId, references, exclude_account = null) => { | ||||
|   state.keySeq().forEach(timeline => { | ||||
|     if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) | ||||
|       state = state.updateIn([timeline, 'items'], list => list.filterNot(item => item === id)); | ||||
|     if (exclude_account === null || (timeline !== `account:${exclude_account}` && !timeline.startsWith(`account:${exclude_account}:`))) { | ||||
|       const helper = list => list.filterNot(item => item === id); | ||||
|       state = state.updateIn([timeline, 'items'], helper).updateIn([timeline, 'pendingItems'], helper); | ||||
|     } | ||||
|   }); | ||||
|  | ||||
|   // Remove reblogs of deleted status | ||||
| @@ -109,11 +121,10 @@ const filterTimelines = (state, relationship, statuses) => { | ||||
|   return state; | ||||
| }; | ||||
|  | ||||
| const filterTimeline = (timeline, state, relationship, statuses) => | ||||
|   state.updateIn([timeline, 'items'], ImmutableList(), list => | ||||
|     list.filterNot(statusId => | ||||
|       statuses.getIn([statusId, 'account']) === relationship.id | ||||
|     )); | ||||
| const filterTimeline = (timeline, state, relationship, statuses) => { | ||||
|   const helper = list => list.filterNot(statusId => statuses.getIn([statusId, 'account']) === relationship.id); | ||||
|   return state.updateIn([timeline, 'items'], ImmutableList(), helper).updateIn([timeline, 'pendingItems'], ImmutableList(), helper); | ||||
| }; | ||||
|  | ||||
| const updateTop = (state, timeline, top) => { | ||||
|   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => { | ||||
| @@ -124,14 +135,17 @@ const updateTop = (state, timeline, top) => { | ||||
|  | ||||
| export default function timelines(state = initialState, action) { | ||||
|   switch(action.type) { | ||||
|   case TIMELINE_LOAD_PENDING: | ||||
|     return state.update(action.timeline, initialTimeline, map => | ||||
|       map.update('items', list => map.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0)); | ||||
|   case TIMELINE_EXPAND_REQUEST: | ||||
|     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', true)); | ||||
|   case TIMELINE_EXPAND_FAIL: | ||||
|     return state.update(action.timeline, initialTimeline, map => map.set('isLoading', false)); | ||||
|   case TIMELINE_EXPAND_SUCCESS: | ||||
|     return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent); | ||||
|     return expandNormalizedTimeline(state, action.timeline, fromJS(action.statuses), action.next, action.partial, action.isLoadingRecent, action.usePendingItems); | ||||
|   case TIMELINE_UPDATE: | ||||
|     return updateTimeline(state, action.timeline, fromJS(action.status)); | ||||
|     return updateTimeline(state, action.timeline, fromJS(action.status), action.usePendingItems); | ||||
|   case TIMELINE_DELETE: | ||||
|     return deleteStatus(state, action.id, action.accountId, action.references, action.reblogOf); | ||||
|   case TIMELINE_CLEAR: | ||||
| @@ -149,7 +163,7 @@ export default function timelines(state = initialState, action) { | ||||
|     return state.update( | ||||
|       action.timeline, | ||||
|       initialTimeline, | ||||
|       map => map.set('online', false).update('items', items => items.first() ? items.unshift(null) : items) | ||||
|       map => map.set('online', false).update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) | ||||
|     ); | ||||
|   default: | ||||
|     return state; | ||||
|   | ||||
| @@ -35,6 +35,7 @@ class UserSettingsDecorator | ||||
|     user.settings['show_application']    = show_application_preference if change?('setting_show_application') | ||||
|     user.settings['advanced_layout']     = advanced_layout_preference if change?('setting_advanced_layout') | ||||
|     user.settings['use_blurhash']        = use_blurhash_preference if change?('setting_use_blurhash') | ||||
|     user.settings['use_pending_items']   = use_pending_items_preference if change?('setting_use_pending_items') | ||||
|   end | ||||
|  | ||||
|   def merged_notification_emails | ||||
| @@ -117,6 +118,10 @@ class UserSettingsDecorator | ||||
|     boolean_cast_setting 'setting_use_blurhash' | ||||
|   end | ||||
|  | ||||
|   def use_pending_items_preference | ||||
|     boolean_cast_setting 'setting_use_pending_items' | ||||
|   end | ||||
|  | ||||
|   def boolean_cast_setting(key) | ||||
|     ActiveModel::Type::Boolean.new.cast(settings[key]) | ||||
|   end | ||||
|   | ||||
| @@ -106,7 +106,7 @@ class User < ApplicationRecord | ||||
|   delegate :auto_play_gif, :default_sensitive, :unfollow_modal, :boost_modal, :delete_modal, | ||||
|            :reduce_motion, :system_font_ui, :noindex, :theme, :display_media, :hide_network, | ||||
|            :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, | ||||
|            :advanced_layout, :use_blurhash, to: :settings, prefix: :setting, allow_nil: false | ||||
|            :advanced_layout, :use_blurhash, :use_pending_items, to: :settings, prefix: :setting, allow_nil: false | ||||
|  | ||||
|   attr_reader :invite_code | ||||
|   attr_writer :external | ||||
|   | ||||
| @@ -23,17 +23,18 @@ class InitialStateSerializer < ActiveModel::Serializer | ||||
|     } | ||||
|  | ||||
|     if object.current_account | ||||
|       store[:me]              = object.current_account.id.to_s | ||||
|       store[:unfollow_modal]  = object.current_account.user.setting_unfollow_modal | ||||
|       store[:boost_modal]     = object.current_account.user.setting_boost_modal | ||||
|       store[:delete_modal]    = object.current_account.user.setting_delete_modal | ||||
|       store[:auto_play_gif]   = object.current_account.user.setting_auto_play_gif | ||||
|       store[:display_media]   = object.current_account.user.setting_display_media | ||||
|       store[:expand_spoilers] = object.current_account.user.setting_expand_spoilers | ||||
|       store[:reduce_motion]   = object.current_account.user.setting_reduce_motion | ||||
|       store[:advanced_layout] = object.current_account.user.setting_advanced_layout | ||||
|       store[:use_blurhash]    = object.current_account.user.setting_use_blurhash | ||||
|       store[:is_staff]        = object.current_account.user.staff? | ||||
|       store[:me]                = object.current_account.id.to_s | ||||
|       store[:unfollow_modal]    = object.current_account.user.setting_unfollow_modal | ||||
|       store[:boost_modal]       = object.current_account.user.setting_boost_modal | ||||
|       store[:delete_modal]      = object.current_account.user.setting_delete_modal | ||||
|       store[:auto_play_gif]     = object.current_account.user.setting_auto_play_gif | ||||
|       store[:display_media]     = object.current_account.user.setting_display_media | ||||
|       store[:expand_spoilers]   = object.current_account.user.setting_expand_spoilers | ||||
|       store[:reduce_motion]     = object.current_account.user.setting_reduce_motion | ||||
|       store[:advanced_layout]   = object.current_account.user.setting_advanced_layout | ||||
|       store[:use_blurhash]      = object.current_account.user.setting_use_blurhash | ||||
|       store[:use_pending_items] = object.current_account.user.setting_use_pending_items | ||||
|       store[:is_staff]          = object.current_account.user.staff? | ||||
|     end | ||||
|  | ||||
|     store | ||||
|   | ||||
| @@ -17,6 +17,9 @@ | ||||
|  | ||||
|   %h4= t 'appearance.animations_and_accessibility' | ||||
|  | ||||
|   .fields-group | ||||
|     = f.input :setting_use_pending_items, as: :boolean, wrapper: :with_label | ||||
|  | ||||
|   .fields-group | ||||
|     = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label, recommended: true | ||||
|     = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label | ||||
|   | ||||
| @@ -35,6 +35,7 @@ en: | ||||
|         setting_noindex: Affects your public profile and status pages | ||||
|         setting_show_application: The application you use to toot will be displayed in the detailed view of your toots | ||||
|         setting_use_blurhash: Gradients are based on the colors of the hidden visuals but obfuscate any details | ||||
|         setting_use_pending_items: Hide timeline updates behind a click instead of automatically scrolling the feed | ||||
|         username: Your username will be unique on %{domain} | ||||
|         whole_word: When the keyword or phrase is alphanumeric only, it will only be applied if it matches the whole word | ||||
|       featured_tag: | ||||
| @@ -111,6 +112,7 @@ en: | ||||
|         setting_theme: Site theme | ||||
|         setting_unfollow_modal: Show confirmation dialog before unfollowing someone | ||||
|         setting_use_blurhash: Show colorful gradients for hidden media | ||||
|         setting_use_pending_items: Slow mode | ||||
|         severity: Severity | ||||
|         type: Import type | ||||
|         username: Username | ||||
|   | ||||
| @@ -33,6 +33,7 @@ defaults: &defaults | ||||
|   aggregate_reblogs: true | ||||
|   advanced_layout: false | ||||
|   use_blurhash: true | ||||
|   use_pending_items: false | ||||
|   notification_emails: | ||||
|     follow: false | ||||
|     reblog: false | ||||
|   | ||||
		Reference in New Issue
	
	Block a user