diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 0184d9c80db..3cfad90a1cd 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -2,6 +2,7 @@ import api, { getLinks } from 'flavours/glitch/util/api';
import IntlMessageFormat from 'intl-messageformat';
import { fetchRelationships } from './accounts';
import { defineMessages } from 'react-intl';
+import { List as ImmutableList } from 'immutable';
import { unescapeHTML } from 'flavours/glitch/util/html';
import { getFilters, regexFromFilters } from 'flavours/glitch/selectors';
@@ -22,6 +23,8 @@ export const NOTIFICATIONS_EXPAND_REQUEST = 'NOTIFICATIONS_EXPAND_REQUEST';
export const NOTIFICATIONS_EXPAND_SUCCESS = 'NOTIFICATIONS_EXPAND_SUCCESS';
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';
@@ -84,10 +87,16 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
+const excludeTypesFromFilter = filter => {
+ const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']);
+ return allTypes.filterNot(item => item === filter).toJS();
+};
+
const noOp = () => {};
export function expandNotifications({ maxId } = {}, done = noOp) {
return (dispatch, getState) => {
+ const activeFilter = getState().getIn(['settings', 'notifications', 'quickFilter', 'active']);
const notifications = getState().get('notifications');
const isLoadingMore = !!maxId;
@@ -98,7 +107,9 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
const params = {
max_id: maxId,
- exclude_types: excludeTypesFromSettings(getState()),
+ exclude_types: activeFilter === 'all'
+ ? excludeTypesFromSettings(getState())
+ : excludeTypesFromFilter(activeFilter),
};
if (!maxId && notifications.get('items').size > 0) {
@@ -244,3 +255,14 @@ export function notificationsSetVisibility(visibility) {
visibility: visibility,
};
};
+
+export function setFilter (filterType) {
+ return dispatch => {
+ dispatch({
+ type: NOTIFICATIONS_FILTER_SET,
+ path: ['notifications', 'quickFilter', 'active'],
+ value: filterType,
+ });
+ dispatch(expandNotifications());
+ };
+};
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index d9638aaf35a..4e35d5b4e85 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -21,9 +21,11 @@ export default class ColumnSettings extends React.PureComponent {
render () {
const { settings, pushSettings, onChange, onClear } = this.props;
- const alertStr = ;
- const showStr = ;
- const soundStr = ;
+ const filterShowStr = ;
+ const filterAdvancedStr = ;
+ const alertStr = ;
+ const showStr = ;
+ const soundStr = ;
const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed');
const pushStr = showPushSettings && ;
@@ -35,6 +37,16 @@ export default class ColumnSettings extends React.PureComponent {
+
diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
new file mode 100644
index 00000000000..f95a2c9dea6
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
@@ -0,0 +1,93 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
+
+const tooltips = defineMessages({
+ mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
+ favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
+ boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
+ follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
+});
+
+export default @injectIntl
+class FilterBar extends React.PureComponent {
+
+ static propTypes = {
+ selectFilter: PropTypes.func.isRequired,
+ selectedFilter: PropTypes.string.isRequired,
+ advancedMode: PropTypes.bool.isRequired,
+ intl: PropTypes.object.isRequired,
+ };
+
+ onClick (notificationType) {
+ return () => this.props.selectFilter(notificationType);
+ }
+
+ render () {
+ const { selectedFilter, advancedMode, intl } = this.props;
+ const renderedElement = !advancedMode ? (
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+ );
+ return renderedElement;
+ }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
index 9585ea556e0..4b863712a4b 100644
--- a/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/flavours/glitch/features/notifications/containers/column_settings_container.js
@@ -2,6 +2,7 @@ import { connect } from 'react-redux';
import { defineMessages, injectIntl } from 'react-intl';
import ColumnSettings from '../components/column_settings';
import { changeSetting } from 'flavours/glitch/actions/settings';
+import { setFilter } from 'flavours/glitch/actions/notifications';
import { clearNotifications } from 'flavours/glitch/actions/notifications';
import { changeAlerts as changePushNotifications } from 'flavours/glitch/actions/push_notifications';
import { openModal } from 'flavours/glitch/actions/modal';
@@ -21,6 +22,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onChange (path, checked) {
if (path[0] === 'push') {
dispatch(changePushNotifications(path.slice(1), checked));
+ } else if (path[0] === 'quickFilter') {
+ dispatch(changeSetting(['notifications', ...path], checked));
+ dispatch(setFilter('all'));
} else {
dispatch(changeSetting(['notifications', ...path], checked));
}
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js
new file mode 100644
index 00000000000..4d495c29081
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/filter_bar_container.js
@@ -0,0 +1,16 @@
+import { connect } from 'react-redux';
+import FilterBar from '../components/filter_bar';
+import { setFilter } from '../../../actions/notifications';
+
+const makeMapStateToProps = state => ({
+ selectedFilter: state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
+ advancedMode: state.getIn(['settings', 'notifications', 'quickFilter', 'advanced']),
+});
+
+const mapDispatchToProps = (dispatch) => ({
+ selectFilter (newActiveFilter) {
+ dispatch(setFilter(newActiveFilter));
+ },
+});
+
+export default connect(makeMapStateToProps, mapDispatchToProps)(FilterBar);
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 0e73f02d805..6a149927c5e 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -15,6 +15,7 @@ import { addColumn, removeColumn, moveColumn } from 'flavours/glitch/actions/col
import NotificationContainer from './containers/notification_container';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import ColumnSettingsContainer from './containers/column_settings_container';
+import FilterBarContainer from './containers/filter_bar_container';
import { createSelector } from 'reselect';
import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash';
@@ -26,11 +27,22 @@ const messages = defineMessages({
});
const getNotifications = createSelector([
+ state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
+ state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
state => state.getIn(['notifications', 'items']),
-], (excludedTypes, notifications) => notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))));
+], (showFilterBar, allowedType, excludedTypes, notifications) => {
+ if (!showFilterBar || allowedType === 'all') {
+ // used if user changed the notification settings after loading the notifications from the server
+ // otherwise a list of notifications will come pre-filtered from the backend
+ // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
+ return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
+ }
+ return notifications.filter(item => item !== null && allowedType === item.get('type'));
+});
const mapStateToProps = state => ({
+ showFilterBar: state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
notifications: getNotifications(state),
localSettings: state.get('local_settings'),
isLoading: state.getIn(['notifications', 'isLoading'], true),
@@ -60,6 +72,7 @@ export default class Notifications extends React.PureComponent {
static propTypes = {
columnId: PropTypes.string,
notifications: ImmutablePropTypes.list.isRequired,
+ showFilterBar: PropTypes.bool.isRequired,
dispatch: PropTypes.func.isRequired,
shouldUpdateScroll: PropTypes.func,
intl: PropTypes.object.isRequired,
@@ -151,12 +164,16 @@ export default class Notifications extends React.PureComponent {
}
render () {
- const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore } = this.props;
+ const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, showFilterBar } = this.props;
const pinned = !!columnId;
const emptyMessage =
;
let scrollableContent = null;
+ const filterBarContainer = showFilterBar
+ ? (
)
+ : null;
+
if (isLoading && this.scrollableContent) {
scrollableContent = this.scrollableContent;
} else if (notifications.size > 0 || hasMore) {
@@ -222,7 +239,7 @@ export default class Notifications extends React.PureComponent {
>
-
+ {filterBarContainer}
{scrollContainer}
);
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index b65c51f3242..6667966c026 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -6,6 +6,7 @@ import {
NOTIFICATIONS_EXPAND_SUCCESS,
NOTIFICATIONS_EXPAND_REQUEST,
NOTIFICATIONS_EXPAND_FAIL,
+ NOTIFICATIONS_FILTER_SET,
NOTIFICATIONS_CLEAR,
NOTIFICATIONS_SCROLL_TOP,
NOTIFICATIONS_DELETE_MARKED_REQUEST,
@@ -197,6 +198,8 @@ export default function notifications(state = initialState, action) {
case NOTIFICATIONS_DELETE_MARKED_FAIL:
case NOTIFICATIONS_EXPAND_FAIL:
return state.set('isLoading', false);
+ case NOTIFICATIONS_FILTER_SET:
+ return state.set('items', ImmutableList()).set('hasMore', true);
case NOTIFICATIONS_SCROLL_TOP:
return updateTop(state, action.top);
case NOTIFICATIONS_UPDATE:
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js.orig b/app/javascript/flavours/glitch/reducers/notifications.js.orig
new file mode 100644
index 00000000000..b65c51f3242
--- /dev/null
+++ b/app/javascript/flavours/glitch/reducers/notifications.js.orig
@@ -0,0 +1,245 @@
+import {
+ NOTIFICATIONS_MOUNT,
+ NOTIFICATIONS_UNMOUNT,
+ NOTIFICATIONS_SET_VISIBILITY,
+ NOTIFICATIONS_UPDATE,
+ NOTIFICATIONS_EXPAND_SUCCESS,
+ NOTIFICATIONS_EXPAND_REQUEST,
+ NOTIFICATIONS_EXPAND_FAIL,
+ NOTIFICATIONS_CLEAR,
+ NOTIFICATIONS_SCROLL_TOP,
+ NOTIFICATIONS_DELETE_MARKED_REQUEST,
+ NOTIFICATIONS_DELETE_MARKED_SUCCESS,
+ NOTIFICATION_MARK_FOR_DELETE,
+ NOTIFICATIONS_DELETE_MARKED_FAIL,
+ NOTIFICATIONS_ENTER_CLEARING_MODE,
+ NOTIFICATIONS_MARK_ALL_FOR_DELETE,
+} from 'flavours/glitch/actions/notifications';
+import {
+ ACCOUNT_BLOCK_SUCCESS,
+ ACCOUNT_MUTE_SUCCESS,
+} from 'flavours/glitch/actions/accounts';
+import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
+import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import compareId from 'flavours/glitch/util/compare_id';
+
+const initialState = ImmutableMap({
+ items: ImmutableList(),
+ hasMore: true,
+ top: true,
+ mounted: 0,
+ unread: 0,
+ lastReadId: '0',
+ isLoading: false,
+ cleaningMode: false,
+ isTabVisible: true,
+ // notification removal mark of new notifs loaded whilst cleaningMode is true.
+ markNewForDelete: false,
+});
+
+const notificationToMap = (state, notification) => ImmutableMap({
+ id: notification.id,
+ type: notification.type,
+ account: notification.account.id,
+ markedForDelete: state.get('markNewForDelete'),
+ status: notification.status ? notification.status.id : null,
+});
+
+const normalizeNotification = (state, notification) => {
+ const top = !shouldCountUnreadNotifications(state);
+
+ if (top) {
+ state = state.set('lastReadId', notification.id);
+ } else {
+ state = state.update('unread', unread => unread + 1);
+ }
+
+ return state.update('items', list => {
+ if (top && list.size > 40) {
+ list = list.take(20);
+ }
+
+ return list.unshift(notificationToMap(state, notification));
+ });
+};
+
+const expandNormalizedNotifications = (state, notifications, next) => {
+ const top = !(shouldCountUnreadNotifications(state));
+ const lastReadId = state.get('lastReadId');
+ let items = ImmutableList();
+
+ notifications.forEach((n, i) => {
+ items = items.set(i, notificationToMap(state, n));
+ });
+
+ return state.withMutations(mutable => {
+ if (!items.isEmpty()) {
+ mutable.update('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'))
+ );
+
+ const firstIndex = 1 + list.take(lastIndex).findLastIndex(
+ item => item !== null && compareId(item.get('id'), items.first().get('id')) > 0
+ );
+
+ return list.take(firstIndex).concat(items, list.skip(lastIndex));
+ });
+ }
+
+ if (top) {
+ if (!items.isEmpty()) {
+ mutable.update('lastReadId', id => compareId(id, items.first().get('id')) > 0 ? id : items.first().get('id'));
+ }
+ } else {
+ mutable.update('unread', unread => unread + items.filter(item => compareId(item.get('id'), lastReadId) > 0).size);
+ }
+
+ if (!next) {
+ mutable.set('hasMore', false);
+ }
+
+ mutable.set('isLoading', false);
+ });
+};
+
+const filterNotifications = (state, relationship) => {
+ return state.update('items', list => list.filterNot(item => item !== null && item.get('account') === relationship.id));
+};
+
+const clearUnread = (state) => {
+ state = state.set('unread', 0);
+ const lastNotification = state.get('items').find(item => item !== null);
+ return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0');
+}
+
+const updateTop = (state, top) => {
+ state = state.set('top', top);
+
+ if (!shouldCountUnreadNotifications(state)) {
+ state = clearUnread(state);
+ }
+
+ return state.set('top', top);
+};
+
+const deleteByStatus = (state, statusId) => {
+ const top = !(shouldCountUnreadNotifications(state));
+ if (!top) {
+ const lastReadId = state.get('lastReadId');
+ const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0);
+ state = state.update('unread', unread => unread - deletedUnread.size);
+ }
+ return state.update('items', list => list.filterNot(item => item !== null && item.get('status') === statusId));
+};
+
+const markForDelete = (state, notificationId, yes) => {
+ return state.update('items', list => list.map(item => {
+ if(item.get('id') === notificationId) {
+ return item.set('markedForDelete', yes);
+ } else {
+ return item;
+ }
+ }));
+};
+
+const markAllForDelete = (state, yes) => {
+ return state.update('items', list => list.map(item => {
+ if(yes !== null) {
+ return item.set('markedForDelete', yes);
+ } else {
+ return item.set('markedForDelete', !item.get('markedForDelete'));
+ }
+ }));
+};
+
+const unmarkAllForDelete = (state) => {
+ return state.update('items', list => list.map(item => item.set('markedForDelete', false)));
+};
+
+const deleteMarkedNotifs = (state) => {
+ return state.update('items', list => list.filterNot(item => item.get('markedForDelete')));
+};
+
+const updateMounted = (state) => {
+ state = state.update('mounted', count => count + 1);
+ if (!shouldCountUnreadNotifications(state)) {
+ state = clearUnread(state);
+ }
+ return state;
+};
+
+const updateVisibility = (state, visibility) => {
+ state = state.set('isTabVisible', visibility);
+ if (!shouldCountUnreadNotifications(state)) {
+ state = clearUnread(state);
+ }
+ return state;
+};
+
+const shouldCountUnreadNotifications = (state) => {
+ return !(state.get('isTabVisible') && state.get('top') && state.get('mounted') > 0);
+};
+
+export default function notifications(state = initialState, action) {
+ let st;
+
+ switch(action.type) {
+ case NOTIFICATIONS_MOUNT:
+ return updateMounted(state);
+ case NOTIFICATIONS_UNMOUNT:
+ return state.update('mounted', count => count - 1);
+ case NOTIFICATIONS_SET_VISIBILITY:
+ return updateVisibility(state, action.visibility);
+ case NOTIFICATIONS_EXPAND_REQUEST:
+ case NOTIFICATIONS_DELETE_MARKED_REQUEST:
+ return state.set('isLoading', true);
+ case NOTIFICATIONS_DELETE_MARKED_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_EXPAND_SUCCESS:
+ 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('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);
+
+ case NOTIFICATIONS_DELETE_MARKED_SUCCESS:
+ return deleteMarkedNotifs(state).set('isLoading', false);
+
+ case NOTIFICATIONS_ENTER_CLEARING_MODE:
+ st = state.set('cleaningMode', action.yes);
+ if (!action.yes) {
+ return unmarkAllForDelete(st).set('markNewForDelete', false);
+ } else {
+ return st;
+ }
+
+ case NOTIFICATIONS_MARK_ALL_FOR_DELETE:
+ st = state;
+ if (action.yes === null) {
+ // Toggle - this is a bit confusing, as it toggles the all-none mode
+ //st = st.set('markNewForDelete', !st.get('markNewForDelete'));
+ } else {
+ st = st.set('markNewForDelete', action.yes);
+ }
+ return markAllForDelete(st, action.yes);
+
+ default:
+ return state;
+ }
+};
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index c04f262da29..cb62f87b087 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -1,4 +1,5 @@
import { SETTING_CHANGE, SETTING_SAVE } from 'flavours/glitch/actions/settings';
+import { NOTIFICATIONS_FILTER_SET } from 'flavours/glitch/actions/notifications';
import { COLUMN_ADD, COLUMN_REMOVE, COLUMN_MOVE } from 'flavours/glitch/actions/columns';
import { STORE_HYDRATE } from 'flavours/glitch/actions/store';
import { EMOJI_USE } from 'flavours/glitch/actions/emojis';
@@ -34,6 +35,12 @@ const initialState = ImmutableMap({
mention: true,
}),
+ quickFilter: ImmutableMap({
+ active: 'all',
+ show: true,
+ advanced: false,
+ }),
+
shows: ImmutableMap({
follow: true,
favourite: true,
@@ -99,6 +106,7 @@ export default function settings(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
return hydrate(state, action.state.get('settings'));
+ case NOTIFICATIONS_FILTER_SET:
case SETTING_CHANGE:
return state
.setIn(action.path, action.value)