about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/settings/preferences_controller.rb1
-rw-r--r--app/javascript/glitch/components/account/header.js2
-rw-r--r--app/javascript/glitch/components/local_settings/container.js4
-rw-r--r--app/javascript/glitch/components/local_settings/index.js2
-rw-r--r--app/javascript/glitch/components/local_settings/navigation/index.js2
-rw-r--r--app/javascript/glitch/components/local_settings/navigation/item/index.js2
-rw-r--r--app/javascript/glitch/components/local_settings/page/index.js2
-rw-r--r--app/javascript/glitch/components/local_settings/page/item/index.js2
-rw-r--r--app/javascript/mastodon/base_polyfills.js2
-rw-r--r--app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap35
-rw-r--r--app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap26
-rw-r--r--app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap114
-rw-r--r--app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap23
-rw-r--r--app/javascript/mastodon/components/__tests__/avatar-test.js36
-rw-r--r--app/javascript/mastodon/components/__tests__/avatar_overlay-test.js29
-rw-r--r--app/javascript/mastodon/components/__tests__/button-test.js75
-rw-r--r--app/javascript/mastodon/components/__tests__/display_name-test.js18
-rw-r--r--app/javascript/mastodon/components/collapsable.js2
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js2
-rw-r--r--app/javascript/mastodon/components/icon_button.js60
-rw-r--r--app/javascript/mastodon/components/status_action_bar.js3
-rw-r--r--app/javascript/mastodon/containers/mastodon.js1
-rw-r--r--app/javascript/mastodon/features/account/components/header.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/search.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/upload_progress.js2
-rw-r--r--app/javascript/mastodon/features/compose/components/warning.js2
-rw-r--r--app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js15
-rw-r--r--app/javascript/mastodon/features/compose/containers/sensitive_button_container.js2
-rw-r--r--app/javascript/mastodon/features/compose/index.js2
-rw-r--r--app/javascript/mastodon/features/emoji/__tests__/emoji-test.js61
-rw-r--r--app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js130
-rw-r--r--app/javascript/mastodon/features/emoji/emoji_compressed.js3
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js26
-rw-r--r--app/javascript/mastodon/features/ui/components/__tests__/column-test.js34
-rw-r--r--app/javascript/mastodon/features/ui/components/upload_area.js2
-rw-r--r--app/javascript/mastodon/features/ui/util/optional_motion.js34
-rw-r--r--app/javascript/mastodon/locales/ca.json1
-rw-r--r--app/javascript/mastodon/locales/en.json1
-rw-r--r--app/javascript/mastodon/locales/es.json1
-rw-r--r--app/javascript/mastodon/locales/fr.json1
-rw-r--r--app/javascript/mastodon/locales/ko.json42
-rw-r--r--app/javascript/mastodon/locales/oc.json1
-rw-r--r--app/javascript/mastodon/locales/pl.json1
-rw-r--r--app/javascript/mastodon/main.js5
-rw-r--r--app/javascript/mastodon/reducers/timelines.js6
-rw-r--r--app/javascript/mastodon/test_setup.js5
-rw-r--r--app/javascript/packs/application.js3
-rw-r--r--app/javascript/packs/common.js3
-rw-r--r--app/javascript/themes/spin/pack.js4
-rw-r--r--app/lib/feed_manager.rb50
-rw-r--r--app/lib/user_settings_decorator.rb5
-rw-r--r--app/models/user.rb4
-rw-r--r--app/serializers/initial_state_serializer.rb1
-rw-r--r--app/views/settings/preferences/show.html.haml1
-rw-r--r--app/views/stream_entries/_detailed_status.html.haml20
-rw-r--r--app/workers/scheduler/feed_cleanup_scheduler.rb5
58 files changed, 805 insertions, 119 deletions
diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb
index 207c7b324..069026715 100644
--- a/app/controllers/settings/preferences_controller.rb
+++ b/app/controllers/settings/preferences_controller.rb
@@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController
       :setting_boost_modal,
       :setting_delete_modal,
       :setting_auto_play_gif,
+      :setting_reduce_motion,
       :setting_system_font_ui,
       :setting_noindex,
       :setting_theme,
diff --git a/app/javascript/glitch/components/account/header.js b/app/javascript/glitch/components/account/header.js
index 6359c1775..f4a413aa3 100644
--- a/app/javascript/glitch/components/account/header.js
+++ b/app/javascript/glitch/components/account/header.js
@@ -48,7 +48,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 
 //  Mastodon imports  //
-import emojify from 'mastodon/features/emoji/emoji';
+import emojify from '../../../mastodon/features/emoji/emoji';
 import IconButton from '../../../mastodon/components/icon_button';
 import Avatar from '../../../mastodon/components/avatar';
 
diff --git a/app/javascript/glitch/components/local_settings/container.js b/app/javascript/glitch/components/local_settings/container.js
index 6c202a4e7..4569db99f 100644
--- a/app/javascript/glitch/components/local_settings/container.js
+++ b/app/javascript/glitch/components/local_settings/container.js
@@ -2,10 +2,10 @@
 import { connect } from 'react-redux';
 
 //  Mastodon imports  //
-import { closeModal } from 'mastodon/actions/modal';
+import { closeModal } from '../../../mastodon/actions/modal';
 
 //  Our imports  //
