about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/javascript/flavours/glitch/components/spoilers.js50
-rw-r--r--app/javascript/flavours/glitch/components/status.js20
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js11
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js55
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/navigation/index.js20
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js23
-rw-r--r--app/javascript/flavours/glitch/reducers/compose.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/reports.js13
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js30
-rw-r--r--app/javascript/flavours/glitch/styles/components/error_boundary.scss36
-rw-r--r--app/javascript/flavours/glitch/styles/components/modal.scss39
-rw-r--r--app/javascript/flavours/glitch/styles/components/status.scss15
-rw-r--r--app/javascript/flavours/glitch/util/backend_links.js1
-rw-r--r--app/models/status.rb2
-rw-r--r--app/services/backup_service.rb2
-rw-r--r--app/services/block_service.rb2
-rw-r--r--app/workers/web/push_notification_worker.rb2
18 files changed, 289 insertions, 34 deletions
diff --git a/app/javascript/flavours/glitch/components/spoilers.js b/app/javascript/flavours/glitch/components/spoilers.js
new file mode 100644
index 000000000..8527403c1
--- /dev/null
+++ b/app/javascript/flavours/glitch/components/spoilers.js
@@ -0,0 +1,50 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+export default
+class Spoilers extends React.PureComponent {
+  static propTypes = {
+    spoilerText: PropTypes.string,
+    children: PropTypes.node,
+  };
+
+  state = {
+    hidden: true,
+  }
+
+  handleSpoilerClick = () => {
+    this.setState({ hidden: !this.state.hidden });
+  }
+
+  render () {
+    const { spoilerText, children } = this.props;
+    const { hidden } = this.state;
+
+      const toggleText = hidden ?
+        <FormattedMessage
+          id='status.show_more'
+          defaultMessage='Show more'
+          key='0'
+        /> :
+        <FormattedMessage
+          id='status.show_less'
+          defaultMessage='Show less'
+          key='0'
+        />;
+
+    return ([
+      <p className='spoiler__text'>
+        {spoilerText}
+        {' '}
+        <button tabIndex='0' className='status__content__spoiler-link' onClick={this.handleSpoilerClick}>
+          {toggleText}
+        </button>
+      </p>,
+      <div className={`status__content__spoiler ${!hidden ? 'status__content__spoiler--visible' : ''}`}>
+        {children}
+      </div>
+    ]);
+  }
+}
+
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 022ae6de8..e94ce6dfe 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -106,6 +106,7 @@ class Status extends ImmutablePureComponent {
     statusId: undefined,
     revealBehindCW: undefined,
     showCard: false,
+    forceFilter: undefined,
   }
 
   // Avoid checking props that are functions (and whose equality will always
@@ -126,6 +127,7 @@ class Status extends ImmutablePureComponent {
     'isExpanded',
     'isCollapsed',
     'showMedia',
+    'forceFilter',
   ]
 
   //  If our settings have changed to disable collapsed statuses, then we
@@ -427,6 +429,15 @@ class Status extends ImmutablePureComponent {
     this.handleToggleMediaVisibility();
   }
 
+  handleUnfilterClick = e => {
+    const { onUnfilter, status } = this.props;
+    onUnfilter(status.get('reblog') ? status.get('reblog') : status, () => this.setState({ forceFilter: false }));
+  }
+
+  handleFilterClick = () => {
+    this.setState({ forceFilter: true });
+  }
+
   handleRef = c => {
     this.node = c;
   }
@@ -485,7 +496,7 @@ class Status extends ImmutablePureComponent {
       );
     }
 
