about summary refs log tree commit diff
path: root/app/javascript
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript')
-rw-r--r--app/javascript/flavours/glitch/actions/compose.js3
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js8
-rw-r--r--app/javascript/flavours/glitch/components/error_boundary.js66
-rw-r--r--app/javascript/flavours/glitch/components/intersection_observer_article.js29
-rw-r--r--app/javascript/flavours/glitch/components/status.js33
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js8
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/item/index.js6
-rw-r--r--app/javascript/flavours/glitch/features/ui/containers/status_list_container.js23
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js35
-rw-r--r--app/javascript/flavours/glitch/util/scroll.js6
-rw-r--r--app/javascript/mastodon/actions/compose.js2
-rw-r--r--app/javascript/mastodon/actions/notifications.js8
-rw-r--r--app/javascript/mastodon/reducers/compose.js1
-rw-r--r--app/javascript/mastodon/scroll.js6
-rw-r--r--app/javascript/mastodon/selectors/index.js35
15 files changed, 164 insertions, 105 deletions
diff --git a/app/javascript/flavours/glitch/actions/compose.js b/app/javascript/flavours/glitch/actions/compose.js
index 69cc6827f..2312bae63 100644
--- a/app/javascript/flavours/glitch/actions/compose.js
+++ b/app/javascript/flavours/glitch/actions/compose.js
@@ -138,7 +138,8 @@ export function submitCompose(routerHistory) {
   return function (dispatch, getState) {
     let status = getState().getIn(['compose', 'text'], '');
     let media  = getState().getIn(['compose', 'media_attachments']);
-    let spoilerText = getState().getIn(['compose', 'spoiler_text'], '');
+    const spoilers = getState().getIn(['compose', 'spoiler']) || getState().getIn(['local_settings', 'always_show_spoilers_field']);
+    let spoilerText = spoilers ? getState().getIn(['compose', 'spoiler_text'], '') : '';
 
     if ((!status || !status.length) && media.size === 0) {
       return;
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 2107503db..c057a5298 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -11,7 +11,7 @@ import { saveSettings } from './settings';
 import { defineMessages } from 'react-intl';
 import { List as ImmutableList } from 'immutable';
 import { unescapeHTML } from 'flavours/glitch/util/html';
-import { getFilters, regexFromFilters } from 'flavours/glitch/selectors';
+import { getFiltersRegex } from 'flavours/glitch/selectors';
 
 export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
 
@@ -57,13 +57,13 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
     const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
     const showAlert    = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
     const playSound    = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
-    const filters      = getFilters(getState(), { contextType: 'notifications' });
+    const filters      = getFiltersRegex(getState(), { contextType: 'notifications' });
 
     let filtered = false;
 
     if (notification.type === 'mention') {
-      const dropRegex   = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
-      const regex       = regexFromFilters(filters);
+      const dropRegex   = filters[0];
+      const regex       = filters[1];
       const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
 
       if (dropRegex && dropRegex.test(searchIndex)) {
diff --git a/app/javascript/flavours/glitch/components/error_boundary.js b/app/javascript/flavours/glitch/components/error_boundary.js
index 142a0c21a..dd21f2930 100644
--- a/app/javascript/flavours/glitch/components/error_boundary.js
+++ b/app/javascript/flavours/glitch/components/error_boundary.js
@@ -50,43 +50,43 @@ export default class ErrorBoundary extends React.PureComponent {
           <h1><FormattedMessage id='web_app_crash.title' defaultMessage="We're sorry, but something went wrong with the Mastodon app." /></h1>
           <p>
             <FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' />
-            <ul>
-              <li>
-                <FormattedMessage
-                  id='web_app_crash.report_issue'
-                  defaultMessage='Report a bug in the {issuetracker}'
-                  values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
-                />
-                { debugInfo !== '' && (
-                  <details>
-                    <summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
-                    <textarea
-                      className='web_app_crash-stacktrace'
-                      value={debugInfo}
-                      rows='10'
-                      readOnly
-                    />
-                  </details>
-                )}
-              </li>
+          </p>
+          <ul>
+            <li>
+              <FormattedMessage
+                id='web_app_crash.report_issue'
+                defaultMessage='Report a bug in the {issuetracker}'
+                values={{ issuetracker: <a href='https://github.com/glitch-soc/mastodon/issues' rel='noopener' target='_blank'><FormattedMessage id='web_app_crash.issue_tracker' defaultMessage='issue tracker' /></a> }}
+              />
+              { debugInfo !== '' && (
+                <details>
+                  <summary><FormattedMessage id='web_app_crash.debug_info' defaultMessage='Debug information' /></summary>
+                  <textarea
+                    className='web_app_crash-stacktrace'
+                    value={debugInfo}
+                    rows='10'
+                    readOnly
+                  />
+                </details>
+              )}
+            </li>
+            <li>
+              <FormattedMessage
+                id='web_app_crash.reload_page'
+                defaultMessage='{reload} the current page'
+                values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }}
+              />
+            </li>
+            { preferencesLink !== undefined && (
               <li>
                 <FormattedMessage
-                  id='web_app_crash.reload_page'
-                  defaultMessage='{reload} the current page'
-                  values={{ reload: <a href='#' onClick={this.handleReload}><FormattedMessage id='web_app_crash.reload' defaultMessage='Reload' /></a> }}
+                  id='web_app_crash.change_your_settings'
+                  defaultMessage='Change your {settings}'
+                  values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
                 />
               </li>
-              { preferencesLink !== undefined && (
-                <li>
-                  <FormattedMessage
-                    id='web_app_crash.change_your_settings'
-                    defaultMessage='Change your {settings}'
-                    values={{ settings: <a href={preferencesLink}><FormattedMessage id='web_app_crash.settings' defaultMessage='settings' /></a> }}
-                  />
-                </li>
-              )}
-            </ul>
-          </p>
+            )}
+          </ul>
         </div>
       </div>
     );
diff --git a/app/javascript/flavours/glitch/components/intersection_observer_article.js b/app/javascript/flavours/glitch/components/intersection_observer_article.js
index 900c98638..03b3700df 100644
--- a/app/javascript/flavours/glitch/components/intersection_observer_article.js
+++ b/app/javascript/flavours/glitch/components/intersection_observer_article.js
@@ -1,10 +1,12 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import ImmutablePureComponent from 'react-immutable-pure-component';
 import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
 import getRectFromEntry from 'flavours/glitch/util/get_rect_from_entry';
 
-export default class IntersectionObserverArticle extends ImmutablePureComponent {
+// Diff these props in the "unrendered" state
+const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
+
+export default class IntersectionObserverArticle extends React.Component {
 
   static propTypes = {
     intersectionObserverWrapper: PropTypes.object.isRequired,
@@ -22,20 +24,21 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
   }
 
   shouldComponentUpdate (nextProps, nextState) {
-    if (!nextState.isIntersecting && nextState.isHidden) {
-      // It's only if we're not intersecting (i.e. offscreen) and isHidden is true
-      // that either "isIntersecting" or "isHidden" matter, and then they're
-      // the only things that matter (and updated ARIA attributes).
-      return this.state.isIntersecting || !this.state.isHidden || nextProps.listLength !== this.props.listLength;
-    } else if (nextState.isIntersecting && !this.state.isIntersecting) {
-      // If we're going from a non-intersecting state to an intersecting state,
-      // (i.e. offscreen to onscreen), then we definitely need to re-render
+    const isUnrendered = !this.state.isIntersecting && (this.state.isHidden || this.props.cachedHeight);
+    const willBeUnrendered = !nextState.isIntersecting && (nextState.isHidden || nextProps.cachedHeight);
+    if (!!isUnrendered !== !!willBeUnrendered) {
+      // If we're going from rendered to unrendered (or vice versa) then update
       return true;
     }
-    // Otherwise, diff based on "updateOnProps" and "updateOnStates"
-    return super.shouldComponentUpdate(nextProps, nextState);
+    // If we are and remain hidden, diff based on props
+    if (isUnrendered) {
+      return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
+    }
+    // Else, assume the children have changed
+    return true;
   }
 
+
   componentDidMount () {
     const { intersectionObserverWrapper, id } = this.props;
 
@@ -119,7 +122,7 @@ export default class IntersectionObserverArticle extends ImmutablePureComponent
         data-id={id}
         tabIndex='0'
         style={style}>
-          {children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || cachedHeight) })}
+          {children && React.cloneElement(children, { hidden: !isIntersecting && (isHidden || !!cachedHeight) })}
       </article>
     );
   }
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index ed2623ebb..022ae6de8 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -55,8 +55,8 @@ export const defaultMediaVisibility = (status, settings) => {
   return (displayMedia !== 'hide_all' && !status.get('sensitive') || displayMedia === 'show_all');
 }
 
-@injectIntl
-export default class Status extends ImmutablePureComponent {
+export default @injectIntl
+class Status extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
@@ -105,6 +105,7 @@ export default class Status extends ImmutablePureComponent {
     showMedia: undefined,
     statusId: undefined,
     revealBehindCW: undefined,
+    showCard: false,
   }
 
   // Avoid checking props that are functions (and whose equality will always
@@ -255,28 +256,32 @@ export default class Status extends ImmutablePureComponent {
       this.setState({ autoCollapsed: true });
     }
 
-    this.didShowCard  = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards');
+    // Hack to fix timeline jumps when a preview card is fetched
+    this.setState({
+      showCard: !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards'),
+    });
   }
 
+  //  Hack to fix timeline jumps on second rendering when auto-collapsing
+  //  or on subsequent rendering when a preview card has been fetched
   getSnapshotBeforeUpdate (prevProps, prevState) {
-    if (this.props.getScrollPosition) {
+    if (!this.props.getScrollPosition) return null;
+
+    const { muted, hidden, status, settings } = this.props;
+
+    const doShowCard = !muted && !hidden && status && status.get('card') && settings.get('inline_preview_cards');
+    if (this.state.autoCollapsed || (doShowCard && !this.state.showCard)) {
+      if (doShowCard) this.setState({ showCard: true });
+      if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
       return this.props.getScrollPosition();
     } else {
       return null;
     }
   }
 
-  //  Hack to fix timeline jumps on second rendering when auto-collapsing
   componentDidUpdate (prevProps, prevState, snapshot) {
-    const doShowCard  = !this.props.muted && !this.props.hidden && this.props.status && this.props.status.get('card') && this.props.settings.get('inline_preview_cards');
-    if (this.state.autoCollapsed || (doShowCard && !this.didShowCard)) {
-      if (doShowCard) this.didShowCard = true;
-      if (this.state.autoCollapsed) this.setState({ autoCollapsed: false });
-      if (snapshot !== null && this.props.updateScrollBottom) {
-        if (this.node.offsetTop < snapshot.top) {
-          this.props.updateScrollBottom(snapshot.height - snapshot.top);
-        }
-      }
+    if (snapshot !== null && this.props.updateScrollBottom && this.node.offsetTop < snapshot.top) {
+      this.props.updateScrollBottom(snapshot.height - snapshot.top);
     }
   }
 
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 85bc4a976..c424fbde1 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -284,11 +284,11 @@ export default class StatusActionBar extends ImmutablePureComponent {
       <div className='status__action-bar'>
         {replyButton}
         {!directMessage && [
-          <IconButton className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />,
-          <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />,
+          <IconButton key='reblog-button' className='status__action-bar-button' disabled={reblogDisabled} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogDisabled ? intl.formatMessage(messages.cannot_reblog) : intl.formatMessage(reblogMessage)} icon={reblogIcon} onClick={this.handleReblogClick} />,
+          <IconButton key='favourite-button' className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />,
           shareButton,
-          <IconButton className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />,
-          <div className='status__action-bar-dropdown'>
+          <IconButton key='bookmark-button' className='status__action-bar-button bookmark-icon' disabled={anonymousAccess} active={status.get('bookmarked')} pressed={status.get('bookmarked')} title={intl.formatMessage(messages.bookmark)} icon='bookmark' onClick={this.handleBookmarkClick} />,
+          <div key='dropdown-button' className='status__action-bar-dropdown'>
             <DropdownMenuContainer disabled={anonymousAccess} status={status} items={menu} icon='ellipsis-h' size={18} direction='right' ariaLabel={intl.formatMessage(messages.more)} />
           </div>,
         ]}
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/item/index.js b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js
index 66b937365..5a68523f6 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/item/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/item/index.js
@@ -8,7 +8,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 export default class LocalSettingsPageItem extends React.PureComponent {
 
   static propTypes = {
-    children: PropTypes.element.isRequired,
+    children: PropTypes.node.isRequired,
     dependsOn: PropTypes.array,
     dependsOnNot: PropTypes.array,
     id: PropTypes.string.isRequired,
@@ -63,12 +63,12 @@ export default class LocalSettingsPageItem extends React.PureComponent {
               disabled={!enabled}
             />
             {opt.message}
-            {opt.hint && <span class='hint'>{opt.hint}</span>}
+            {opt.hint && <span className='hint'>{opt.hint}</span>}
           </label>
         );
       });
       return (
-        <div class='glitch local-settings__page__item radio_buttons'>
+        <div className='glitch local-settings__page__item radio_buttons'>
           <fieldset>
             <legend>{children}</legend>
             {optionElems}
diff --git a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
index e0c017f82..deb8b7763 100644
--- a/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
+++ b/app/javascript/flavours/glitch/features/ui/containers/status_list_container.js
@@ -6,19 +6,26 @@ import { createSelector } from 'reselect';
 import { debounce } from 'lodash';
 import { me } from 'flavours/glitch/util/initial_state';
 
-const makeGetStatusIds = () => createSelector([
-  (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
-  (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
-  (state)           => state.get('statuses'),
-], (columnSettings, statusIds, statuses) => {
-  const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
-  let regex      = null;
+const getRegex = createSelector([
+  (state, { type }) => state.getIn(['settings', type, 'regex', 'body']),
+], (rawRegex) => {
+  let regex = null;
 
   try {
-    regex = rawRegex && new RegExp(rawRegex, 'i');
+    regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
   } catch (e) {
     // Bad regex, don't affect filters
   }
+  return regex;
+});
+
+const makeGetStatusIds = () => createSelector([
+  (state, { type }) => state.getIn(['settings', type], ImmutableMap()),
+  (state, { type }) => state.getIn(['timelines', type, 'items'], ImmutableList()),
+  (state)           => state.get('statuses'),
+  getRegex,
+], (columnSettings, statusIds, statuses, regex) => {
+  const rawRegex = columnSettings.getIn(['regex', 'body'], '').trim();
 
   return statusIds.filter(id => {
     if (id === null) return true;
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index 2d4f3f7b4..9e4582532 100644
--- a/app/javascript/flavours/glitch/selectors/index.js
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -1,5 +1,5 @@
 import { createSelector } from 'reselect';
-import { List as ImmutableList } from 'immutable';
+import { List as ImmutableList, is } from 'immutable';
 import { me } from 'flavours/glitch/util/initial_state';
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
@@ -36,12 +36,10 @@ const toServerSideType = columnType => {
   }
 };
 
-export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
-
 const escapeRegExp = string =>
   string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
 
-export const regexFromFilters = filters => {
+const regexFromFilters = filters => {
   if (filters.size === 0) {
     return null;
   }
@@ -63,6 +61,27 @@ export const regexFromFilters = filters => {
   }).join('|'), 'i');
 };
 
+// Memoize the filter regexps for each valid server contextType
+const makeGetFiltersRegex = () => {
+  let memo = {};
+
+  return (state, { contextType }) => {
+    if (!contextType) return ImmutableList();
+
+    const serverSideType = toServerSideType(contextType);
+    const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
+
+    if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
+      const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
+      const regex = regexFromFilters(filters);
+      memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
+    }
+    return memo[serverSideType].results;
+  };
+};
+
+export const getFiltersRegex = makeGetFiltersRegex();
+
 export const makeGetStatus = () => {
   return createSelector(
     [
@@ -70,21 +89,21 @@ export const makeGetStatus = () => {
       (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
-      getFilters,
+      getFiltersRegex,
     ],
 
-    (statusBase, statusReblog, accountBase, accountReblog, filters) => {
+    (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
       if (!statusBase) {
         return null;
       }
 
-      const dropRegex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters.filter(filter => filter.get('irreversible')));
+      const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
 
       if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
         return null;
       }
 
-      const regex  = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters);
+      const regex  = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
       let filtered = false;
 
       if (statusReblog) {
diff --git a/app/javascript/flavours/glitch/util/scroll.js b/app/javascript/flavours/glitch/util/scroll.js
index 2af07e0fb..84fe58269 100644
--- a/app/javascript/flavours/glitch/util/scroll.js
+++ b/app/javascript/flavours/glitch/util/scroll.js
@@ -26,5 +26,7 @@ const scroll = (node, key, target) => {
   };
 };
 
-export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
-export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
+const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style;
+
+export const scrollRight = (node, position) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
+export const scrollTop = (node) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js
index 300fb48a9..fbf97d374 100644
--- a/app/javascript/mastodon/actions/compose.js
+++ b/app/javascript/mastodon/actions/compose.js
@@ -139,7 +139,7 @@ export function submitCompose(routerHistory) {
       in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
       media_ids: media.map(item => item.get('id')),
       sensitive: getState().getIn(['compose', 'sensitive']),
-      spoiler_text: getState().getIn(['compose', 'spoiler_text'], ''),
+      spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
       visibility: getState().getIn(['compose', 'privacy']),
       poll: getState().getIn(['compose', 'poll'], null),
     }, {
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 88788eec9..56c952cb0 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -11,7 +11,7 @@ import { saveSettings } from './settings';
 import { defineMessages } from 'react-intl';
 import { List as ImmutableList } from 'immutable';
 import { unescapeHTML } from '../utils/html';
-import { getFilters, regexFromFilters } from '../selectors';
+import { getFiltersRegex } from '../selectors';
 
 export const NOTIFICATIONS_UPDATE      = 'NOTIFICATIONS_UPDATE';
 export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@@ -43,13 +43,13 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
     const showInColumn = getState().getIn(['settings', 'notifications', 'shows', notification.type], true);
     const showAlert    = getState().getIn(['settings', 'notifications', 'alerts', notification.type], true);
     const playSound    = getState().getIn(['settings', 'notifications', 'sounds', notification.type], true);
-    const filters      = getFilters(getState(), { contextType: 'notifications' });
+    const filters      = getFiltersRegex(getState(), { contextType: 'notifications' });
 
     let filtered = false;
 
     if (notification.type === 'mention') {
-      const dropRegex   = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
-      const regex       = regexFromFilters(filters);
+      const dropRegex   = filters[0];
+      const regex       = filters[1];
       const searchIndex = notification.status.spoiler_text + '\n' + unescapeHTML(notification.status.content);
 
       if (dropRegex && dropRegex.test(searchIndex)) {
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index 29c691144..8cdd29bfe 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -224,6 +224,7 @@ export default function compose(state = initialState, action) {
       }
     });
   case COMPOSE_SPOILER_TEXT_CHANGE:
+    if (!state.get('spoiler')) return state;
     return state
       .set('spoiler_text', action.text)
       .set('idempotencyKey', uuid());
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js
index 2af07e0fb..84fe58269 100644
--- a/app/javascript/mastodon/scroll.js
+++ b/app/javascript/mastodon/scroll.js
@@ -26,5 +26,7 @@ const scroll = (node, key, target) => {
   };
 };
 
-export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
-export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
+const isScrollBehaviorSupported = 'scrollBehavior' in document.documentElement.style;
+
+export const scrollRight = (node, position) => isScrollBehaviorSupported ? node.scrollTo({ left: position, behavior: 'smooth' }) : scroll(node, 'scrollLeft', position);
+export const scrollTop = (node) => isScrollBehaviorSupported ? node.scrollTo({ top: 0, behavior: 'smooth' }) : scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index ff6c7fdfb..c87654547 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -1,5 +1,5 @@
 import { createSelector } from 'reselect';
-import { List as ImmutableList } from 'immutable';
+import { List as ImmutableList, is } from 'immutable';
 import { me } from '../initial_state';
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
@@ -36,12 +36,10 @@ const toServerSideType = columnType => {
   }
 };
 
-export const getFilters = (state, { contextType }) => state.get('filters', ImmutableList()).filter(filter => contextType && filter.get('context').includes(toServerSideType(contextType)) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
-
 const escapeRegExp = string =>
   string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
 
-export const regexFromFilters = filters => {
+const regexFromFilters = filters => {
   if (filters.size === 0) {
     return null;
   }
@@ -63,6 +61,27 @@ export const regexFromFilters = filters => {
   }).join('|'), 'i');
 };
 
+// Memoize the filter regexps for each valid server contextType
+const makeGetFiltersRegex = () => {
+  let memo = {};
+
+  return (state, { contextType }) => {
+    if (!contextType) return ImmutableList();
+
+    const serverSideType = toServerSideType(contextType);
+    const filters = state.get('filters', ImmutableList()).filter(filter => filter.get('context').includes(serverSideType) && (filter.get('expires_at') === null || Date.parse(filter.get('expires_at')) > (new Date())));
+
+    if (!memo[serverSideType] || !is(memo[serverSideType].filters, filters)) {
+      const dropRegex = regexFromFilters(filters.filter(filter => filter.get('irreversible')));
+      const regex = regexFromFilters(filters);
+      memo[serverSideType] = { filters: filters, results: [dropRegex, regex] };
+    }
+    return memo[serverSideType].results;
+  };
+};
+
+export const getFiltersRegex = makeGetFiltersRegex();
+
 export const makeGetStatus = () => {
   return createSelector(
     [
@@ -70,10 +89,10 @@ export const makeGetStatus = () => {
       (state, { id }) => state.getIn(['statuses', state.getIn(['statuses', id, 'reblog'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', id, 'account'])]),
       (state, { id }) => state.getIn(['accounts', state.getIn(['statuses', state.getIn(['statuses', id, 'reblog']), 'account'])]),
-      getFilters,
+      getFiltersRegex,
     ],
 
-    (statusBase, statusReblog, accountBase, accountReblog, filters) => {
+    (statusBase, statusReblog, accountBase, accountReblog, filtersRegex) => {
       if (!statusBase) {
         return null;
       }
@@ -84,12 +103,12 @@ export const makeGetStatus = () => {
         statusReblog = null;
       }
 
-      const dropRegex = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters.filter(filter => filter.get('irreversible')));
+      const dropRegex = (accountReblog || accountBase).get('id') !== me && filtersRegex[0];
       if (dropRegex && dropRegex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'))) {
         return null;
       }
 
-      const regex     = (accountReblog || accountBase).get('id') !== me && regexFromFilters(filters);
+      const regex     = (accountReblog || accountBase).get('id') !== me && filtersRegex[1];
       const filtered  = regex && regex.test(statusBase.get('reblog') ? statusReblog.get('search_index') : statusBase.get('search_index'));
 
       return statusBase.withMutations(map => {