-import { changeLocalSetting } from 'glitch/actions/local_settings';
+import { changeLocalSetting } from '../../../glitch/actions/local_settings';
 import LocalSettings from '.';
 
 const mapStateToProps = state => ({
diff --git a/app/javascript/glitch/components/local_settings/index.js b/app/javascript/glitch/components/local_settings/index.js
index 7f7b93de4..ef711229a 100644
--- a/app/javascript/glitch/components/local_settings/index.js
+++ b/app/javascript/glitch/components/local_settings/index.js
@@ -8,7 +8,7 @@ import LocalSettingsPage from './page';
 import LocalSettingsNavigation from './navigation';
 
 //  Stylesheet imports
-import './style';
+import './style.scss';
 
 export default class LocalSettings extends React.PureComponent {
 
diff --git a/app/javascript/glitch/components/local_settings/navigation/index.js b/app/javascript/glitch/components/local_settings/navigation/index.js
index 1f72cc824..fa35e83c7 100644
--- a/app/javascript/glitch/components/local_settings/navigation/index.js
+++ b/app/javascript/glitch/components/local_settings/navigation/index.js
@@ -7,7 +7,7 @@ import { injectIntl, defineMessages } from 'react-intl';
 import LocalSettingsNavigationItem from './item';
 
 //  Stylesheet imports
-import './style';
+import './style.scss';
 
 //  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
 
diff --git a/app/javascript/glitch/components/local_settings/navigation/item/index.js b/app/javascript/glitch/components/local_settings/navigation/item/index.js
index 1676aa404..a352d5fb2 100644
--- a/app/javascript/glitch/components/local_settings/navigation/item/index.js
+++ b/app/javascript/glitch/components/local_settings/navigation/item/index.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import classNames from 'classnames';
 
 //  Stylesheet imports
-import './style';
+import './style.scss';
 
 //  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
 
diff --git a/app/javascript/glitch/components/local_settings/page/index.js b/app/javascript/glitch/components/local_settings/page/index.js
index 338d86333..366c113c0 100644
--- a/app/javascript/glitch/components/local_settings/page/index.js
+++ b/app/javascript/glitch/components/local_settings/page/index.js
@@ -8,7 +8,7 @@ import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
 import LocalSettingsPageItem from './item';
 
 //  Stylesheet imports
-import './style';
+import './style.scss';
 
 //  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
 
diff --git a/app/javascript/glitch/components/local_settings/page/item/index.js b/app/javascript/glitch/components/local_settings/page/item/index.js
index 326c7eeb0..37e28c084 100644
--- a/app/javascript/glitch/components/local_settings/page/item/index.js
+++ b/app/javascript/glitch/components/local_settings/page/item/index.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 
 //  Stylesheet imports
-import './style';
+import './style.scss';
 
 //  * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * *
 
diff --git a/app/javascript/mastodon/base_polyfills.js b/app/javascript/mastodon/base_polyfills.js
index 266a0020c..7856b26f9 100644
--- a/app/javascript/mastodon/base_polyfills.js
+++ b/app/javascript/mastodon/base_polyfills.js
@@ -1,5 +1,5 @@
 import 'intl';
-import 'intl/locale-data/jsonp/en.js';
+import 'intl/locale-data/jsonp/en';
 import 'es6-symbol/implement';
 import includes from 'array-includes';
 import assign from 'object-assign';
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
new file mode 100644
index 000000000..4005c860f
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar-test.js.snap
@@ -0,0 +1,35 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`<Avatar /> Autoplay renders a animated avatar 1`] = `
+<div
+  className="account__avatar"
+  data-avatar-of="@alice"
+  onMouseEnter={[Function]}
+  onMouseLeave={[Function]}
+  style={
+    Object {
+      "backgroundImage": "url(/animated/alice.gif)",
+      "backgroundSize": "100px 100px",
+      "height": "100px",
+      "width": "100px",
+    }
+  }
+/>
+`;
+
+exports[`<Avatar /> Still renders a still avatar 1`] = `
+<div
+  className="account__avatar"
+  data-avatar-of="@alice"
+  onMouseEnter={[Function]}
+  onMouseLeave={[Function]}
+  style={
+    Object {
+      "backgroundImage": "url(/static/alice.jpg)",
+      "backgroundSize": "100px 100px",
+      "height": "100px",
+      "width": "100px",
+    }
+  }
+/>
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
new file mode 100644
index 000000000..d9e5e5252
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.js.snap
@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`<AvatarOverlay renders a overlay avatar 1`] = `
+<div
+  className="account__avatar-overlay"
+>
+  <div
+    className="account__avatar-overlay-base"
+    data-avatar-of="@alice"
+    style={
+      Object {
+        "backgroundImage": "url(/static/alice.jpg)",
+      }
+    }
+  />
+  <div
+    className="account__avatar-overlay-overlay"
+    data-avatar-of="@eve@blackhat.lair"
+    style={
+      Object {
+        "backgroundImage": "url(/static/eve.jpg)",
+      }
+    }
+  />
+</div>
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
new file mode 100644
index 000000000..c3f018d90
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/button-test.js.snap
@@ -0,0 +1,114 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`<Button /> adds class "button-secondary" if props.secondary given 1`] = `
+<button
+  className="button button-secondary"
+  disabled={undefined}
+  onClick={[Function]}
+  style={
+    Object {
+      "height": "36px",
+      "lineHeight": "36px",
+      "padding": "0 16px",
+    }
+  }
+/>
+`;
+
+exports[`<Button /> renders a button element 1`] = `
+<button
+  className="button"
+  disabled={undefined}
+  onClick={[Function]}
+  style={
+    Object {
+      "height": "36px",
+      "lineHeight": "36px",
+      "padding": "0 16px",
+    }
+  }
+/>
+`;
+
+exports[`<Button /> renders a disabled attribute if props.disabled given 1`] = `
+<button
+  className="button"
+  disabled={true}
+  onClick={[Function]}
+  style={
+    Object {
+      "height": "36px",
+      "lineHeight": "36px",
+      "padding": "0 16px",
+    }
+  }
+/>
+`;
+
+exports[`<Button /> renders class="button--block" if props.block given 1`] = `
+<button
+  className="button button--block"
+  disabled={undefined}
+  onClick={[Function]}
+  style={
+    Object {
+      "height": "36px",
+      "lineHeight": "36px",
+      "padding": "0 16px",
+    }
+  }
+/>
+`;
+
+exports[`<Button /> renders the children 1`] = `
+<button
+  className="button"
+  disabled={undefined}
+  onClick={[Function]}
+  style={
+    Object {
+      "height": "36px",
+      "lineHeight": "36px",
+      "padding": "0 16px",
+    }
+  }
+>
+  <p>
+    children
+  </p>
+</button>
+`;
+
+exports[`<Button /> renders the given text 1`] = `
+<button
+  className="button"
+  disabled={undefined}
+  onClick={[Function]}
+  style={
+    Object {
+      "height": "36px",
+      "lineHeight": "36px",
+      "padding": "0 16px",
+    }
+  }
+>
+  foo
+</button>
+`;
+
+exports[`<Button /> renders the props.text instead of children 1`] = `
+<button
+  className="button"
+  disabled={undefined}
+  onClick={[Function]}
+  style={
+    Object {
+      "height": "36px",
+      "lineHeight": "36px",
+      "padding": "0 16px",
+    }
+  }
+>
+  foo
+</button>
+`;
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
new file mode 100644
index 000000000..533359ffe
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/display_name-test.js.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`<DisplayName /> renders display name + account name 1`] = `
+<span
+  className="display-name"
+>
+  <strong
+    className="display-name__html"
+    dangerouslySetInnerHTML={
+      Object {
+        "__html": "<p>Foo</p>",
+      }
+    }
+  />
+   
+  <span
+    className="display-name__account"
+  >
+    @
+    bar@baz
+  </span>
+</span>
+`;
diff --git a/app/javascript/mastodon/components/__tests__/avatar-test.js b/app/javascript/mastodon/components/__tests__/avatar-test.js
new file mode 100644
index 000000000..dd3f7b7d2
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/avatar-test.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import Avatar from '../avatar';
+
+describe('<Avatar />', () => {
+  const account = fromJS({
+    username: 'alice',
+    acct: 'alice',
+    display_name: 'Alice',
+    avatar: '/animated/alice.gif',
+    avatar_static: '/static/alice.jpg',
+  });
+
+  const size     = 100;
+
+  describe('Autoplay', () => {
+    it('renders a animated avatar', () => {
+      const component = renderer.create(<Avatar account={account} animate size={size} />);
+      const tree      = component.toJSON();
+
+      expect(tree).toMatchSnapshot();
+    });
+  });
+
+  describe('Still', () => {
+    it('renders a still avatar', () => {
+      const component = renderer.create(<Avatar account={account} size={size} />);
+      const tree      = component.toJSON();
+
+      expect(tree).toMatchSnapshot();
+    });
+  });
+
+  // TODO add autoplay test if possible
+});
diff --git a/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js
new file mode 100644
index 000000000..44addea83
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/avatar_overlay-test.js
@@ -0,0 +1,29 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS } from 'immutable';
+import AvatarOverlay from '../avatar_overlay';
+
+describe('<AvatarOverlay', () => {
+  const account = fromJS({
+    username: 'alice',
+    acct: 'alice',
+    display_name: 'Alice',
+    avatar: '/animated/alice.gif',
+    avatar_static: '/static/alice.jpg',
+  });
+
+  const friend = fromJS({
+    username: 'eve',
+    acct: 'eve@blackhat.lair',
+    display_name: 'Evelyn',
+    avatar: '/animated/eve.gif',
+    avatar_static: '/static/eve.jpg',
+  });
+
+  it('renders a overlay avatar', () => {
+    const component = renderer.create(<AvatarOverlay account={account} friend={friend} />);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+});
diff --git a/app/javascript/mastodon/components/__tests__/button-test.js b/app/javascript/mastodon/components/__tests__/button-test.js
new file mode 100644
index 000000000..160cd3cbc
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/button-test.js
@@ -0,0 +1,75 @@
+import { shallow } from 'enzyme';
+import React from 'react';
+import renderer from 'react-test-renderer';
+import Button from '../button';
+
+describe('<Button />', () => {
+  it('renders a button element', () => {
+    const component = renderer.create(<Button />);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+
+  it('renders the given text', () => {
+    const text      = 'foo';
+    const component = renderer.create(<Button text={text} />);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+
+  it('handles click events using the given handler', () => {
+    const handler = jest.fn();
+    const button  = shallow(<Button onClick={handler} />);
+    button.find('button').simulate('click');
+
+    expect(handler.mock.calls.length).toEqual(1);
+  });
+
+  it('does not handle click events if props.disabled given', () => {
+    const handler = jest.fn();
+    const button  = shallow(<Button onClick={handler} disabled />);
+    button.find('button').simulate('click');
+
+    expect(handler.mock.calls.length).toEqual(0);
+  });
+
+  it('renders a disabled attribute if props.disabled given', () => {
+    const component = renderer.create(<Button disabled />);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+
+  it('renders the children', () => {
+    const children  = <p>children</p>;
+    const component = renderer.create(<Button>{children}</Button>);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+
+  it('renders the props.text instead of children', () => {
+    const text      = 'foo';
+    const children  = <p>children</p>;
+    const component = renderer.create(<Button text={text}>{children}</Button>);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+
+  it('renders class="button--block" if props.block given', () => {
+    const component = renderer.create(<Button block />);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+
+  it('adds class "button-secondary" if props.secondary given', () => {
+    const component = renderer.create(<Button secondary />);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+});
diff --git a/app/javascript/mastodon/components/__tests__/display_name-test.js b/app/javascript/mastodon/components/__tests__/display_name-test.js
new file mode 100644
index 000000000..0d040c4cd
--- /dev/null
+++ b/app/javascript/mastodon/components/__tests__/display_name-test.js
@@ -0,0 +1,18 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { fromJS }  from 'immutable';
+import DisplayName from '../display_name';
+
+describe('<DisplayName />', () => {
+  it('renders display name + account name', () => {
+    const account = fromJS({
+      username: 'bar',
+      acct: 'bar@baz',
+      display_name_html: '<p>Foo</p>',
+    });
+    const component = renderer.create(<DisplayName account={account} />);
+    const tree      = component.toJSON();
+
+    expect(tree).toMatchSnapshot();
+  });
+});
diff --git a/app/javascript/mastodon/components/collapsable.js b/app/javascript/mastodon/components/collapsable.js
index ad1453589..42ea37ec2 100644
--- a/app/javascript/mastodon/components/collapsable.js
+++ b/app/javascript/mastodon/components/collapsable.js
@@ -1,5 +1,5 @@
 import React from 'react';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import PropTypes from 'prop-types';
 
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 73ad46bb7..3a3ebf487 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import IconButton from './icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import detectPassiveEvents from 'detect-passive-events';
 
diff --git a/app/javascript/mastodon/components/icon_button.js b/app/javascript/mastodon/components/icon_button.js
index 6fb191c6b..76b0da12f 100644
--- a/app/javascript/mastodon/components/icon_button.js
+++ b/app/javascript/mastodon/components/icon_button.js
@@ -1,7 +1,8 @@
 import React from 'react';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import PropTypes from 'prop-types';
+import classNames from 'classnames';
 
 export default class IconButton extends React.PureComponent {
 
@@ -56,27 +57,26 @@ export default class IconButton extends React.PureComponent {
       style.textAlign = 'left';
     }
 
-    const classes = ['icon-button'];
-
-    if (this.props.active) {
-      classes.push('active');
-    }
-
-    if (this.props.disabled) {
-      classes.push('disabled');
-    }
-
-    if (this.props.inverted) {
-      classes.push('inverted');
-    }
-
-    if (this.props.overlay) {
-      classes.push('overlayed');
-    }
-
-    if (this.props.className) {
-      classes.push(this.props.className);
-    }
+    const {
+      active,
+      animate,
+      className,
+      disabled,
+      expanded,
+      icon,
+      inverted,
+      overlay,
+      pressed,
+      tabIndex,
+      title,
+    } = this.props;
+
+    const classes = classNames(className, 'icon-button', {
+      active,
+      disabled,
+      inverted,
+      overlayed: overlay,
+    });
 
     const flipDeg = this.props.flip ? -180 : -360;
     const rotateDeg = this.props.active ? flipDeg : 0;
@@ -90,23 +90,23 @@ export default class IconButton extends React.PureComponent {
       damping: 7,
     };
     const motionStyle = {
-      rotate: this.props.animate ? spring(rotateDeg, springOpts) : 0,
+      rotate: animate ? spring(rotateDeg, springOpts) : 0,
     };
 
     return (
       <Motion defaultStyle={motionDefaultStyle} style={motionStyle}>
         {({ rotate }) =>
           <button
-            aria-label={this.props.title}
-            aria-pressed={this.props.pressed}
-            aria-expanded={this.props.expanded}
-            title={this.props.title}
-            className={classes.join(' ')}
+            aria-label={title}
+            aria-pressed={pressed}
+            aria-expanded={expanded}
+            title={title}
+            className={classes}
             onClick={this.handleClick}
             style={style}
-            tabIndex={this.props.tabIndex}
+            tabIndex={tabIndex}
           >
-            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${this.props.icon}`} aria-hidden='true' />
+            <i style={{ transform: `rotate(${rotate}deg)` }} className={`fa fa-fw fa-${icon}`} aria-hidden='true' />
             {this.props.label}
           </button>
         }
diff --git a/app/javascript/mastodon/components/status_action_bar.js b/app/javascript/mastodon/components/status_action_bar.js
index cf9c8fb53..af152cc32 100644
--- a/app/javascript/mastodon/components/status_action_bar.js
+++ b/app/javascript/mastodon/components/status_action_bar.js
@@ -16,6 +16,7 @@ const messages = defineMessages({
   block: { id: 'account.block', defaultMessage: 'Block @{name}' },
   reply: { id: 'status.reply', defaultMessage: 'Reply' },
   share: { id: 'status.share', defaultMessage: 'Share' },
+  more: { id: 'status.more', defaultMessage: 'More' },
   replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
   reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
@@ -182,7 +183,7 @@ export default class StatusActionBar extends ImmutablePureComponent {
         {shareButton}
 
         <div className='status__action-bar-dropdown'>
-          <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel='More' />
+          <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
         </div>
       </div>
     );
diff --git a/app/javascript/mastodon/containers/mastodon.js b/app/javascript/mastodon/containers/mastodon.js
index 6beffca1c..a7138e62d 100644
--- a/app/javascript/mastodon/containers/mastodon.js
+++ b/app/javascript/mastodon/containers/mastodon.js
@@ -10,6 +10,7 @@ import { hydrateStore } from '../actions/store';
 import { connectUserStream } from '../actions/streaming';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from '../locales';
+
 const { localeData, messages } = getLocale();
 addLocaleData(localeData);
 
diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js
index 5402d6753..57678d162 100644
--- a/app/javascript/mastodon/features/account/components/header.js
+++ b/app/javascript/mastodon/features/account/components/header.js
@@ -6,7 +6,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import IconButton from '../../../components/icon_button';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { connect } from 'react-redux';
 import ImmutablePureComponent from 'react-immutable-pure-component';
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index e38ed38c1..c1e85aee3 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { injectIntl, defineMessages } from 'react-intl';
 import IconButton from '../../../components/icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import detectPassiveEvents from 'detect-passive-events';
 import classNames from 'classnames';
diff --git a/app/javascript/mastodon/features/compose/components/search.js b/app/javascript/mastodon/features/compose/components/search.js
index f57d54618..398fc44ce 100644
--- a/app/javascript/mastodon/features/compose/components/search.js
+++ b/app/javascript/mastodon/features/compose/components/search.js
@@ -2,7 +2,7 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import Overlay from 'react-overlays/lib/Overlay';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 
 const messages = defineMessages({
diff --git a/app/javascript/mastodon/features/compose/components/upload.js b/app/javascript/mastodon/features/compose/components/upload.js
index cd9e08360..5d8d66cf7 100644
--- a/app/javascript/mastodon/features/compose/components/upload.js
+++ b/app/javascript/mastodon/features/compose/components/upload.js
@@ -2,7 +2,7 @@ import React from 'react';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import IconButton from '../../../components/icon_button';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import ImmutablePureComponent from 'react-immutable-pure-component';
 import { defineMessages, injectIntl } from 'react-intl';
diff --git a/app/javascript/mastodon/features/compose/components/upload_progress.js b/app/javascript/mastodon/features/compose/components/upload_progress.js
index 3e49098c7..d5e6f19cd 100644
--- a/app/javascript/mastodon/features/compose/components/upload_progress.js
+++ b/app/javascript/mastodon/features/compose/components/upload_progress.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { FormattedMessage } from 'react-intl';
 
diff --git a/app/javascript/mastodon/features/compose/components/warning.js b/app/javascript/mastodon/features/compose/components/warning.js
index a0814e984..803b7f86a 100644
--- a/app/javascript/mastodon/features/compose/components/warning.js
+++ b/app/javascript/mastodon/features/compose/components/warning.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 
 export default class Warning extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js b/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js
deleted file mode 100644
index a9e3a9edf..000000000
--- a/app/javascript/mastodon/features/compose/containers/autosuggest_status_container.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { connect } from 'react-redux';
-import AutosuggestStatus from '../components/autosuggest_status';
-import { makeGetStatus } from '../../../selectors';
-
-const makeMapStateToProps = () => {
-  const getStatus = makeGetStatus();
-
-  const mapStateToProps = (state, { id }) => ({
-    status: getStatus(state, id),
-  });
-
-  return mapStateToProps;
-};
-
-export default connect(makeMapStateToProps)(AutosuggestStatus);
diff --git a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
index 8624849f3..e4bd5a743 100644
--- a/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
+++ b/app/javascript/mastodon/features/compose/containers/sensitive_button_container.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import classNames from 'classnames';
 import IconButton from '../../../components/icon_button';
 import { changeComposeSensitivity } from '../../../actions/compose';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { injectIntl, defineMessages } from 'react-intl';
 
diff --git a/app/javascript/mastodon/features/compose/index.js b/app/javascript/mastodon/features/compose/index.js
index 9068648bd..41a97d550 100644
--- a/app/javascript/mastodon/features/compose/index.js
+++ b/app/javascript/mastodon/features/compose/index.js
@@ -10,7 +10,7 @@ import { changeLocalSetting } from '../../../glitch/actions/local_settings';
 import { Link } from 'react-router-dom';
 import { injectIntl, defineMessages } from 'react-intl';
 import SearchContainer from './containers/search_container';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import SearchResultsContainer from './containers/search_results_container';
 import { changeComposing } from '../../actions/compose';
diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
new file mode 100644
index 000000000..636402172
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/__tests__/emoji-test.js
@@ -0,0 +1,61 @@
+import emojify from '../emoji';
+
+describe('emoji', () => {
+  describe('.emojify', () => {
+    it('ignores unknown shortcodes', () => {
+      expect(emojify(':foobarbazfake:')).toEqual(':foobarbazfake:');
+    });
+
+    it('ignores shortcodes inside of tags', () => {
+      expect(emojify('<p data-foo=":smile:"></p>')).toEqual('<p data-foo=":smile:"></p>');
+    });
+
+    it('works with unclosed tags', () => {
+      expect(emojify('hello>')).toEqual('hello>');
+      expect(emojify('<hello')).toEqual('<hello');
+    });
+
+    it('works with unclosed shortcodes', () => {
+      expect(emojify('smile:')).toEqual('smile:');
+      expect(emojify(':smile')).toEqual(':smile');
+    });
+
+    it('does unicode', () => {
+      expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).toEqual(
+        '<img draggable="false" class="emojione" alt="๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg" />');
+      expect(emojify('๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง')).toEqual(
+      '<img draggable="false" class="emojione" alt="๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
+      expect(emojify('๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ')).toEqual('<img draggable="false" class="emojione" alt="๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg" />');
+      expect(emojify('\u2757')).toEqual(
+      '<img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" />');
+    });
+
+    it('does multiple unicode', () => {
+      expect(emojify('\u2757 #\uFE0F\u20E3')).toEqual(
+        '<img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#๏ธโƒฃ" title=":hash:" src="/emoji/23-20e3.svg" />');
+      expect(emojify('\u2757#\uFE0F\u20E3')).toEqual(
+        '<img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#๏ธโƒฃ" title=":hash:" src="/emoji/23-20e3.svg" />');
+      expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).toEqual(
+        '<img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#๏ธโƒฃ" title=":hash:" src="/emoji/23-20e3.svg" /> <img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" />');
+      expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).toEqual(
+        'foo <img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#๏ธโƒฃ" title=":hash:" src="/emoji/23-20e3.svg" /> bar');
+    });
+
+    it('ignores unicode inside of tags', () => {
+      expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).toEqual('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
+    });
+
+    it('does multiple emoji properly (issue 5188)', () => {
+      expect(emojify('๐Ÿ‘Œ๐ŸŒˆ๐Ÿ’•')).toEqual('<img draggable="false" class="emojione" alt="๐Ÿ‘Œ" title=":ok_hand:" src="/emoji/1f44c.svg" /><img draggable="false" class="emojione" alt="๐ŸŒˆ" title=":rainbow:" src="/emoji/1f308.svg" /><img draggable="false" class="emojione" alt="๐Ÿ’•" title=":two_hearts:" src="/emoji/1f495.svg" />');
+      expect(emojify('๐Ÿ‘Œ ๐ŸŒˆ ๐Ÿ’•')).toEqual('<img draggable="false" class="emojione" alt="๐Ÿ‘Œ" title=":ok_hand:" src="/emoji/1f44c.svg" /> <img draggable="false" class="emojione" alt="๐ŸŒˆ" title=":rainbow:" src="/emoji/1f308.svg" /> <img draggable="false" class="emojione" alt="๐Ÿ’•" title=":two_hearts:" src="/emoji/1f495.svg" />');
+    });
+
+    it('does an emoji that has no shortcode', () => {
+      expect(emojify('๐Ÿ•‰๏ธ')).toEqual('<img draggable="false" class="emojione" alt="๐Ÿ•‰๏ธ" title="" src="/emoji/1f549.svg" />');
+    });
+
+    it('does an emoji whose filename is irregular', () => {
+      expect(emojify('โ†™๏ธ')).toEqual('<img draggable="false" class="emojione" alt="โ†™๏ธ" title=":arrow_lower_left:" src="/emoji/2199.svg" />');
+    });
+  });
+});
diff --git a/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js b/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js
new file mode 100644
index 000000000..53efa5743
--- /dev/null
+++ b/app/javascript/mastodon/features/emoji/__tests__/emoji_index-test.js
@@ -0,0 +1,130 @@
+import { pick } from 'lodash';
+import { emojiIndex } from 'emoji-mart';
+import { search } from '../emoji_mart_search_light';
+
+const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
+
+describe('emoji_index', () => {
+  it('should give same result for emoji_index_light and emoji-mart', () => {
+    const expected = [
+      {
+        id: 'pineapple',
+        unified: '1f34d',
+        native: '๐Ÿ',
+      },
+    ];
+    expect(search('pineapple').map(trimEmojis)).toEqual(expected);
+    expect(emojiIndex.search('pineapple').map(trimEmojis)).toEqual(expected);
+  });
+
+  it('orders search results correctly', () => {
+    const expected = [
+      {
+        id: 'apple',
+        unified: '1f34e',
+        native: '๐ŸŽ',
+      },
+      {
+        id: 'pineapple',
+        unified: '1f34d',
+        native: '๐Ÿ',
+      },
+      {
+        id: 'green_apple',
+        unified: '1f34f',
+        native: '๐Ÿ',
+      },
+      {
+        id: 'iphone',
+        unified: '1f4f1',
+        native: '๐Ÿ“ฑ',
+      },
+    ];
+    expect(search('apple').map(trimEmojis)).toEqual(expected);
+    expect(emojiIndex.search('apple').map(trimEmojis)).toEqual(expected);
+  });
+
+  it('handles custom emoji', () => {
+    const custom = [
+      {
+        id: 'mastodon',
+        name: 'mastodon',
+        short_names: ['mastodon'],
+        text: '',
+        emoticons: [],
+        keywords: ['mastodon'],
+        imageUrl: 'http://example.com',
+        custom: true,
+      },
+    ];
+    search('', { custom });
+    emojiIndex.search('', { custom });
+    const expected = [
+      {
+        id: 'mastodon',
+        custom: true,
+      },
+    ];
+    expect(search('masto').map(trimEmojis)).toEqual(expected);
+    expect(emojiIndex.search('masto').map(trimEmojis)).toEqual(expected);
+  });
+
+  it('should filter only emojis we care about, exclude pineapple', () => {
+    const emojisToShowFilter = unified => unified !== '1F34D';
+    expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
+      .not.toContain('pineapple');
+    expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
+      .not.toContain('pineapple');
+  });
+
+  it('can include/exclude categories', () => {
+    expect(search('flag', { include: ['people'] })).toEqual([]);
+    expect(emojiIndex.search('flag', { include: ['people'] })).toEqual([]);
+  });
+
+  it('does an emoji whose unified name is irregular', () => {
+    const expected = [
+      {
+        'id': 'water_polo',
+        'unified': '1f93d',
+        'native': '๐Ÿคฝ',
+      },
+      {
+        'id': 'man-playing-water-polo',
+        'unified': '1f93d-200d-2642-fe0f',
+        'native': '๐Ÿคฝโ€โ™‚๏ธ',
+      },
+      {
+        'id': 'woman-playing-water-polo',
+        'unified': '1f93d-200d-2640-fe0f',
+        'native': '๐Ÿคฝโ€โ™€๏ธ',
+      },
+    ];
+    expect(search('polo').map(trimEmojis)).toEqual(expected);
+    expect(emojiIndex.search('polo').map(trimEmojis)).toEqual(expected);
+  });
+
+  it('can search for thinking_face', () => {
+    const expected = [
+      {
+        id: 'thinking_face',
+        unified: '1f914',
+        native: '๐Ÿค”',
+      },
+    ];
+    expect(search('thinking_fac').map(trimEmojis)).toEqual(expected);
+    expect(emojiIndex.search('thinking_fac').map(trimEmojis)).toEqual(expected);
+  });
+
+  it('can search for woman-facepalming', () => {
+    const expected = [
+      {
+        id: 'woman-facepalming',
+        unified: '1f926-200d-2640-fe0f',
+        native: '๐Ÿคฆโ€โ™€๏ธ',
+      },
+    ];
+    expect(search('woman-facep').map(trimEmojis)).toEqual(expected);
+    expect(emojiIndex.search('woman-facep').map(trimEmojis)).toEqual(expected);
+  });
+});
diff --git a/app/javascript/mastodon/features/emoji/emoji_compressed.js b/app/javascript/mastodon/features/emoji/emoji_compressed.js
index 3bd89cf3b..c0cba952a 100644
--- a/app/javascript/mastodon/features/emoji/emoji_compressed.js
+++ b/app/javascript/mastodon/features/emoji/emoji_compressed.js
@@ -9,7 +9,8 @@ const { unicodeToFilename } = require('./unicode_to_filename');
 const { unicodeToUnifiedName } = require('./unicode_to_unified_name');
 const emojiMap         = require('./emoji_map.json');
 const { emojiIndex } = require('emoji-mart');
-const emojiMartData = require('emoji-mart/dist/data').default;
+const { default: emojiMartData } = require('emoji-mart/dist/data');
+
 const excluded       = ['ยฎ', 'ยฉ', 'โ„ข'];
 const skins          = ['๐Ÿป', '๐Ÿผ', '๐Ÿฝ', '๐Ÿพ', '๐Ÿฟ'];
 const shortcodeMap   = {};
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 816f83e45..d8547db36 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -48,6 +48,8 @@ export default class DetailedStatus extends ImmutablePureComponent {
     let media           = '';
     let mediaIcon       = null;
     let applicationLink = '';
+    let reblogLink = '';
+    let reblogIcon = 'retweet';
 
     if (status.get('media_attachments').size > 0) {
       if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
@@ -85,6 +87,23 @@ export default class DetailedStatus extends ImmutablePureComponent {
       applicationLink = <span> ยท <a className='detailed-status__application' href={status.getIn(['application', 'website'])} target='_blank' rel='noopener'>{status.getIn(['application', 'name'])}</a></span>;
     }
 
+    if (status.get('visibility') === 'direct') {
+      reblogIcon = 'envelope';
+    } else if (status.get('visibility') === 'private') {
+      reblogIcon = 'lock';
+    }
+
+    if (status.get('visibility') === 'private') {
+      reblogLink = <i className={`fa fa-${reblogIcon}`} />;
+    } else {
+      reblogLink = (<Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
+        <i className={`fa fa-${reblogIcon}`} />
+        <span className='detailed-status__reblogs'>
+          <FormattedNumber value={status.get('reblogs_count')} />
+        </span>
+      </Link>);
+    }
+
     return (
       <div className='detailed-status'>
         <a href={status.getIn(['account', 'url'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
@@ -101,12 +120,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
         <div className='detailed-status__meta'>
           <a className='detailed-status__datetime' href={status.get('url')} target='_blank' rel='noopener'>
             <FormattedDate value={new Date(status.get('created_at'))} hour12={false} year='numeric' month='short' day='2-digit' hour='2-digit' minute='2-digit' />
-          </a>{applicationLink} ยท <Link to={`/statuses/${status.get('id')}/reblogs`} className='detailed-status__link'>
-            <i className='fa fa-retweet' />
-            <span className='detailed-status__reblogs'>
-              <FormattedNumber value={status.get('reblogs_count')} />
-            </span>
-          </Link> ยท <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
+          </a>{applicationLink} ยท {reblogLink} ยท <Link to={`/statuses/${status.get('id')}/favourites`} className='detailed-status__link'>
             <i className='fa fa-star' />
             <span className='detailed-status__favorites'>
               <FormattedNumber value={status.get('favourites_count')} />
diff --git a/app/javascript/mastodon/features/ui/components/__tests__/column-test.js b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
new file mode 100644
index 000000000..1e5e1d8dc
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/components/__tests__/column-test.js
@@ -0,0 +1,34 @@
+import React from 'react';
+import { mount } from 'enzyme';
+import Column from '../column';
+import ColumnHeader from '../column_header';
+
+describe('<Column />', () => {
+  describe('<ColumnHeader /> click handler', () => {
+    const originalRaf = global.requestAnimationFrame;
+
+    beforeEach(() => {
+      global.requestAnimationFrame = jest.fn();
+    });
+
+    afterAll(() => {
+      global.requestAnimationFrame = originalRaf;
+    });
+
+    it('runs the scroll animation if the column contains scrollable content', () => {
+      const wrapper = mount(
+        <Column heading='notifications'>
+          <div className='scrollable' />
+        </Column>
+      );
+      wrapper.find(ColumnHeader).simulate('click');
+      expect(global.requestAnimationFrame.mock.calls.length).toEqual(1);
+    });
+
+    it('does not try to scroll if there is no scrollable content', () => {
+      const wrapper = mount(<Column heading='notifications' />);
+      wrapper.find(ColumnHeader).simulate('click');
+      expect(global.requestAnimationFrame.mock.calls.length).toEqual(0);
+    });
+  });
+});
diff --git a/app/javascript/mastodon/features/ui/components/upload_area.js b/app/javascript/mastodon/features/ui/components/upload_area.js
index dda28feeb..c19065be6 100644
--- a/app/javascript/mastodon/features/ui/components/upload_area.js
+++ b/app/javascript/mastodon/features/ui/components/upload_area.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import Motion from 'react-motion/lib/Motion';
+import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import { FormattedMessage } from 'react-intl';
 
diff --git a/app/javascript/mastodon/features/ui/util/optional_motion.js b/app/javascript/mastodon/features/ui/util/optional_motion.js
new file mode 100644
index 000000000..4276eeaa4
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/optional_motion.js
@@ -0,0 +1,34 @@
+// Like react-motion's Motion, but checks to see if the user prefers
+// reduced motion and uses a cross-fade in those cases.
+
+import Motion from 'react-motion/lib/Motion';
+import { connect } from 'react-redux';
+
+const stylesToKeep = ['opacity', 'backgroundOpacity'];
+
+const extractValue = (value) => {
+  // This is either an object with a "val" property or it's a number
+  return (typeof value === 'object' && value && 'val' in value) ? value.val : value;
+};
+
+const mapStateToProps = (state, ownProps) => {
+  const reduceMotion = state.getIn(['meta', 'reduce_motion']);
+
+  if (reduceMotion) {
+    const { style, defaultStyle } = ownProps;
+
+    Object.keys(style).forEach(key => {
+      if (stylesToKeep.includes(key)) {
+        return;
+      }
+      // If it's setting an x or height or scale or some other value, we need
+      // to preserve the end-state value without actually animating it
+      style[key] = defaultStyle[key] = extractValue(style[key]);
+    });
+
+    return { style, defaultStyle };
+  }
+  return {};
+};
+
+export default connect(mapStateToProps)(Motion);
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index fe2433591..1130e6c09 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -184,6 +184,7 @@
   "status.load_more": "Carrega mรฉs",
   "status.media_hidden": "Multimรจdia amagat",
   "status.mention": "Esmentar @{name}",
+  "status.more": "Mรฉs",
   "status.mute_conversation": "Silenciar conversaciรณ",
   "status.open": "Ampliar aquest estat",
   "status.pin": "Fixat en el perfil",
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 12efe0e0c..7e8d30c64 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -179,6 +179,7 @@
   "status.load_more": "Load more",
   "status.media_hidden": "Media hidden",
   "status.mention": "Mention @{name}",
+  "status.more": "More",
   "status.mute_conversation": "Mute conversation",
   "status.open": "Expand this status",
   "status.pin": "Pin on profile",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index f6bfbb04d..03dd9ce02 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -179,6 +179,7 @@
   "status.load_more": "Cargar mรกs",
   "status.media_hidden": "Contenido multimedia oculto",
   "status.mention": "Mencionar",
+  "status.more": "Mรกs",
   "status.mute_conversation": "Silenciar conversaciรณn",
   "status.open": "Expandir estado",
   "status.pin": "Fijar",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index 145b683f3..f192f3cfc 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -179,6 +179,7 @@
   "status.load_more": "Charger plus",
   "status.media_hidden": "Mรฉdia cachรฉ",
   "status.mention": "Mentionner",
+  "status.more": "Plus",
   "status.mute_conversation": "Masquer la conversation",
   "status.open": "Dรฉplier ce statut",
   "status.pin": "ร‰pingler sur le profil",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index c1768cf8f..637acfab6 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -18,7 +18,7 @@
   "account.unblock_domain": "{domain} ์ˆจ๊น€ ํ•ด์ œ",
   "account.unfollow": "ํŒ”๋กœ์šฐ ํ•ด์ œ",
   "account.unmute": "๋ฎคํŠธ ํ•ด์ œ",
-  "account.view_full_profile": "View full profile",
+  "account.view_full_profile": "์ „์ฒด ํ”„๋กœํ•„ ๋ณด๊ธฐ",
   "boost_modal.combo": "๋‹ค์Œ๋ถ€ํ„ฐ {combo}๋ฅผ ๋ˆ„๋ฅด๋ฉด ์ด ๊ณผ์ •์„ ๊ฑด๋„ˆ๋›ธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.",
   "bundle_column_error.body": "Something went wrong while loading this component.",
   "bundle_column_error.retry": "Try again",
@@ -33,7 +33,7 @@
   "column.home": "ํ™ˆ",
   "column.mutes": "๋ฎคํŠธ ์ค‘์ธ ์‚ฌ์šฉ์ž",
   "column.notifications": "์•Œ๋ฆผ",
-  "column.pins": "๊ณ ์ •๋œ Toot",
+  "column.pins": "๊ณ ์ •๋œ ํˆฟ",
   "column.public": "์—ฐํ•ฉ ํƒ€์ž„๋ผ์ธ",
   "column_back_button.label": "๋Œ์•„๊ฐ€๊ธฐ",
   "column_header.hide_settings": "Hide settings",
@@ -47,7 +47,7 @@
   "compose_form.lock_disclaimer": "์ด ๊ณ„์ •์€ {locked}๋กœ ์„ค์ • ๋˜์–ด ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋ˆ„๊ตฌ๋‚˜ ์ด ๊ณ„์ •์„ ํŒ”๋กœ์šฐ ํ•  ์ˆ˜ ์žˆ์œผ๋ฉฐ, ํŒ”๋กœ์›Œ ๊ณต๊ฐœ์˜ ํฌ์ŠคํŒ…์„ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.",
   "compose_form.lock_disclaimer.lock": "๋น„๊ณต๊ฐœ",
   "compose_form.placeholder": "์ง€๊ธˆ ๋ฌด์—‡์„ ํ•˜๊ณ  ์žˆ๋‚˜์š”?",
-  "compose_form.publish": "Toot",
+  "compose_form.publish": "ํˆฟ",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive": "์ด ๋ฏธ๋””์–ด๋ฅผ ๋ฏผ๊ฐํ•œ ๋ฏธ๋””์–ด๋กœ ์ทจ๊ธ‰",
   "compose_form.spoiler": "ํ…์ŠคํŠธ ์ˆจ๊ธฐ๊ธฐ",
@@ -63,8 +63,8 @@
   "confirmations.mute.message": "์ •๋ง๋กœ {name}๋ฅผ ๋ฎคํŠธํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?",
   "confirmations.unfollow.confirm": "Unfollow",
   "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
-  "embed.instructions": "Embed this status on your website by copying the code below.",
-  "embed.preview": "Here is what it will look like:",
+  "embed.instructions": "์•„๋ž˜์˜ ์ฝ”๋“œ๋ฅผ ๋ณต์‚ฌํ•˜์—ฌ ๋Œ€ํ™”๋ฅผ ์›ํ•˜๋Š” ๊ณณ์œผ๋กœ ํผ๊ฐ€์„ธ์š”.",
+  "embed.preview": "๋‹ค์Œ๊ณผ ๊ฐ™์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค:",
   "emoji_button.activity": "ํ™œ๋™",
   "emoji_button.custom": "Custom",
   "emoji_button.flags": "๊ตญ๊ธฐ",
@@ -82,7 +82,6 @@
   "empty_column.community": "๋กœ์ปฌ ํƒ€์ž„๋ผ์ธ์— ์•„๋ฌด ๊ฒƒ๋„ ์—†์Šต๋‹ˆ๋‹ค. ์•„๋ฌด๊ฑฐ๋‚˜ ์ ์–ด ๋ณด์„ธ์š”!",
   "empty_column.hashtag": "์ด ํ•ด์‹œํƒœ๊ทธ๋Š” ์•„์ง ์‚ฌ์šฉ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค.",
   "empty_column.home": "์•„์ง ์•„๋ฌด๋„ ํŒ”๋กœ์šฐ ํ•˜๊ณ  ์žˆ์ง€ ์•Š์Šต๋‹ˆ๋‹ค. {public}๋ฅผ ๋ณด๋Ÿฌ ๊ฐ€๊ฑฐ๋‚˜, ๊ฒ€์ƒ‰ํ•˜์—ฌ ๋‹ค๋ฅธ ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ์•„ ๋ณด์„ธ์š”.",
-  "empty_column.home.inactivity": "ํ™ˆ ํ”ผ๋“œ์— ์•„๋ฌด ๊ฒƒ๋„ ์—†์Šต๋‹ˆ๋‹ค. ํ•œ๋™์•ˆ ํ™œ๋™ํ•˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๊ณง ์›๋ž˜๋Œ€๋กœ ๋Œ์•„์˜ฌ ๊ฒƒ์ž…๋‹ˆ๋‹ค.",
   "empty_column.home.public_timeline": "์—ฐํ•ฉ ํƒ€์ž„๋ผ์ธ",
   "empty_column.notifications": "์•„์ง ์•Œ๋ฆผ์ด ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์‚ฌ๋žŒ๊ณผ ๋Œ€ํ™”๋ฅผ ์‹œ์ž‘ํ•ด ๋ณด์„ธ์š”!",
   "empty_column.public": "์—ฌ๊ธฐ์—” ์•„์ง ์•„๋ฌด ๊ฒƒ๋„ ์—†์Šต๋‹ˆ๋‹ค! ๊ณต๊ฐœ์ ์œผ๋กœ ๋ฌด์–ธ๊ฐ€ ํฌ์ŠคํŒ…ํ•˜๊ฑฐ๋‚˜, ๋‹ค๋ฅธ ์ธ์Šคํ„ด์Šค ์œ ์ €๋ฅผ ํŒ”๋กœ์šฐ ํ•ด์„œ ๊ฐ€๋“ ์ฑ„์›Œ๋ณด์„ธ์š”!",
@@ -113,7 +112,7 @@
   "navigation_bar.info": "์ด ์ธ์Šคํ„ด์Šค์— ๋Œ€ํ•ด์„œ",
   "navigation_bar.logout": "๋กœ๊ทธ์•„์›ƒ",
   "navigation_bar.mutes": "๋ฎคํŠธ ์ค‘์ธ ์‚ฌ์šฉ์ž",
-  "navigation_bar.pins": "๊ณ ์ •๋œ Toot",
+  "navigation_bar.pins": "๊ณ ์ •๋œ ํˆฟ",
   "navigation_bar.preferences": "์‚ฌ์šฉ์ž ์„ค์ •",
   "navigation_bar.public_timeline": "์—ฐํ•ฉ ํƒ€์ž„๋ผ์ธ",
   "notification.favourite": "{name}๋‹˜์ด ์ฆ๊ฒจ์ฐพ๊ธฐ ํ–ˆ์Šต๋‹ˆ๋‹ค",
@@ -159,29 +158,34 @@
   "privacy.public.long": "๊ณต๊ฐœ ํƒ€์ž„๋ผ์ธ์— ํ‘œ์‹œ",
   "privacy.public.short": "๊ณต๊ฐœ",
   "privacy.unlisted.long": "๊ณต๊ฐœ ํƒ€์ž„๋ผ์ธ์— ํ‘œ์‹œํ•˜์ง€ ์•Š์Œ",
-  "privacy.unlisted.short": "Unlisted",
+  "privacy.unlisted.short": "ํƒ€์ž„๋ผ์ธ์— ๋น„ํ‘œ์‹œ",
+  "relative_time.days": "{number}์ผ ์ „",
+  "relative_time.hours": "{number}์‹œ๊ฐ„ ์ „",
+  "relative_time.just_now": "๋ฐฉ๊ธˆ",
+  "relative_time.minutes": "{number}๋ถ„ ์ „",
+  "relative_time.seconds": "{number}์ดˆ ์ „",
   "reply_indicator.cancel": "์ทจ์†Œ",
   "report.placeholder": "์ฝ”๋ฉ˜ํŠธ",
   "report.submit": "์‹ ๊ณ ํ•˜๊ธฐ",
   "report.target": "๋ฌธ์ œ๊ฐ€ ๋œ ์‚ฌ์šฉ์ž",
   "search.placeholder": "๊ฒ€์ƒ‰",
-  "search_popout.search_format": "Advanced search format",
-  "search_popout.tips.hashtag": "hashtag",
-  "search_popout.tips.status": "status",
-  "search_popout.tips.text": "Simple text returns matching display names, usernames and hashtags",
-  "search_popout.tips.user": "user",
+  "search_popout.search_format": "๊ณ ๊ธ‰ ๊ฒ€์ƒ‰ ๋ฐฉ๋ฒ•",
+  "search_popout.tips.hashtag": "ํ•ด์‹œํƒœ๊ทธ",
+  "search_popout.tips.status": "ํˆฟ",
+  "search_popout.tips.text": "๋‹จ์ˆœํ•œ ํ…์ŠคํŠธ ๊ฒ€์ƒ‰์€ ๊ด€๊ณ„๋œ ํ”„๋กœํ•„ ์ด๋ฆ„, ์œ ์ € ์ด๋ฆ„ ๊ทธ๋ฆฌ๊ณ  ํ•ด์‹œํƒœ๊ทธ๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค",
+  "search_popout.tips.user": "์œ ์ €",
   "search_results.total": "{count, number}๊ฑด์˜ ๊ฒฐ๊ณผ",
   "standalone.public_title": "A look inside...",
   "status.cannot_reblog": "์ด ํฌ์ŠคํŠธ๋Š” ๋ถ€์ŠคํŠธ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค",
   "status.delete": "์‚ญ์ œ",
-  "status.embed": "Embed",
+  "status.embed": "๊ณต์œ ํ•˜๊ธฐ",
   "status.favourite": "์ฆ๊ฒจ์ฐพ๊ธฐ",
   "status.load_more": "๋” ๋ณด๊ธฐ",
   "status.media_hidden": "๋ฏธ๋””์–ด ์ˆจ๊ฒจ์ง",
   "status.mention": "๋‹ต์žฅ",
   "status.mute_conversation": "์ด ๋Œ€ํ™”๋ฅผ ๋ฎคํŠธ",
   "status.open": "์ƒ์„ธ ์ •๋ณด ํ‘œ์‹œ",
-  "status.pin": "Pin on profile",
+  "status.pin": "๊ณ ์ •",
   "status.reblog": "๋ถ€์ŠคํŠธ",
   "status.reblogged_by": "{name}๋‹˜์ด ๋ถ€์ŠคํŠธ ํ–ˆ์Šต๋‹ˆ๋‹ค",
   "status.reply": "๋‹ต์žฅ",
@@ -193,7 +197,7 @@
   "status.show_less": "์ˆจ๊ธฐ๊ธฐ",
   "status.show_more": "๋” ๋ณด๊ธฐ",
   "status.unmute_conversation": "์ด ๋Œ€ํ™”์˜ ๋ฎคํŠธ ํ•ด์ œํ•˜๊ธฐ",
-  "status.unpin": "Unpin from profile",
+  "status.unpin": "๊ณ ์ • ํ•ด์ œ",
   "tabs_bar.compose": "ํฌ์ŠคํŠธ",
   "tabs_bar.federated_timeline": "์—ฐํ•ฉ",
   "tabs_bar.home": "ํ™ˆ",
@@ -212,5 +216,9 @@
   "video.mute": "Mute sound",
   "video.pause": "Pause",
   "video.play": "Play",
-  "video.unmute": "Unmute sound"
+  "video.unmute": "Unmute sound",
+  "video_player.expand": "Expand video",
+  "video_player.toggle_sound": "Toggle sound",
+  "video_player.toggle_visible": "Toggle visibility",
+  "video_player.video_error": "Video could not be played"
 }
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 4715f60ef..773f2d1e4 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -179,6 +179,7 @@
   "status.load_more": "Cargar mai",
   "status.media_hidden": "Mรจdia rescondut",
   "status.mention": "Mencionar",
+  "status.more": "Mai",
   "status.mute_conversation": "Rescondre la conversacion",
   "status.open": "Desplegar aqueste estatut",
   "status.pin": "Penjar al perfil",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index c8228c0cb..77da77e10 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -179,6 +179,7 @@
   "status.load_more": "Zaล‚aduj wiฤ™cej",
   "status.media_hidden": "Zawartoล›ฤ‡ multimedialna ukryta",
   "status.mention": "Wspomnij o @{name}",
+  "status.more": "Wiฤ™cej",
   "status.mute_conversation": "Wycisz konwersacjฤ™",
   "status.open": "Rozszerz ten wpis",
   "status.pin": "Przypnij do profilu",
diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js
index c85cd5800..93d2eaf10 100644
--- a/app/javascript/mastodon/main.js
+++ b/app/javascript/mastodon/main.js
@@ -1,6 +1,5 @@
-import * as OfflinePluginRuntime from 'offline-plugin/runtime';
 import * as WebPushSubscription from './web_push_subscription';
-import Mastodon from 'mastodon/containers/mastodon';
+import Mastodon from './containers/mastodon';
 import React from 'react';
 import ReactDOM from 'react-dom';
 import ready from './ready';
@@ -25,7 +24,7 @@ function main() {
     ReactDOM.render(<Mastodon {...props} />, mountNode);
     if (process.env.NODE_ENV === 'production') {
       // avoid offline in dev mode because it's harder to debug
-      OfflinePluginRuntime.install();
+      require('offline-plugin/runtime').install();
       WebPushSubscription.register();
     }
     perf.stop('main()');
diff --git a/app/javascript/mastodon/reducers/timelines.js b/app/javascript/mastodon/reducers/timelines.js
index b17d74ef3..c3f117647 100644
--- a/app/javascript/mastodon/reducers/timelines.js
+++ b/app/javascript/mastodon/reducers/timelines.js
@@ -31,10 +31,10 @@ const initialTimeline = ImmutableMap({
 });
 
 const normalizeTimeline = (state, timeline, statuses, next) => {
-  const ids       = ImmutableList(statuses.map(status => status.get('id')));
+  const oldIds    = state.getIn([timeline, 'items'], ImmutableList());
+  const ids       = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
   const wasLoaded = state.getIn([timeline, 'loaded']);
   const hadNext   = state.getIn([timeline, 'next']);
-  const oldIds    = state.getIn([timeline, 'items'], ImmutableList());
 
   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
     mMap.set('loaded', true);
@@ -45,8 +45,8 @@ const normalizeTimeline = (state, timeline, statuses, next) => {
 };
 
 const appendNormalizedTimeline = (state, timeline, statuses, next) => {
-  const ids    = ImmutableList(statuses.map(status => status.get('id')));
   const oldIds = state.getIn([timeline, 'items'], ImmutableList());
+  const ids    = ImmutableList(statuses.map(status => status.get('id'))).filter(newId => !oldIds.includes(newId));
 
   return state.update(timeline, initialTimeline, map => map.withMutations(mMap => {
     mMap.set('isLoading', false);
diff --git a/app/javascript/mastodon/test_setup.js b/app/javascript/mastodon/test_setup.js
new file mode 100644
index 000000000..80148379b
--- /dev/null
+++ b/app/javascript/mastodon/test_setup.js
@@ -0,0 +1,5 @@
+import { configure } from 'enzyme';
+import Adapter from 'enzyme-adapter-react-16';
+
+const adapter = new Adapter();
+configure({ adapter });
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index aa94006c6..d275c3bb0 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -2,7 +2,8 @@ import loadPolyfills from '../mastodon/load_polyfills';
 
 // import default stylesheet with variables
 require('font-awesome/css/font-awesome.css');
-import 'styles/application';
+
+import '../styles/application.scss';
 
 require.context('../images/', true);
 
diff --git a/app/javascript/packs/common.js b/app/javascript/packs/common.js
index de0c68fa5..5ac6504d4 100644
--- a/app/javascript/packs/common.js
+++ b/app/javascript/packs/common.js
@@ -1,6 +1,9 @@
 import { start } from 'rails-ujs';
+import 'font-awesome/css/font-awesome.css';
 
 // import common styling
 require('../styles/common.scss');
 
+require.context('../images/', true);
+
 start();
diff --git a/app/javascript/themes/spin/pack.js b/app/javascript/themes/spin/pack.js
index dab0e93a4..b11ac4802 100644
--- a/app/javascript/themes/spin/pack.js
+++ b/app/javascript/themes/spin/pack.js
@@ -1,2 +1,2 @@
-import 'packs/application';
-import 'themes/spin/style';
+import '../../packs/application';
+import './style.scss';
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 5a3af7206..9a64d58bd 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -100,11 +100,24 @@ class FeedManager
   end
 
   def populate_feed(account)
-    prepopulate_limit = FeedManager::MAX_ITEMS / 4
-    statuses = Status.as_home_timeline(account).order(account_id: :desc).limit(prepopulate_limit)
-    statuses.reverse_each do |status|
-      next if filter_from_home?(status, account)
-      add_to_feed(:home, account, status)
+    added  = 0
+    limit  = FeedManager::MAX_ITEMS / 2
+    max_id = nil
+
+    loop do
+      statuses = Status.as_home_timeline(account)
+                       .paginate_by_max_id(limit, max_id)
+
+      break if statuses.empty?
+
+      statuses.each do |status|
+        next if filter_from_home?(status, account)
+        added += 1 if add_to_feed(:home, account, status)
+      end
+
+      break unless added.zero?
+
+      max_id = statuses.last.id
     end
   end
 
@@ -167,13 +180,19 @@ class FeedManager
   # either action is appropriate.
   def add_to_feed(timeline_type, account, status)
     timeline_key = key(timeline_type, account.id)
-    reblog_key = key(timeline_type, account.id, 'reblogs')
+    reblog_key   = key(timeline_type, account.id, 'reblogs')
 
     if status.reblog?
+      reblog_set_key = key(timeline_type, account.id, "reblogs:#{status.reblog_of_id}")
+
       # If the original status or a reblog of it is within
       # REBLOG_FALLOFF statuses from the top, do not re-insert it into
       # the feed
       rank = redis.zrevrank(timeline_key, status.reblog_of_id)
+
+      redis.sadd(reblog_set_key, status.reblog_of_id) unless rank.nil?
+      redis.sadd(reblog_set_key, status.id)
+
       return false if !rank.nil? && rank < FeedManager::REBLOG_FALLOFF
 
       reblog_rank = redis.zrevrank(reblog_key, status.reblog_of_id)
@@ -194,7 +213,7 @@ class FeedManager
   # do so if appropriate.
   def remove_from_feed(timeline_type, account, status)
     timeline_key = key(timeline_type, account.id)
-    reblog_key = key(timeline_type, account.id, 'reblogs')
+    reblog_key   = key(timeline_type, account.id, 'reblogs')
 
     if status.reblog?
       # 1. If the reblogging status is not in the feed, stop.
@@ -204,12 +223,21 @@ class FeedManager
       # 2. Remove the reblogged status from the `:reblogs` zset.
       redis.zrem(reblog_key, status.reblog_of_id)
 
-      # 3. Add the reblogged status to the feed using the reblogging
-      # status' ID as its score, and the reblogged status' ID as its
-      # value.
-      redis.zadd(timeline_key, status.id, status.reblog_of_id)
+      # 3. Remove reblog from set of this status's reblogs, and
+      # re-insert another reblog or original into the feed if
+      # one remains in the set
+      reblog_set_key = key(timeline_type, account.id, "reblogs:#{status.reblog_of_id}")
+
+      redis.srem(reblog_set_key, status.id)
+      other_reblog = redis.srandmember(reblog_set_key)
+
+      redis.zadd(timeline_key, other_reblog, other_reblog) if other_reblog
 
       # 4. Remove the reblogging status from the feed (as normal)
+      # (outside conditional)
+    else
+      # If the original is getting deleted, no use for reblog references
+      redis.del(key(timeline_type, account.id, "reblogs:#{status.id}"))
     end
 
     redis.zrem(timeline_key, status.id)
diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb
index 3b7a856ee..d86959c0b 100644
--- a/app/lib/user_settings_decorator.rb
+++ b/app/lib/user_settings_decorator.rb
@@ -23,6 +23,7 @@ class UserSettingsDecorator
     user.settings['boost_modal']         = boost_modal_preference if change?('setting_boost_modal')
     user.settings['delete_modal']        = delete_modal_preference if change?('setting_delete_modal')
     user.settings['auto_play_gif']       = auto_play_gif_preference if change?('setting_auto_play_gif')
+    user.settings['reduce_motion']       = reduce_motion_preference if change?('setting_reduce_motion')
     user.settings['system_font_ui']      = system_font_ui_preference if change?('setting_system_font_ui')
     user.settings['noindex']             = noindex_preference if change?('setting_noindex')
     user.settings['theme']               = theme_preference if change?('setting_theme')
@@ -64,6 +65,10 @@ class UserSettingsDecorator
     boolean_cast_setting 'setting_auto_play_gif'
   end
 
+  def reduce_motion_preference
+    boolean_cast_setting 'setting_reduce_motion'
+  end
+
   def noindex_preference
     boolean_cast_setting 'setting_noindex'
   end
diff --git a/app/models/user.rb b/app/models/user.rb
index 3bf069a31..325e27f44 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -102,6 +102,10 @@ class User < ApplicationRecord
     settings.auto_play_gif
   end
 
+  def setting_reduce_motion
+    settings.reduce_motion
+  end
+
   def setting_system_font_ui
     settings.system_font_ui
   end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 0992771fc..1f5ee789a 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -25,6 +25,7 @@ class InitialStateSerializer < ActiveModel::Serializer
       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[:reduce_motion]  = object.current_account.user.setting_reduce_motion
     end
 
     store
diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml
index 7475e3fd2..69e26a7be 100644
--- a/app/views/settings/preferences/show.html.haml
+++ b/app/views/settings/preferences/show.html.haml
@@ -35,6 +35,7 @@
 
   .fields-group
     = f.input :setting_auto_play_gif, as: :boolean, wrapper: :with_label
+    = f.input :setting_reduce_motion, as: :boolean, wrapper: :with_label
     = f.input :setting_system_font_ui, as: :boolean, wrapper: :with_label
 
   .actions
diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml
index a4eab16df..b488bd9ba 100644
--- a/app/views/stream_entries/_detailed_status.html.haml
+++ b/app/views/stream_entries/_detailed_status.html.haml
@@ -19,15 +19,14 @@
         %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more')
     .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }<
       = Formatter.instance.format(status, custom_emojify: true)
-
       - if !status.media_attachments.empty?
         - if status.media_attachments.first.video?
           - video = status.media_attachments.first
-          %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }}><
+          %div{ data: { component: 'Video', props: Oj.dump(src: video.file.url(:original), preview: video.file.url(:small), sensitive: status.sensitive?, width: 670, height: 380) }}<
         - else
-          %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}><
+          %div{ data: { component: 'MediaGallery', props: Oj.dump(height: 380, sensitive: status.sensitive?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json }) }}<
       - elsif status.preview_cards.first
-        %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}><
+        %div{ data: { component: 'Card', props: Oj.dump('maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_cards.first, serializer: REST::PreviewCardSerializer).as_json) }}<
 
   .detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
@@ -40,9 +39,16 @@
       - else
         = link_to status.application.name, status.application.website, class: 'detailed-status__application', target: '_blank', rel: 'noopener'
       ยท
-    %span<
-      = fa_icon('retweet')
-      %span= status.reblogs_count
+    - if status.direct_visibility?
+      %span<
+        = fa_icon('envelope')
+    - elsif status.private_visibility?
+      %span<
+        = fa_icon('lock')
+    - else
+      %span<
+        = fa_icon('retweet')
+        %span= status.reblogs_count
     ยท
     %span<
       = fa_icon('star')
diff --git a/app/workers/scheduler/feed_cleanup_scheduler.rb b/app/workers/scheduler/feed_cleanup_scheduler.rb
index dbebaa2c3..222f5ed84 100644
--- a/app/workers/scheduler/feed_cleanup_scheduler.rb
+++ b/app/workers/scheduler/feed_cleanup_scheduler.rb
@@ -6,8 +6,9 @@ class Scheduler::FeedCleanupScheduler
 
   def perform
     redis.pipelined do
-      inactive_users.pluck(:account_id).each do |account_id|
+      inactive_users.each do |account_id|
         redis.del(FeedManager.instance.key(:home, account_id))
+        redis.del(FeedManager.instance.key(:home, account_id, 'reblogs'))
       end
     end
   end
@@ -15,7 +16,7 @@ class Scheduler::FeedCleanupScheduler
   private
 
   def inactive_users
-    User.confirmed.inactive
+    @inactive_users ||= User.confirmed.inactive.pluck(:account_id)
   end
 
   def redis