about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--app/javascript/mastodon/actions/push_notifications.js57
-rw-r--r--app/javascript/mastodon/actions/push_notifications/index.js23
-rw-r--r--app/javascript/mastodon/actions/push_notifications/registerer.js149
-rw-r--r--app/javascript/mastodon/actions/push_notifications/setter.js34
-rw-r--r--app/javascript/mastodon/components/account.js1
-rw-r--r--app/javascript/mastodon/features/getting_started/index.js13
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js1
-rw-r--r--app/javascript/mastodon/features/notifications/containers/column_settings_container.js9
-rw-r--r--app/javascript/mastodon/features/ui/components/column_link.js1
-rw-r--r--app/javascript/mastodon/main.js6
-rw-r--r--app/javascript/mastodon/reducers/push_notifications.js4
-rw-r--r--app/javascript/mastodon/web_push_subscription.js129
-rw-r--r--app/javascript/styles/mastodon/components.scss17
-rwxr-xr-xapp/views/layouts/application.html.haml1
-rw-r--r--lib/tasks/mastodon.rake73
15 files changed, 311 insertions, 207 deletions
diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js
deleted file mode 100644
index de06385f9..000000000
--- a/app/javascript/mastodon/actions/push_notifications.js
+++ /dev/null
@@ -1,57 +0,0 @@
-import axios from 'axios';
-import { pushNotificationsSetting } from '../settings';
-
-export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
-export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
-export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
-export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE';
-
-export function setBrowserSupport (value) {
-  return {
-    type: SET_BROWSER_SUPPORT,
-    value,
-  };
-}
-
-export function setSubscription (subscription) {
-  return {
-    type: SET_SUBSCRIPTION,
-    subscription,
-  };
-}
-
-export function clearSubscription () {
-  return {
-    type: CLEAR_SUBSCRIPTION,
-  };
-}
-
-export function changeAlerts(key, value) {
-  return dispatch => {
-    dispatch({
-      type: ALERTS_CHANGE,
-      key,
-      value,
-    });
-
-    dispatch(saveSettings());
-  };
-}
-
-export function saveSettings() {
-  return (_, getState) => {
-    const state = getState().get('push_notifications');
-    const subscription = state.get('subscription');
-    const alerts = state.get('alerts');
-    const data = { alerts };
-
-    axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
-      data,
-    }).then(() => {
-      const me = getState().getIn(['meta', 'me']);
-      if (me) {
-        pushNotificationsSetting.set(me, data);
-      }
-    });
-  };
-}
diff --git a/app/javascript/mastodon/actions/push_notifications/index.js b/app/javascript/mastodon/actions/push_notifications/index.js
new file mode 100644
index 000000000..376b55b62
--- /dev/null
+++ b/app/javascript/mastodon/actions/push_notifications/index.js
@@ -0,0 +1,23 @@
+import {
+  SET_BROWSER_SUPPORT,
+  SET_SUBSCRIPTION,
+  CLEAR_SUBSCRIPTION,
+  SET_ALERTS,
+  setAlerts,
+} from './setter';
+import { register, saveSettings } from './registerer';
+
+export {
+  SET_BROWSER_SUPPORT,
+  SET_SUBSCRIPTION,
+  CLEAR_SUBSCRIPTION,
+  SET_ALERTS,
+  register,
+};
+
+export function changeAlerts(key, value) {
+  return dispatch => {
+    dispatch(setAlerts(key, value));
+    dispatch(saveSettings());
+  };
+}
diff --git a/app/javascript/mastodon/actions/push_notifications/registerer.js b/app/javascript/mastodon/actions/push_notifications/registerer.js
new file mode 100644
index 000000000..f851c311c
--- /dev/null
+++ b/app/javascript/mastodon/actions/push_notifications/registerer.js
@@ -0,0 +1,149 @@
+import axios from 'axios';
+import { pushNotificationsSetting } from '../../settings';
+import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
+
+// Taken from https://www.npmjs.com/package/web-push
+const urlBase64ToUint8Array = (base64String) => {
+  const padding = '='.repeat((4 - base64String.length % 4) % 4);
+  const base64 = (base64String + padding)
+    .replace(/\-/g, '+')
+    .replace(/_/g, '/');
+
+  const rawData = window.atob(base64);
+  const outputArray = new Uint8Array(rawData.length);
+
+  for (let i = 0; i < rawData.length; ++i) {
+    outputArray[i] = rawData.charCodeAt(i);
+  }
+  return outputArray;
+};
+
+const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
+
+const getRegistration = () => navigator.serviceWorker.ready;
+
+const getPushSubscription = (registration) =>
+  registration.pushManager.getSubscription()
+    .then(subscription => ({ registration, subscription }));
+
+const subscribe = (registration) =>
+  registration.pushManager.subscribe({
+    userVisibleOnly: true,
+    applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
+  });
+
+const unsubscribe = ({ registration, subscription }) =>
+  subscription ? subscription.unsubscribe().then(() => registration) : registration;
+
+const sendSubscriptionToBackend = (subscription, me) => {
+  const params = { subscription };
+
+  if (me) {
+    const data = pushNotificationsSetting.get(me);
+    if (data) {
+      params.data = data;
+    }
+  }
+
+  return axios.post('/api/web/push_subscriptions', params).then(response => response.data);
+};
+
+// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
+const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
+
+export default function register () {
+  return (dispatch, getState) => {
+    dispatch(setBrowserSupport(supportsPushNotifications));
+    const me = getState().getIn(['meta', 'me']);
+
+    if (me && !pushNotificationsSetting.get(me)) {
+      const alerts = getState().getIn(['push_notifications', 'alerts']);
+      if (alerts) {
+        pushNotificationsSetting.set(me, { alerts: alerts });
+      }
+    }
+
+    if (supportsPushNotifications) {
+      if (!getApplicationServerKey()) {
+        console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
+        return;
+      }
+
+      getRegistration()
+        .then(getPushSubscription)
+        .then(({ registration, subscription }) => {
+          if (subscription !== null) {
+            // We have a subscription, check if it is still valid
+            const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
+            const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
+            const serverEndpoint = getState().getIn(['push_notifications', 'subscription', 'endpoint']);
+
+            // If the VAPID public key did not change and the endpoint corresponds
+            // to the endpoint saved in the backend, the subscription is valid
+            if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
+              return subscription;
+            } else {
+              // Something went wrong, try to subscribe again
+              return unsubscribe({ registration, subscription }).then(subscribe).then(
+                subscription => sendSubscriptionToBackend(subscription, me));
+            }
+          }
+
+          // No subscription, try to subscribe
+          return subscribe(registration).then(
+            subscription => sendSubscriptionToBackend(subscription, me));
+        })
+        .then(subscription => {
+          // If we got a PushSubscription (and not a subscription object from the backend)
+          // it means that the backend subscription is valid (and was set during hydration)
+          if (!(subscription instanceof PushSubscription)) {
+            dispatch(setSubscription(subscription));
+            if (me) {
+              pushNotificationsSetting.set(me, { alerts: subscription.alerts });
+            }
+          }
+        })
+        .catch(error => {
+          if (error.code === 20 && error.name === 'AbortError') {
+            console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
+          } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
+            console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
+          }
+
+          // Clear alerts and hide UI settings
+          dispatch(clearSubscription());
+          if (me) {
+            pushNotificationsSetting.remove(me);
+          }
+
+          try {
+            getRegistration()
+              .then(getPushSubscription)
+              .then(unsubscribe);
+          } catch (e) {
+
+          }
+        });
+    } else {
+      console.warn('Your browser does not support Web Push Notifications.');
+    }
+  };
+}
+
+export function saveSettings() {
+  return (_, getState) => {
+    const state = getState().get('push_notifications');
+    const subscription = state.get('subscription');
+    const alerts = state.get('alerts');
+    const data = { alerts };
+
+    axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
+      data,
+    }).then(() => {
+      const me = getState().getIn(['meta', 'me']);
+      if (me) {
+        pushNotificationsSetting.set(me, data);
+      }
+    });
+  };
+}
diff --git a/app/javascript/mastodon/actions/push_notifications/setter.js b/app/javascript/mastodon/actions/push_notifications/setter.js
new file mode 100644
index 000000000..a2cc41c5a
--- /dev/null
+++ b/app/javascript/mastodon/actions/push_notifications/setter.js
@@ -0,0 +1,34 @@
+export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT';
+export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION';
+export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION';
+export const SET_ALERTS = 'PUSH_NOTIFICATIONS_SET_ALERTS';
+
+export function setBrowserSupport (value) {
+  return {
+    type: SET_BROWSER_SUPPORT,
+    value,
+  };
+}
+
+export function setSubscription (subscription) {
+  return {
+    type: SET_SUBSCRIPTION,
+    subscription,
+  };
+}
+
+export function clearSubscription () {
+  return {
+    type: CLEAR_SUBSCRIPTION,
+  };
+}
+
+export function setAlerts (key, value) {
+  return dispatch => {
+    dispatch({
+      type: SET_ALERTS,
+      key,
+      value,
+    });
+  };
+}
diff --git a/app/javascript/mastodon/components/account.js b/app/javascript/mastodon/components/account.js
index b0479db4f..81459731c 100644
--- a/app/javascript/mastodon/components/account.js
+++ b/app/javascript/mastodon/components/account.js
@@ -27,6 +27,7 @@ export default class Account extends ImmutablePureComponent {
     onFollow: PropTypes.func.isRequired,
     onBlock: PropTypes.func.isRequired,
     onMute: PropTypes.func.isRequired,
+    onMuteNotifications: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
     hidden: PropTypes.bool,
   };
