Fix #9031 Fix #7913master^2
@@ -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; | |||
} | |||
} | |||
}; |
@@ -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 | |||
@@ -18,6 +18,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 | |||
= f.input :setting_system_font_ui, 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 | |||