-    if (status.get('filtered') || status.getIn(['reblog', 'filtered'])) {
+    if ((status.get('filtered') || status.getIn(['reblog', 'filtered'])) && (this.state.forceFilter === true || settings.get('filtering_behavior') !== 'content_warning')) {
       const minHandlers = this.props.muted ? {} : {
         moveUp: this.handleHotkeyMoveUp,
         moveDown: this.handleHotkeyMoveDown,
@@ -495,6 +506,12 @@ class Status extends ImmutablePureComponent {
         <HotKeys handlers={minHandlers}>
           <div className='status__wrapper status__wrapper--filtered focusable' tabIndex='0' ref={this.handleRef}>
             <FormattedMessage id='status.filtered' defaultMessage='Filtered' />
+            {settings.get('filtering_behavior') !== 'upstream' && ' '}
+            {settings.get('filtering_behavior') !== 'upstream' && (
+              <button className='status__wrapper--filtered__button' onClick={this.handleUnfilterClick}>
+                <FormattedMessage id='status.show_filter_reason' defaultMessage='(show why)' />
+              </button>
+            )}
           </div>
         </HotKeys>
       );
@@ -689,6 +706,7 @@ class Status extends ImmutablePureComponent {
               account={status.get('account')}
               showReplyCount={settings.get('show_reply_count')}
               directMessage={!!otherAccounts}
+              onFilter={this.handleFilterClick}
             />
           ) : null}
           {notification ? (
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index c424fbde1..4ef518f5e 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -35,6 +35,7 @@ const messages = defineMessages({
   admin_account: { id: 'status.admin_account', defaultMessage: 'Open moderation interface for @{name}' },
   admin_status: { id: 'status.admin_status', defaultMessage: 'Open this status in the moderation interface' },
   copy: { id: 'status.copy', defaultMessage: 'Copy link to status' },
+  hide: { id: 'status.hide', defaultMessage: 'Hide toot' },
 });
 
 const obfuscatedCount = count => {
@@ -69,6 +70,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
     onMuteConversation: PropTypes.func,
     onPin: PropTypes.func,
     onBookmark: PropTypes.func,
+    onFilter: PropTypes.func,
     withDismiss: PropTypes.bool,
     showReplyCount: PropTypes.bool,
     directMessage: PropTypes.bool,
@@ -191,6 +193,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
     }
   }
 
+  handleFilterClick = () => {
+    this.props.onFilter();
+  }
+
   render () {
     const { status, intl, withDismiss, showReplyCount, directMessage } = this.props;
 
@@ -263,6 +269,10 @@ export default class StatusActionBar extends ImmutablePureComponent {
       <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.share)} icon='share-alt' onClick={this.handleShareClick} />
     );
 
+    const filterButton = status.get('filtered') && (
+      <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.hide)} icon='eye' onClick={this.handleFilterClick} />
+    );
+
     let replyButton = (
       <IconButton
         className='status__action-bar-button'
@@ -288,6 +298,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
           <IconButton key='favourite-button' className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />,
           shareButton,
           <IconButton key='bookmark-button' className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />,
+          filterButton,
           <div key='dropdown-button' className='status__action-bar-dropdown'>
             <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
           </div>,
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index a6069cb90..bded66d09 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -1,7 +1,8 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import Status from 'flavours/glitch/components/status';
-import { makeGetStatus } from 'flavours/glitch/selectors';
+import { List as ImmutableList } from 'immutable';
+import { makeGetStatus, regexFromFilters, toServerSideType } from 'flavours/glitch/selectors';
 import {
   replyCompose,
   mentionCompose,
@@ -25,7 +26,11 @@ import { openModal } from 'flavours/glitch/actions/modal';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import { boostModal, favouriteModal, deleteModal } from 'flavours/glitch/util/initial_state';
+import { filterEditLink } from 'flavours/glitch/util/backend_links';
 import { showAlertForError } from '../actions/alerts';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import Spoilers from '../components/spoilers';
+import Icon from 'flavours/glitch/components/icon';
 
 const messages = defineMessages({
   deleteConfirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
@@ -36,6 +41,10 @@ const messages = defineMessages({
   replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
   replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
   blockAndReport: { id: 'confirmations.block.block_and_report', defaultMessage: 'Block & Report' },
+  unfilterConfirm: { id: 'confirmations.unfilter.confirm', defaultMessage: 'Show' },
+  author: { id: 'confirmations.unfilter.author', defaultMessage: 'Author' },
+  matchingFilters: { id: 'confirmations.unfilter.filters', defaultMessage: 'Matching {count, plural, one {filter} other {filters}}' },
+  editFilter: { id: 'confirmations.unfilter.edit_filter', defaultMessage: 'Edit filter' },
 });
 
 const makeMapStateToProps = () => {
@@ -69,7 +78,7 @@ const makeMapStateToProps = () => {
   return mapStateToProps;
 };
 
-const mapDispatchToProps = (dispatch, { intl }) => ({
+const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
 
   onReply (status, router) {
     dispatch((_, getState) => {
@@ -189,6 +198,48 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }));
   },
 
+  onUnfilter (status, onConfirm) {
+    dispatch((_, getState) => {
+      let state = getState();
+      const serverSideType = toServerSideType(contextType);
+      const enabledFilters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray();
+      const searchIndex = status.get('search_index');
+      const matchingFilters = enabledFilters.filter(filter => regexFromFilters([filter]).test(searchIndex));
+      dispatch(openModal('CONFIRM', {
+        message: [
+          <FormattedMessage id='confirmations.unfilter' defaultMessage='Information about this filtered toot' />,
+          <div className='filtered-status-info'>
+            <Spoilers spoilerText={intl.formatMessage(messages.author)}>
+              <AccountContainer id={status.getIn(['account', 'id'])} />
+            </Spoilers>
+            <Spoilers spoilerText={intl.formatMessage(messages.matchingFilters, {count: matchingFilters.size})}>
+              <ul>
+                {matchingFilters.map(filter => (
+                  <li>
+                    {filter.get('phrase')}
+                    {!!filterEditLink && ' '}
+                    {!!filterEditLink && (
+                      <a
+                        target='_blank'
+                        className='filtered-status-edit-link'
+                        title={intl.formatMessage(messages.editFilter)}
+                        href={filterEditLink(filter.get('id'))}
+                      >
+                        <Icon icon='pencil' />
+                      </a>
+                    )}
+                  </li>
+                ))}
+              </ul>
+            </Spoilers>
+          </div>
+        ],
+        confirm: intl.formatMessage(messages.unfilterConfirm),
+        onConfirm: onConfirm,
+      }));
+    });
+  },
+
   onReport (status) {
     dispatch(initReport(status.get('account'), status));
   },
diff --git a/app/javascript/flavours/glitch/features/local_settings/navigation/index.js b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js
index 01368abad..47f3d6d15 100644
--- a/app/javascript/flavours/glitch/features/local_settings/navigation/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/navigation/index.js
@@ -13,6 +13,7 @@ const messages = defineMessages({
   general: {  id: 'settings.general', defaultMessage: 'General' },
   compose: {  id: 'settings.compose_box_opts', defaultMessage: 'Compose box' },
   content_warnings: { id: 'settings.content_warnings', defaultMessage: 'Content Warnings' },
+  filters: { id: 'settings.filters', defaultMessage: 'Filters' },
   collapsed: { id: 'settings.collapsed_statuses', defaultMessage: 'Collapsed toots' },
   media: { id: 'settings.media', defaultMessage: 'Media' },
   preferences: { id: 'settings.preferences', defaultMessage: 'Preferences' },
@@ -60,27 +61,34 @@ export default class LocalSettingsNavigation extends React.PureComponent {
           active={index === 3}
           index={3}
           onNavigate={onNavigate}
-          icon='angle-double-up'
-          title={intl.formatMessage(messages.collapsed)}
+          icon='filter'
+          title={intl.formatMessage(messages.filters)}
         />
         <LocalSettingsNavigationItem
           active={index === 4}
           index={4}
           onNavigate={onNavigate}
+          icon='angle-double-up'
+          title={intl.formatMessage(messages.collapsed)}
+        />
+        <LocalSettingsNavigationItem
+          active={index === 5}
+          index={5}
+          onNavigate={onNavigate}
           icon='image'
           title={intl.formatMessage(messages.media)}
         />
         <LocalSettingsNavigationItem
-          active={index === 5}
+          active={index === 6}
           href={ preferencesLink }
-          index={5}
+          index={6}
           icon='cog'
           title={intl.formatMessage(messages.preferences)}
         />
         <LocalSettingsNavigationItem
-          active={index === 6}
+          active={index === 7}
           className='close'
-          index={6}
+          index={7}
           onNavigate={onClose}
           icon='times'
           title={intl.formatMessage(messages.close)}
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index 23499455b..910cb5346 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -21,6 +21,10 @@ const messages = defineMessages({
   side_arm_copy: { id: 'settings.side_arm_reply_mode.copy', defaultMessage: 'Copy privacy setting of the toot being replied to' },
   side_arm_restrict: { id: 'settings.side_arm_reply_mode.restrict', defaultMessage: 'Restrict privacy setting to that of the toot being replied to' },
   regexp: { id: 'settings.content_warnings.regexp', defaultMessage: 'Regular expression' },
+  filters_drop: { id: 'settings.filtering_behavior.drop', defaultMessage: 'Hide filtered toots completely' },
+  filters_upstream: { id: 'settings.filtering_behavior.upstream', defaultMessage: 'Show "filtered" like vanilla Mastodon' },
+  filters_hide: { id: 'settings.filtering_behavior.hide', defaultMessage: 'Show "filtered" and add a button to display why' },
+  filters_cw: { id: 'settings.filtering_behavior.cw', defaultMessage: 'Still display the post, and add filtered words to content warning' },
 });
 
 @injectIntl
@@ -223,6 +227,25 @@ export default class LocalSettingsPage extends React.PureComponent {
         </LocalSettingsPageItem>
       </div>
     ),
+    ({ intl, onChange, settings }) => (
+      <div className='glitch local-settings__page filters'>
+        <h1><FormattedMessage id='settings.filters' defaultMessage='Filters' /></h1>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['filtering_behavior']}
+          id='mastodon-settings--filters-behavior'
+          onChange={onChange}
+          options={[
+            { value: 'drop', message: intl.formatMessage(messages.filters_drop) },
+            { value: 'upstream', message: intl.formatMessage(messages.filters_upstream) },
+            { value: 'hide', message: intl.formatMessage(messages.filters_hide) },
+            { value: 'content_warning', message: intl.formatMessage(messages.filters_cw) }
+          ]}
+        >
+          <FormattedMessage id='settings.filtering_behavior' defaultMessage='Filtering behavior' />
+        </LocalSettingsPageItem>
+      </div>
+    ),
     ({ onChange, settings }) => (
       <div className='glitch local-settings__page collapsed'>
         <h1><FormattedMessage id='settings.collapsed_statuses' defaultMessage='Collapsed toots' /></h1>
diff --git a/app/javascript/flavours/glitch/reducers/compose.js b/app/javascript/flavours/glitch/reducers/compose.js
index a47b8b7bd..5f176b832 100644
--- a/app/javascript/flavours/glitch/reducers/compose.js
+++ b/app/javascript/flavours/glitch/reducers/compose.js
@@ -182,6 +182,7 @@ function continueThread (state, status) {
     map.set('privacy', status.visibility);
     map.set('sensitive', false);
     map.update('media_attachments', list => list.clear());
+    map.set('poll', null);
     map.set('idempotencyKey', uuid());
     map.set('focusDate', new Date());
     map.set('caretPosition', null);
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index 68e1c8424..6fd3d901b 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -21,6 +21,7 @@ const initialState = ImmutableMap({
   inline_preview_cards: true,
   hicolor_privacy_icons: false,
   show_content_type_choice: false,
+  filtering_behavior: 'hide',
   content_warnings : ImmutableMap({
     auto_unfold : false,
     filter      : null,
diff --git a/app/javascript/flavours/glitch/reducers/reports.js b/app/javascript/flavours/glitch/reducers/reports.js
index fdcfb14a0..1f7f3f273 100644
--- a/app/javascript/flavours/glitch/reducers/reports.js
+++ b/app/javascript/flavours/glitch/reducers/reports.js
@@ -8,6 +8,9 @@ import {
   REPORT_COMMENT_CHANGE,
   REPORT_FORWARD_CHANGE,
 } from 'flavours/glitch/actions/reports';
+import {
+  TIMELINE_DELETE,
+} from 'flavours/glitch/actions/timelines';
 import { Map as ImmutableMap, Set as ImmutableSet } from 'immutable';
 
 const initialState = ImmutableMap({
@@ -20,6 +23,14 @@ const initialState = ImmutableMap({
   }),
 });
 
+const deleteStatus = (state, id, references) => {
+  references.forEach(ref => {
+    state = deleteStatus(state, ref[0], []);
+  });
+
+  return state.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.remove(id));
+};
+
 export default function reports(state = initialState, action) {
   switch(action.type) {
   case REPORT_INIT:
@@ -58,6 +69,8 @@ export default function reports(state = initialState, action) {
       map.setIn(['new', 'comment'], '');
       map.setIn(['new', 'isSubmitting'], false);
     });
+  case TIMELINE_DELETE:
+    return deleteStatus(state, action.id, action.references);
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index 9e4582532..b414cd5e5 100644
--- a/app/javascript/flavours/glitch/selectors/index.js
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -1,3 +1,4 @@
+import escapeTextContentForBrowser from 'escape-html';
 import { createSelector } from 'reselect';
 import { List as ImmutableList, is } from 'immutable';
 import { me } from 'flavours/glitch/util/initial_state';
@@ -20,7 +21,7 @@ export const makeGetAccount = () => {
   });
 };
 
-const toServerSideType = columnType => {
+export const toServerSideType = columnType => {
   switch (columnType) {
   case 'home':
   case 'notifications':
@@ -39,7 +40,7 @@ const toServerSideType = columnType => {
 const escapeRegExp = string =>
   string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
 
-const regexFromFilters = filters => {
+export const regexFromFilters = filters => {
   if (filters.size === 0) {
     return null;
   }
@@ -89,10 +90,13 @@ export const makeGetStatus = () => {
       (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
+      (state, _) => state.getIn(['local_settings', 'filtering_behavior']),
+      (state, _) => state.get('filters', ImmutableList()),
+      (_, { contextType }) => contextType,
       getFiltersRegex,
     ],
 
-    (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
+    (statusBase, statusReblog, accountBase, accountReblog, filteringBehavior, filters, contextType, filtersRegex) => {
       if (!statusBase) {
         return null;
       }
@@ -116,6 +120,26 @@ export const makeGetStatus = () => {
 
       filtered = filtered || regex && regex.test(statusBase.get('search_index'));
 
+      if (filtered && filteringBehavior === 'drop') {
+        return null;
+      } else if (filtered && filteringBehavior === 'content_warning') {
+        let spoilerText = (statusReblog || statusBase).get('spoiler_text', '');
+        const searchIndex = (statusReblog || statusBase).get('search_index');
+        const serverSideType = toServerSideType(contextType);
+        const enabledFilters = filters.filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date()))).toArray();
+        const matchingFilters = enabledFilters.filter(filter => {
+          const regexp = regexFromFilters([filter]);
+          return regexp.test(searchIndex) && !regexp.test(spoilerText);
+        });
+        if (statusReblog) {
+          statusReblog = statusReblog.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', '));
+          statusReblog = statusReblog.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', '));
+        } else {
+          statusBase = statusBase.set('spoiler_text', matchingFilters.map(filter => filter.get('phrase')).concat([spoilerText]).filter(cw => !!cw).join(', '));
+          statusBase = statusBase.update('spoilerHtml', '', spoilerText => matchingFilters.map(filter => escapeTextContentForBrowser(filter.get('phrase'))).concat([spoilerText]).filter(cw => !!cw).join(', '));
+        }
+      }
+
       return statusBase.withMutations(map => {
         map.set('reblog', statusReblog);
         map.set('account', accountBase);
diff --git a/app/javascript/flavours/glitch/styles/components/error_boundary.scss b/app/javascript/flavours/glitch/styles/components/error_boundary.scss
index f9bf425f8..3176690e2 100644
--- a/app/javascript/flavours/glitch/styles/components/error_boundary.scss
+++ b/app/javascript/flavours/glitch/styles/components/error_boundary.scss
@@ -1,4 +1,8 @@
 .error-boundary {
+  color: $primary-text-color;
+  font-size: 15px;
+  line-height: 20px;
+
   h1 {
     font-size: 26px;
     line-height: 36px;
@@ -6,27 +10,21 @@
     margin-bottom: 8px;
   }
 
-  p {
+  a {
     color: $primary-text-color;
-    font-size: 15px;
-    line-height: 20px;
-
-    a {
-      color: $primary-text-color;
-      text-decoration: underline;
-    }
+    text-decoration: underline;
+  }
 
-    ul {
-      list-style: disc;
-      margin-left: 0;
-      padding-left: 1em;
-    }
+  ul {
+    list-style: disc;
+    margin-left: 0;
+    padding-left: 1em;
+  }
 
-    textarea.web_app_crash-stacktrace {
-      width: 100%;
-      resize: none;
-      white-space: pre;
-      font-family: $font-monospace, monospace;
-    }
+  textarea.web_app_crash-stacktrace {
+    width: 100%;
+    resize: none;
+    white-space: pre;
+    font-family: $font-monospace, monospace;
   }
 }
diff --git a/app/javascript/flavours/glitch/styles/components/modal.scss b/app/javascript/flavours/glitch/styles/components/modal.scss
index 65b2e75f0..a98efee9f 100644
--- a/app/javascript/flavours/glitch/styles/components/modal.scss
+++ b/app/javascript/flavours/glitch/styles/components/modal.scss
@@ -820,3 +820,42 @@
     left: 0;
   }
 }
+
+.filtered-status-info {
+  text-align: start;
+
+  .spoiler__text {
+    margin-top: 20px;
+  }
+
+  .account {
+    border-bottom: 0;
+  }
+
+  .account__display-name strong {
+    color: $inverted-text-color;
+  }
+
+  .status__content__spoiler {
+    display: none;
+
+    &--visible {
+      display: flex;
+    }
+  }
+
+  ul {
+    padding: 10px;
+    margin-left: 12px;
+    list-style: disc inside;
+  }
+
+  .filtered-status-edit-link {
+    color: $action-button-color;
+    text-decoration: none;
+
+    &:hover {
+      text-decoration: underline
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/components/status.scss b/app/javascript/flavours/glitch/styles/components/status.scss
index fa115f21b..4ffbb2c21 100644
--- a/app/javascript/flavours/glitch/styles/components/status.scss
+++ b/app/javascript/flavours/glitch/styles/components/status.scss
@@ -996,3 +996,18 @@ a.status-card.compact:hover {
     }
   }
 }
+
+.status__wrapper--filtered__button {
+  display: inline;
+  color: lighten($ui-highlight-color, 8%);
+  border: 0;
+  background: transparent;
+  padding: 0;
+  font-size: inherit;
+  line-height: inherit;
+
+  &:hover,
+  &:active {
+    text-decoration: underline;
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/backend_links.js b/app/javascript/flavours/glitch/util/backend_links.js
index 4fc03f919..bc82197be 100644
--- a/app/javascript/flavours/glitch/util/backend_links.js
+++ b/app/javascript/flavours/glitch/util/backend_links.js
@@ -4,3 +4,4 @@ export const signOutLink = '/auth/sign_out';
 export const termsLink = '/terms';
 export const accountAdminLink = (id) => `/admin/accounts/${id}`;
 export const statusAdminLink = (account_id, status_id) => `/admin/accounts/${account_id}/statuses/${status_id}`;
+export const filterEditLink = (id) => `/filters/${id}/edit`;
diff --git a/app/models/status.rb b/app/models/status.rb
index 5ddce72de..5adccb722 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -82,7 +82,7 @@ class Status < ApplicationRecord
   default_scope { recent }
 
   scope :recent, -> { reorder(id: :desc) }
-  scope :remote, -> { where(local: false).or(where.not(uri: nil)) }
+  scope :remote, -> { where(local: false).where.not(uri: nil) }
   scope :local,  -> { where(local: true).or(where(uri: nil)) }
 
   scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb
index bd9e77223..cc9fb1f4e 100644
--- a/app/services/backup_service.rb
+++ b/app/services/backup_service.rb
@@ -164,5 +164,7 @@ class BackupService < BaseService
         io.write(buffer)
       end
     end
+  rescue Errno::ENOENT
+    Rails.logger.warn "Could not backup file #{filename}: file not found"
   end
 end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 9050a4858..0d9a6eccd 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -8,7 +8,7 @@ class BlockService < BaseService
 
     UnfollowService.new.call(account, target_account) if account.following?(target_account)
     UnfollowService.new.call(target_account, account) if target_account.following?(account)
-    RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
+    RejectFollowService.new.call(target_account, account) if target_account.requested?(account)
 
     block = account.block!(target_account)
 
diff --git a/app/workers/web/push_notification_worker.rb b/app/workers/web/push_notification_worker.rb
index 8e8a35973..901043975 100644
--- a/app/workers/web/push_notification_worker.rb
+++ b/app/workers/web/push_notification_worker.rb
@@ -3,7 +3,7 @@
 class Web::PushNotificationWorker
   include Sidekiq::Worker
 
-  sidekiq_options backtrace: true
+  sidekiq_options backtrace: true, retry: 5
 
   def perform(subscription_id, notification_id)
     subscription = ::Web::PushSubscription.find(subscription_id)