diff --git a/app/javascript/mastodon/features/getting_started/index.js b/app/javascript/mastodon/features/getting_started/index.js
index 4b4c02bcc..11fb6d365 100644
--- a/app/javascript/mastodon/features/getting_started/index.js
+++ b/app/javascript/mastodon/features/getting_started/index.js
@@ -71,19 +71,22 @@ export default class GettingStarted extends ImmutablePureComponent {
     navItems = navItems.concat([
       <ColumnLink key='4' icon='star' text={intl.formatMessage(messages.favourites)} to='/favourites' />,
       <ColumnLink key='5' icon='thumb-tack' text={intl.formatMessage(messages.pins)} to='/pinned' />,
-      <ColumnLink key='9' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />,
+      <ColumnLink key='6' icon='bars' text={intl.formatMessage(messages.lists)} to='/lists' />,
     ]);
 
     if (myAccount.get('locked')) {
-      navItems.push(<ColumnLink key='6' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
+      navItems.push(<ColumnLink key='7' icon='users' text={intl.formatMessage(messages.follow_requests)} to='/follow_requests' />);
     }
 
     navItems = navItems.concat([
-      <ColumnLink key='7' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
-      <ColumnLink key='8' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
-      <ColumnLink key='10' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' hideOnMobile />,
+      <ColumnLink key='8' icon='volume-off' text={intl.formatMessage(messages.mutes)} to='/mutes' />,
+      <ColumnLink key='9' icon='ban' text={intl.formatMessage(messages.blocks)} to='/blocks' />,
     ]);
 
+    if (multiColumn) {
+      navItems.push(<ColumnLink key='10' icon='question' text={intl.formatMessage(messages.keyboard_shortcuts)} to='/keyboard-shortcuts' />);
+    }
+
     return (
       <Column icon='asterisk' heading={intl.formatMessage(messages.heading)} hideHeadingOnMobile>
         <div className='getting-started__wrapper'>
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index 57cded4f1..23545185c 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -11,7 +11,6 @@ export default class ColumnSettings extends React.PureComponent {
     settings: ImmutablePropTypes.map.isRequired,
     pushSettings: ImmutablePropTypes.map.isRequired,
     onChange: PropTypes.func.isRequired,
-    onSave: PropTypes.func.isRequired,
     onClear: PropTypes.func.isRequired,
   };
 
diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
index d4ead7881..f4c63fee6 100644
--- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
+++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js
@@ -1,9 +1,9 @@
 import { connect } from 'react-redux';
 import { defineMessages, injectIntl } from 'react-intl';
 import ColumnSettings from '../components/column_settings';
-import { changeSetting, saveSettings } from '../../../actions/settings';
+import { changeSetting } from '../../../actions/settings';
 import { clearNotifications } from '../../../actions/notifications';
-import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications';
+import { changeAlerts as changePushNotifications } from '../../../actions/push_notifications';
 import { openModal } from '../../../actions/modal';
 
 const messages = defineMessages({
@@ -26,11 +26,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
     }
   },
 
-  onSave () {
-    dispatch(saveSettings());
-    dispatch(savePushNotificationSettings());
-  },
-
   onClear () {
     dispatch(openModal('CONFIRM', {
       message: intl.formatMessage(messages.clearMessage),
diff --git a/app/javascript/mastodon/features/ui/components/column_link.js b/app/javascript/mastodon/features/ui/components/column_link.js
index 5425219c4..a90616213 100644
--- a/app/javascript/mastodon/features/ui/components/column_link.js
+++ b/app/javascript/mastodon/features/ui/components/column_link.js
@@ -26,7 +26,6 @@ ColumnLink.propTypes = {
   to: PropTypes.string,
   href: PropTypes.string,
   method: PropTypes.string,
-  hideOnMobile: PropTypes.bool,
 };
 
 export default ColumnLink;
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
index 23b6b04fa..9b18465f5 100644
--- a/app/javascript/mastodon/main.js
+++ b/app/javascript/mastodon/main.js
@@ -1,5 +1,5 @@
-import * as WebPushSubscription from './web_push_subscription';
-import Mastodon from './containers/mastodon';
+import { register as registerPushNotifications } from './actions/push_notifications';
+import { default as Mastodon, store } from './containers/mastodon';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ready from './ready';
@@ -25,7 +25,7 @@ function main() {
     if (process.env.NODE_ENV === 'production') {
       // avoid offline in dev mode because it's harder to debug
       require('offline-plugin/runtime').install();
-      WebPushSubscription.register();
+      store.dispatch(registerPushNotifications.register());
     }
     perf.stop('main()');
   });
diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js
index 31a40d246..c15b38fe4 100644
--- a/app/javascript/mastodon/reducers/push_notifications.js
+++ b/app/javascript/mastodon/reducers/push_notifications.js
@@ -1,5 +1,5 @@
 import { STORE_HYDRATE } from '../actions/store';
-import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from '../actions/push_notifications';
+import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, SET_ALERTS } from '../actions/push_notifications';
 import Immutable from 'immutable';
 
 const initialState = Immutable.Map({
@@ -43,7 +43,7 @@ export default function push_subscriptions(state = initialState, action) {
     return state.set('browserSupport', action.value);
   case CLEAR_SUBSCRIPTION:
     return initialState;
-  case ALERTS_CHANGE:
+  case SET_ALERTS:
     return state.setIn(action.key, action.value);
   default:
     return state;
diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js
deleted file mode 100644
index 17aca4060..000000000
--- a/app/javascript/mastodon/web_push_subscription.js
+++ /dev/null
@@ -1,129 +0,0 @@
-import axios from 'axios';
-import { store } from './containers/mastodon';
-import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications';
-import { pushNotificationsSetting } from './settings';
-
-// Taken from https://www.npmjs.com/package/web-push
-const urlBase64ToUint8Array = (base64String) => {
-  const padding = '='.repeat((4 - base64String.length % 4) % 4);
-  const base64 = (base64String + padding)
-    .replace(/\-/g, '+')
-    .replace(/_/g, '/');
-
-  const rawData = window.atob(base64);
-  const outputArray = new Uint8Array(rawData.length);
-
-  for (let i = 0; i < rawData.length; ++i) {
-    outputArray[i] = rawData.charCodeAt(i);
-  }
-  return outputArray;
-};
-
-const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
-
-const getRegistration = () => navigator.serviceWorker.ready;
-
-const getPushSubscription = (registration) =>
-  registration.pushManager.getSubscription()
-    .then(subscription => ({ registration, subscription }));
-
-const subscribe = (registration) =>
-  registration.pushManager.subscribe({
-    userVisibleOnly: true,
-    applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()),
-  });
-
-const unsubscribe = ({ registration, subscription }) =>
-  subscription ? subscription.unsubscribe().then(() => registration) : registration;
-
-const sendSubscriptionToBackend = (subscription) => {
-  const params = { subscription };
-
-  const me = store.getState().getIn(['meta', 'me']);
-  if (me) {
-    const data = pushNotificationsSetting.get(me);
-    if (data) {
-      params.data = data;
-    }
-  }
-
-  return axios.post('/api/web/push_subscriptions', params).then(response => response.data);
-};
-
-// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
-const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype);
-
-export function register () {
-  store.dispatch(setBrowserSupport(supportsPushNotifications));
-  const me = store.getState().getIn(['meta', 'me']);
-
-  if (me && !pushNotificationsSetting.get(me)) {
-    const alerts = store.getState().getIn(['push_notifications', 'alerts']);
-    if (alerts) {
-      pushNotificationsSetting.set(me, { alerts: alerts });
-    }
-  }
-
-  if (supportsPushNotifications) {
-    if (!getApplicationServerKey()) {
-      console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
-      return;
-    }
-
-    getRegistration()
-      .then(getPushSubscription)
-      .then(({ registration, subscription }) => {
-        if (subscription !== null) {
-          // We have a subscription, check if it is still valid
-          const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString();
-          const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString();
-          const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']);
-
-          // If the VAPID public key did not change and the endpoint corresponds
-          // to the endpoint saved in the backend, the subscription is valid
-          if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) {
-            return subscription;
-          } else {
-            // Something went wrong, try to subscribe again
-            return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend);
-          }
-        }
-
-        // No subscription, try to subscribe
-        return subscribe(registration).then(sendSubscriptionToBackend);
-      })
-      .then(subscription => {
-        // If we got a PushSubscription (and not a subscription object from the backend)
-        // it means that the backend subscription is valid (and was set during hydration)
-        if (!(subscription instanceof PushSubscription)) {
-          store.dispatch(setSubscription(subscription));
-          if (me) {
-            pushNotificationsSetting.set(me, { alerts: subscription.alerts });
-          }
-        }
-      })
-      .catch(error => {
-        if (error.code === 20 && error.name === 'AbortError') {
-          console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
-        } else if (error.code === 5 && error.name === 'InvalidCharacterError') {
-          console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
-        }
-
-        // Clear alerts and hide UI settings
-        store.dispatch(clearSubscription());
-        if (me) {
-          pushNotificationsSetting.remove(me);
-        }
-
-        try {
-          getRegistration()
-            .then(getPushSubscription)
-            .then(unsubscribe);
-        } catch (e) {
-
-        }
-      });
-  } else {
-    console.warn('Your browser does not support Web Push Notifications.');
-  }
-}
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index b5655975a..71d0b91e9 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -214,6 +214,7 @@
 
 .dropdown-menu {
   position: absolute;
+  transform-origin: 50% 0;
 }
 
 .dropdown--active .icon-button {
@@ -2148,7 +2149,8 @@
 
 @import 'boost';
 
-button.icon-button i.fa-retweet {
+.no-reduce-motion button.icon-button i.fa-retweet {
+
   background-position: 0 0;
   height: 19px;
   transition: background-position 0.9s steps(10);
@@ -2159,13 +2161,23 @@ button.icon-button i.fa-retweet {
   &::before {
     display: none !important;
   }
+
 }
 
-button.icon-button.active i.fa-retweet {
+.no-reduce-motion button.icon-button.active i.fa-retweet {
   transition-duration: 0.9s;
   background-position: 0 100%;
 }
 
+.reduce-motion button.icon-button i.fa-retweet {
+  color: $ui-base-lighter-color;
+  transition: color 100ms ease-in;
+}
+
+.reduce-motion button.icon-button.active i.fa-retweet {
+  color: $ui-highlight-color;
+}
+
 .status-card {
   display: flex;
   cursor: pointer;
@@ -2943,6 +2955,7 @@ button.icon-button.active i.fa-retweet {
   border-radius: 4px;
   margin-left: 40px;
   overflow: hidden;
+  transform-origin: 50% 0;
 }
 
 .privacy-dropdown__option {
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 20603678b..5b9e652cb 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -34,6 +34,7 @@
 
   - body_classes ||= @body_classes || ''
   - body_classes += ' system-font' if current_account&.user&.setting_system_font_ui
+  - body_classes += current_account&.user&.setting_reduce_motion ? ' reduce-motion' : ' no-reduce-motion'
 
   %body{ class: add_rtl_body_class(body_classes) }
     = content_for?(:content) ? yield(:content) : yield
diff --git a/lib/tasks/mastodon.rake b/lib/tasks/mastodon.rake
index 0f2cc536a..33969d470 100644
--- a/lib/tasks/mastodon.rake
+++ b/lib/tasks/mastodon.rake
@@ -1,5 +1,8 @@
 # frozen_string_literal: true
 
+require 'optparse'
+require 'colorize'
+
 namespace :mastodon do
   desc 'Execute daily tasks (deprecated)'
   task :daily do
@@ -338,5 +341,75 @@ namespace :mastodon do
       PreviewCard.where(embed_url: '', type: :photo).delete_all
       LinkCrawlWorker.push_bulk status_ids
     end
+
+    desc 'Check every known remote account and delete those that no longer exist in origin'
+    task purge_removed_accounts: :environment do
+      prepare_for_options!
+
+      options = {}
+
+      OptionParser.new do |opts|
+        opts.banner = 'Usage: rails mastodon:maintenance:purge_removed_accounts [options]'
+
+        opts.on('-f', '--force', 'Remove all encountered accounts without asking for confirmation') do
+          options[:force] = true
+        end
+
+        opts.on('-h', '--help', 'Display this message') do
+          puts opts
+          exit
+        end
+      end.parse!
+
+      disable_log_stdout!
+
+      total        = Account.remote.where(protocol: :activitypub).count
+      progress_bar = ProgressBar.create(total: total, format: '%c/%C |%w>%i| %e')
+
+      Account.remote.where(protocol: :activitypub).partitioned.find_each do |account|
+        progress_bar.increment
+
+        begin
+          res = Request.new(:head, account.uri).perform
+        rescue StandardError
+          # This could happen due to network timeout, DNS timeout, wrong SSL cert, etc,
+          # which should probably not lead to perceiving the account as deleted, so
+          # just skip till next time
+          next
+        end
+
+        if [404, 410].include?(res.code)
+          if options[:force]
+            account.destroy
+          else
+            progress_bar.pause
+            progress_bar.clear
+            print "\nIt seems like #{account.acct} no longer exists. Purge the account from the database? [Y/n]: ".colorize(:yellow)
+            confirm = STDIN.gets.chomp
+            puts ''
+            progress_bar.resume
+
+            if confirm.casecmp('n').zero?
+              next
+            else
+              account.destroy
+            end
+          end
+        end
+      end
+    end
   end
 end
+
+def disable_log_stdout!
+  dev_null = Logger.new('/dev/null')
+
+  Rails.logger                 = dev_null
+  ActiveRecord::Base.logger    = dev_null
+  HttpLog.configuration.logger = dev_null
+  Paperclip.options[:log]      = false
+end
+
+def prepare_for_options!
+  2.times { ARGV.shift }
+end