about summary refs log tree commit diff
path: root/app/javascript/flavours/glitch
diff options
context:
space:
mode:
Diffstat (limited to 'app/javascript/flavours/glitch')
-rw-r--r--app/javascript/flavours/glitch/actions/importer/index.js2
-rw-r--r--app/javascript/flavours/glitch/actions/importer/normalizer.js5
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js6
-rw-r--r--app/javascript/flavours/glitch/actions/statuses.js34
-rw-r--r--app/javascript/flavours/glitch/components/hashtag.js18
-rw-r--r--app/javascript/flavours/glitch/components/icon_button.js12
-rw-r--r--app/javascript/flavours/glitch/components/status.js109
-rw-r--r--app/javascript/flavours/glitch/components/status_action_bar.js6
-rw-r--r--app/javascript/flavours/glitch/components/status_content.js6
-rw-r--r--app/javascript/flavours/glitch/containers/mastodon.js1
-rw-r--r--app/javascript/flavours/glitch/containers/status_container.js17
-rw-r--r--app/javascript/flavours/glitch/features/account/components/header.js9
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js5
-rw-r--r--app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js1
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js75
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/admin_report.js108
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js21
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js14
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/report.js62
-rw-r--r--app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js13
-rw-r--r--app/javascript/flavours/glitch/features/status/components/action_bar.js6
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js26
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js46
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/image_loader.js26
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/link_footer.js9
-rw-r--r--app/javascript/flavours/glitch/packs/settings.js1
-rw-r--r--app/javascript/flavours/glitch/permissions.js3
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js4
-rw-r--r--app/javascript/flavours/glitch/reducers/meta.js3
-rw-r--r--app/javascript/flavours/glitch/reducers/notifications.js3
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js3
-rw-r--r--app/javascript/flavours/glitch/reducers/statuses.js21
-rw-r--r--app/javascript/flavours/glitch/selectors/index.js17
-rw-r--r--app/javascript/flavours/glitch/styles/accounts.scss6
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss55
-rw-r--r--app/javascript/flavours/glitch/styles/components/accounts.scss29
-rw-r--r--app/javascript/flavours/glitch/styles/components/index.scss6
-rw-r--r--app/javascript/flavours/glitch/styles/components/search.scss7
-rw-r--r--app/javascript/flavours/glitch/styles/components/single_column.scss10
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss35
-rw-r--r--app/javascript/flavours/glitch/util/content_warning.js33
-rw-r--r--app/javascript/flavours/glitch/util/initial_state.js2
42 files changed, 722 insertions, 153 deletions
diff --git a/app/javascript/flavours/glitch/actions/importer/index.js b/app/javascript/flavours/glitch/actions/importer/index.js
index f4372fb31..ec41fea6e 100644
--- a/app/javascript/flavours/glitch/actions/importer/index.js
+++ b/app/javascript/flavours/glitch/actions/importer/index.js
@@ -63,7 +63,7 @@ export function importFetchedStatuses(statuses) {
     const polls = [];
 
     function processStatus(status) {
-      pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id])));
+      pushUnique(normalStatuses, normalizeStatus(status, getState().getIn(['statuses', status.id]), getState().get('local_settings')));
       pushUnique(accounts, status.account);
 
       if (status.reblog && status.reblog.id) {
diff --git a/app/javascript/flavours/glitch/actions/importer/normalizer.js b/app/javascript/flavours/glitch/actions/importer/normalizer.js
index c38af196a..c6acdbdbb 100644
--- a/app/javascript/flavours/glitch/actions/importer/normalizer.js
+++ b/app/javascript/flavours/glitch/actions/importer/normalizer.js
@@ -1,6 +1,7 @@
 import escapeTextContentForBrowser from 'escape-html';
 import emojify from 'flavours/glitch/util/emoji';
 import { unescapeHTML } from 'flavours/glitch/util/html';
+import { autoHideCW } from 'flavours/glitch/util/content_warning';
 
 const domParser = new DOMParser();
 
@@ -41,7 +42,7 @@ export function normalizeAccount(account) {
   return account;
 }
 
-export function normalizeStatus(status, normalOldStatus) {
+export function normalizeStatus(status, normalOldStatus, settings) {
   const normalStatus   = { ...status };
   normalStatus.account = status.account.id;
 
@@ -60,6 +61,7 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.search_index = normalOldStatus.get('search_index');
     normalStatus.contentHtml = normalOldStatus.get('contentHtml');
     normalStatus.spoilerHtml = normalOldStatus.get('spoilerHtml');
+    normalStatus.hidden = normalOldStatus.get('hidden');
   } else {
     const spoilerText   = normalStatus.spoiler_text || '';
     const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
@@ -68,6 +70,7 @@ export function normalizeStatus(status, normalOldStatus) {
     normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
     normalStatus.contentHtml  = emojify(normalStatus.content, emojiMap);
     normalStatus.spoilerHtml  = emojify(escapeTextContentForBrowser(spoilerText), emojiMap);
+    normalStatus.hidden       = (spoilerText.length > 0 || normalStatus.sensitive) && autoHideCW(settings, spoilerText);
   }
 
   return normalStatus;
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index 85938867b..3993b1ea5 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -103,6 +103,10 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
         dispatch(importFetchedStatus(notification.status));
       }
 
+      if (notification.report) {
+        dispatch(importFetchedAccount(notification.report.target_account));
+      }
+
       dispatch({
         type: NOTIFICATIONS_UPDATE,
         notification,
@@ -146,6 +150,7 @@ const excludeTypesFromFilter = filter => {
     'status',
     'update',
     'admin.sign_up',
+    'admin.report',
   ]);
 
   return allTypes.filterNot(item => item === filter).toJS();
@@ -191,6 +196,7 @@ export function expandNotifications({ maxId } = {}, done = noOp) {
 
       dispatch(importFetchedAccounts(response.data.map(item => item.account)));
       dispatch(importFetchedStatuses(response.data.map(item => item.status).filter(status => !!status)));
+      dispatch(importFetchedAccounts(response.data.filter(item => item.report).map(item => item.report.target_account)));
 
       dispatch(expandNotificationsSuccess(response.data, next ? next.uri : null, isLoadingMore, isLoadingRecent, isLoadingRecent && preferPendingItems));
       fetchRelatedRelationships(dispatch, response.data);
diff --git a/app/javascript/flavours/glitch/actions/statuses.js b/app/javascript/flavours/glitch/actions/statuses.js
index 6ffcf181d..1f223f22e 100644
--- a/app/javascript/flavours/glitch/actions/statuses.js
+++ b/app/javascript/flavours/glitch/actions/statuses.js
@@ -24,6 +24,10 @@ export const STATUS_UNMUTE_REQUEST = 'STATUS_UNMUTE_REQUEST';
 export const STATUS_UNMUTE_SUCCESS = 'STATUS_UNMUTE_SUCCESS';
 export const STATUS_UNMUTE_FAIL    = 'STATUS_UNMUTE_FAIL';
 
+export const STATUS_REVEAL   = 'STATUS_REVEAL';
+export const STATUS_HIDE     = 'STATUS_HIDE';
+export const STATUS_COLLAPSE = 'STATUS_COLLAPSE';
+
 export const REDRAFT = 'REDRAFT';
 
 export const STATUS_FETCH_SOURCE_REQUEST = 'STATUS_FETCH_SOURCE_REQUEST';
@@ -277,3 +281,33 @@ export function unmuteStatusFail(id, error) {
     error,
   };
 };
+
+export function hideStatus(ids) {
+  if (!Array.isArray(ids)) {
+    ids = [ids];
+  }
+
+  return {
+    type: STATUS_HIDE,
+    ids,
+  };
+};
+
+export function revealStatus(ids) {
+  if (!Array.isArray(ids)) {
+    ids = [ids];
+  }
+
+  return {
+    type: STATUS_REVEAL,
+    ids,
+  };
+};
+
+export function toggleStatusCollapse(id, isCollapsed) {
+  return {
+    type: STATUS_COLLAPSE,
+    id,
+    isCollapsed,
+  };
+}
diff --git a/app/javascript/flavours/glitch/components/hashtag.js b/app/javascript/flavours/glitch/components/hashtag.js
index 769185a2b..5bbf32c87 100644
--- a/app/javascript/flavours/glitch/components/hashtag.js
+++ b/app/javascript/flavours/glitch/components/hashtag.js
@@ -1,7 +1,7 @@
 // @ts-check
 import React from 'react';
 import { Sparklines, SparklinesCurve } from 'react-sparklines';
-import { FormattedMessage } from 'react-intl';
+import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import Permalink from './permalink';
@@ -9,6 +9,10 @@ import ShortNumber from 'flavours/glitch/components/short_number';
 import Skeleton from 'flavours/glitch/components/skeleton';
 import classNames from 'classnames';
 
+const messages = defineMessages({
+  totalVolume: { id: 'hashtag.total_volume', defaultMessage: 'Total volume in the last {days, plural, one {day} other {{days} days}}' },
+});
+
 class SilentErrorBoundary extends React.Component {
 
   static propTypes = {
@@ -41,10 +45,11 @@ class SilentErrorBoundary extends React.Component {
 const accountsCountRenderer = (displayNumber, pluralReady) => (
   <FormattedMessage
     id='trends.counter_by_accounts'
-    defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} talking'
+    defaultMessage='{count, plural, one {{counter} person} other {{counter} people}} in the past {days, plural, one {day} other {{days} days}}'
     values={{
       count: pluralReady,
       counter: <strong>{displayNumber}</strong>,
+      days: 2,
     }}
   />
 );
@@ -64,7 +69,7 @@ ImmutableHashtag.propTypes = {
   hashtag: ImmutablePropTypes.map.isRequired,
 };
 
-const Hashtag = ({ name, href, to, people, uses, history, className }) => (
+const Hashtag = injectIntl(({ name, href, to, people, uses, history, className, intl }) => (
   <div className={classNames('trends__item', className)}>
     <div className='trends__item__name'>
       <Permalink href={href} to={to}>
@@ -74,9 +79,10 @@ const Hashtag = ({ name, href, to, people, uses, history, className }) => (
       {typeof people !== 'undefined' ? <ShortNumber value={people} renderer={accountsCountRenderer} /> : <Skeleton width={100} />}
     </div>
 
-    <div className='trends__item__current'>
+    <abbr className='trends__item__current' title={intl.formatMessage(messages.totalVolume, { days: 2 })}>
       {typeof uses !== 'undefined' ? <ShortNumber value={uses} /> : <Skeleton width={42} height={36} />}
-    </div>
+      <span className='trends__item__current__asterisk'>*</span>
+    </abbr>
 
     <div className='trends__item__sparkline'>
       <SilentErrorBoundary>
@@ -86,7 +92,7 @@ const Hashtag = ({ name, href, to, people, uses, history, className }) => (
       </SilentErrorBoundary>
     </div>
   </div>
-);
+));
 
 Hashtag.propTypes = {
   name: PropTypes.string,
diff --git a/app/javascript/flavours/glitch/components/icon_button.js b/app/javascript/flavours/glitch/components/icon_button.js
index 9a05badd0..be2468d68 100644
--- a/app/javascript/flavours/glitch/components/icon_button.js
+++ b/app/javascript/flavours/glitch/components/icon_button.js
@@ -140,8 +140,16 @@ export default class IconButton extends React.PureComponent {
     );
 
     if (href) {
-      contents = (
-        <a href={href} target='_blank' rel='noopener noreferrer'>
+      return (
+        <a
+          href={href}
+          aria-label={title}
+          title={title}
+          target='_blank'
+          rel='noopener noreferrer'
+          className={classes}
+          style={style}
+        >
           {contents}
         </a>
       );
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 8a5fda676..11c81765b 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -81,8 +81,8 @@ class Status extends ImmutablePureComponent {
     onBlock: PropTypes.func,
     onEmbed: PropTypes.func,
     onHeightChange: PropTypes.func,
+    onToggleHidden: PropTypes.func,
     muted: PropTypes.bool,
-    collapse: PropTypes.bool,
     hidden: PropTypes.bool,
     unread: PropTypes.bool,
     prepend: PropTypes.string,
@@ -121,7 +121,6 @@ class Status extends ImmutablePureComponent {
     'settings',
     'prepend',
     'muted',
-    'collapse',
     'notification',
     'hidden',
     'expanded',
@@ -149,14 +148,14 @@ class Status extends ImmutablePureComponent {
     let updated = false;
 
     // Make sure the state mirrors props we track…
-    if (nextProps.collapse !== prevState.collapseProp) {
-      update.collapseProp = nextProps.collapse;
-      updated = true;
-    }
     if (nextProps.expanded !== prevState.expandedProp) {
       update.expandedProp = nextProps.expanded;
       updated = true;
     }
+    if (nextProps.status?.get('hidden') !== prevState.statusPropHidden) {
+      update.statusPropHidden = nextProps.status?.get('hidden');
+      updated = true;
+    }
 
     // Update state based on new props
     if (!nextProps.settings.getIn(['collapsed', 'enabled'])) {
@@ -164,14 +163,19 @@ class Status extends ImmutablePureComponent {
         update.isCollapsed = false;
         updated = true;
       }
-    } else if (
-      nextProps.collapse !== prevState.collapseProp &&
-      nextProps.collapse !== undefined
+    }
+
+    // Handle uncollapsing toots when the shared CW state is expanded
+    if (nextProps.settings.getIn(['content_warnings', 'shared_state']) &&
+      nextProps.status?.get('spoiler_text')?.length && nextProps.status?.get('hidden') === false &&
+      prevState.statusPropHidden !== false && prevState.isCollapsed
     ) {
-      update.isCollapsed = nextProps.collapse;
-      if (nextProps.collapse) update.isExpanded = false;
+      update.isCollapsed = false;
       updated = true;
     }
+
+    // The “expanded” prop is used to one-off change the local state.
+    // It's used in the thread view when unfolding/re-folding all CWs at once.
     if (nextProps.expanded !== prevState.expandedProp &&
       nextProps.expanded !== undefined
     ) {
@@ -180,15 +184,9 @@ class Status extends ImmutablePureComponent {
       updated = true;
     }
 
-    if (nextProps.expanded === undefined &&
-      prevState.isExpanded === undefined &&
-      update.isExpanded === undefined
-    ) {
-      const isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status);
-      if (isExpanded !== undefined) {
-        update.isExpanded = isExpanded;
-        updated = true;
-      }
+    if (prevState.isExpanded === undefined && update.isExpanded === undefined) {
+      update.isExpanded = autoUnfoldCW(nextProps.settings, nextProps.status);
+      updated = true;
     }
 
     if (nextProps.status && nextProps.status.get('id') !== prevState.statusId) {
@@ -243,22 +241,18 @@ class Status extends ImmutablePureComponent {
 
     const autoCollapseSettings = settings.getIn(['collapsed', 'auto']);
 
-    if (function () {
-      switch (true) {
-      case !!collapse:
-      case !!autoCollapseSettings.get('all'):
-      case autoCollapseSettings.get('notifications') && !!muted:
-      case autoCollapseSettings.get('lengthy') && node.clientHeight > (
-        status.get('media_attachments').size && !muted ? 650 : 400
-      ):
-      case autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by':
-      case autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null:
-      case autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && !!status.get('media_attachments').size:
-        return true;
-      default:
-        return false;
-      }
-    }()) {
+    // Don't autocollapse if CW state is shared and status is explicitly revealed,
+    // as it could cause surprising changes when receiving notifications
+    if (settings.getIn(['content_warnings', 'shared_state']) && status.get('spoiler_text').length && !status.get('hidden')) return;
+
+    if (collapse ||
+      autoCollapseSettings.get('all') ||
+      (autoCollapseSettings.get('notifications') && muted) ||
+      (autoCollapseSettings.get('lengthy') && node.clientHeight > ((status.get('media_attachments').size && !muted) ? 650 : 400)) ||
+      (autoCollapseSettings.get('reblogs') && prepend === 'reblogged_by') ||
+      (autoCollapseSettings.get('replies') && status.get('in_reply_to_id', null) !== null) ||
+      (autoCollapseSettings.get('media') && !(status.get('spoiler_text').length) && status.get('media_attachments').size > 0)
+    ) {
       this.setCollapsed(true);
       // Hack to fix timeline jumps on second rendering when auto-collapsing
       this.setState({ autoCollapsed: true });
@@ -309,16 +303,20 @@ class Status extends ImmutablePureComponent {
   //  is enabled, so we don't have to.
   setCollapsed = (value) => {
     if (this.props.settings.getIn(['collapsed', 'enabled'])) {
-      this.setState({ isCollapsed: value });
       if (value) {
         this.setExpansion(false);
       }
+      this.setState({ isCollapsed: value });
     } else {
       this.setState({ isCollapsed: false });
     }
   }
 
   setExpansion = (value) => {
+    if (this.props.settings.getIn(['content_warnings', 'shared_state']) && this.props.status.get('hidden') === value) {
+      this.props.onToggleHidden(this.props.status);
+    }
+
     this.setState({ isExpanded: value });
     if (value) {
       this.setCollapsed(false);
@@ -365,7 +363,9 @@ class Status extends ImmutablePureComponent {
   }
 
   handleExpandedToggle = () => {
-    if (this.props.status.get('spoiler_text')) {
+    if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
+      this.props.onToggleHidden(this.props.status);
+    } else if (this.props.status.get('spoiler_text')) {
       this.setExpansion(!this.state.isExpanded);
     }
   };
@@ -505,16 +505,31 @@ class Status extends ImmutablePureComponent {
       usingPiP,
       ...other
     } = this.props;
-    const { isExpanded, isCollapsed, forceFilter } = this.state;
+    const { isCollapsed, forceFilter } = this.state;
     let background = null;
     let attachments = null;
-    let media = [];
-    let mediaIcons = [];
+
+    //  Depending on user settings, some media are considered as parts of the
+    //  contents (affected by CW) while other will be displayed outside of the
+    //  CW.
+    let contentMedia = [];
+    let contentMediaIcons = [];
+    let extraMedia = [];
+    let extraMediaIcons = [];
+    let media = contentMedia;
+    let mediaIcons = contentMediaIcons;
+
+    if (settings.getIn(['content_warnings', 'media_outside'])) {
+      media = extraMedia;
+      mediaIcons = extraMediaIcons;
+    }
 
     if (status === null) {
       return null;
     }
 
+    const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
+
     const handlers = {
       reply: this.handleHotkeyReply,
       favourite: this.handleHotkeyFavourite,
@@ -681,8 +696,8 @@ class Status extends ImmutablePureComponent {
     }
 
     if (status.get('poll')) {
-      media.push(<PollContainer pollId={status.get('poll')} />);
-      mediaIcons.push('tasks');
+      contentMedia.push(<PollContainer pollId={status.get('poll')} />);
+      contentMediaIcons.push('tasks');
     }
 
     //  Here we prepare extra data-* attributes for CSS selectors.
@@ -748,7 +763,7 @@ class Status extends ImmutablePureComponent {
             </span>
             <StatusIcons
               status={status}
-              mediaIcons={mediaIcons}
+              mediaIcons={contentMediaIcons.concat(extraMediaIcons)}
               collapsible={settings.getIn(['collapsed', 'enabled'])}
               collapsed={isCollapsed}
               setCollapsed={setCollapsed}
@@ -757,8 +772,9 @@ class Status extends ImmutablePureComponent {
           </header>
           <StatusContent
             status={status}
-            media={media}
-            mediaIcons={mediaIcons}
+            media={contentMedia}
+            extraMedia={extraMedia}
+            mediaIcons={contentMediaIcons}
             expanded={isExpanded}
             onExpandedToggle={this.handleExpandedToggle}
             parseClick={parseClick}
@@ -766,6 +782,7 @@ class Status extends ImmutablePureComponent {
             tagLinks={settings.get('tag_misleading_links')}
             rewriteMentions={settings.get('rewrite_mentions')}
           />
+
           {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
             <StatusActionBar
               {...other}
diff --git a/app/javascript/flavours/glitch/components/status_action_bar.js b/app/javascript/flavours/glitch/components/status_action_bar.js
index 0a5c5b69d..667afac5a 100644
--- a/app/javascript/flavours/glitch/components/status_action_bar.js
+++ b/app/javascript/flavours/glitch/components/status_action_bar.js
@@ -5,10 +5,11 @@ import IconButton from './icon_button';
 import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { me, isStaff } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/util/initial_state';
 import RelativeTimestamp from './relative_timestamp';
 import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links';
 import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -47,6 +48,7 @@ class StatusActionBar extends ImmutablePureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
+    identity: PropTypes.object,
   };
 
   static propTypes = {
@@ -240,7 +242,7 @@ class StatusActionBar extends ImmutablePureComponent {
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
       menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
 
-      if (isStaff && (accountAdminLink || statusAdminLink)) {
+      if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) {
         menu.push(null);
         if (accountAdminLink !== undefined) {
           menu.push({
diff --git a/app/javascript/flavours/glitch/components/status_content.js b/app/javascript/flavours/glitch/components/status_content.js
index 6a027f8d2..39891da4f 100644
--- a/app/javascript/flavours/glitch/components/status_content.js
+++ b/app/javascript/flavours/glitch/components/status_content.js
@@ -70,6 +70,7 @@ export default class StatusContent extends React.PureComponent {
     collapsed: PropTypes.bool,
     onExpandedToggle: PropTypes.func,
     media: PropTypes.node,
+    extraMedia: PropTypes.node,
     mediaIcons: PropTypes.arrayOf(PropTypes.string),
     parseClick: PropTypes.func,
     disabled: PropTypes.bool,
@@ -256,6 +257,7 @@ export default class StatusContent extends React.PureComponent {
     const {
       status,
       media,
+      extraMedia,
       mediaIcons,
       parseClick,
       disabled,
@@ -351,6 +353,8 @@ export default class StatusContent extends React.PureComponent {
             {media}
           </div>
 
+          {extraMedia}
+
         </div>
       );
     } else if (parseClick) {
@@ -372,6 +376,7 @@ export default class StatusContent extends React.PureComponent {
             lang={lang}
           />
           {media}
+          {extraMedia}
         </div>
       );
     } else {
@@ -391,6 +396,7 @@ export default class StatusContent extends React.PureComponent {
             lang={lang}
           />
           {media}
+          {extraMedia}
         </div>
       );
     }
diff --git a/app/javascript/flavours/glitch/containers/mastodon.js b/app/javascript/flavours/glitch/containers/mastodon.js
index 989e37024..d07b2b3d0 100644
--- a/app/javascript/flavours/glitch/containers/mastodon.js
+++ b/app/javascript/flavours/glitch/containers/mastodon.js
@@ -31,6 +31,7 @@ const createIdentityContext = state => ({
   signedIn: !!state.meta.me,
   accountId: state.meta.me,
   accessToken: state.meta.access_token,
+  permissions: state.role.permissions,
 });
 
 export default class Mastodon extends React.PureComponent {
diff --git a/app/javascript/flavours/glitch/containers/status_container.js b/app/javascript/flavours/glitch/containers/status_container.js
index 358b89ab9..6c8f261e4 100644
--- a/app/javascript/flavours/glitch/containers/status_container.js
+++ b/app/javascript/flavours/glitch/containers/status_container.js
@@ -17,7 +17,14 @@ import {
   pin,
   unpin,
 } from 'flavours/glitch/actions/interactions';
-import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses';
+import {
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  hideStatus,
+  revealStatus,
+  editStatus
+} from 'flavours/glitch/actions/statuses';
 import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
@@ -252,6 +259,14 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
     }
   },
 
+  onToggleHidden (status) {
+    if (status.get('hidden')) {
+      dispatch(revealStatus(status.get('id')));
+    } else {
+      dispatch(hideStatus(status.get('id')));
+    }
+  },
+
   deployPictureInPicture (status, type, mediaProps) {
     dispatch((_, getState) => {
       if (getState().getIn(['local_settings', 'media', 'pop_in_player'])) {
diff --git a/app/javascript/flavours/glitch/features/account/components/header.js b/app/javascript/flavours/glitch/features/account/components/header.js
index 45aba53f7..53170b7a6 100644
--- a/app/javascript/flavours/glitch/features/account/components/header.js
+++ b/app/javascript/flavours/glitch/features/account/components/header.js
@@ -3,7 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 import PropTypes from 'prop-types';
 import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 import ImmutablePureComponent from 'react-immutable-pure-component';
-import { autoPlayGif, me, isStaff } from 'flavours/glitch/util/initial_state';
+import { autoPlayGif, me } from 'flavours/glitch/util/initial_state';
 import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links';
 import classNames from 'classnames';
 import Icon from 'flavours/glitch/components/icon';
@@ -13,6 +13,7 @@ import Button from 'flavours/glitch/components/button';
 import { NavLink } from 'react-router-dom';
 import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
 import AccountNoteContainer from '../containers/account_note_container';
+import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
 
 const messages = defineMessages({
   unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
@@ -64,6 +65,10 @@ const dateFormatOptions = {
 export default @injectIntl
 class Header extends ImmutablePureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     account: ImmutablePropTypes.map,
     identity_props: ImmutablePropTypes.list,
@@ -244,7 +249,7 @@ class Header extends ImmutablePureComponent {
       }
     }
 
-    if (account.get('id') !== me && isStaff && accountAdminLink) {
+    if (account.get('id') !== me && (this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && accountAdminLink) {
       menu.push(null);
       menu.push({ text: intl.formatMessage(messages.admin_account, { name: account.get('username') }), href: accountAdminLink(account.get('id')) });
     }
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
index 202d96676..7107c9db3 100644
--- a/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
+++ b/app/javascript/flavours/glitch/features/direct_timeline/components/conversation.js
@@ -132,6 +132,8 @@ class Conversation extends ImmutablePureComponent {
   }
 
   handleShowMore = () => {
+    this.props.onToggleHidden(this.props.lastStatus);
+
     if (this.props.lastStatus.get('spoiler_text')) {
       this.setExpansion(!this.state.isExpanded);
     }
@@ -143,12 +145,13 @@ class Conversation extends ImmutablePureComponent {
 
   render () {
     const { accounts, lastStatus, unread, scrollKey, intl } = this.props;
-    const { isExpanded } = this.state;
 
     if (lastStatus === null) {
       return null;
     }
 
+    const isExpanded = this.props.settings.getIn(['content_warnings', 'shared_state']) ? !lastStatus.get('hidden') : this.state.isExpanded;
+
     const menu = [
       { text: intl.formatMessage(messages.open), action: this.handleClick },
       null,
diff --git a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
index b15ce9f0f..f5e5946e3 100644
--- a/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
+++ b/app/javascript/flavours/glitch/features/direct_timeline/containers/conversation_container.js
@@ -23,6 +23,7 @@ const mapStateToProps = () => {
       accounts: conversation.get('accounts').map(accountId => state.getIn(['accounts', accountId], null)),
       unread: conversation.get('unread'),
       lastStatus: lastStatusId && getStatus(state, { id: lastStatusId }),
+      settings: state.get('local_settings'),
     };
   };
 };
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index 2490b6e2d..ffa4e3409 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -303,38 +303,59 @@ class LocalSettingsPage extends React.PureComponent {
     ({ intl, onChange, settings }) => (
       <div className='glitch local-settings__page content_warnings'>
         <h1><FormattedMessage id='settings.content_warnings' defaultMessage='Content warnings' /></h1>
-        <DeprecatedLocalSettingsPageItem
-          id='mastodon-settings--content_warnings-auto_unfold'
-          value={expandSpoilers}
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['content_warnings', 'shared_state']}
+          id='mastodon-settings--content_warnings-shared_state'
+          onChange={onChange}
         >
-          <FormattedMessage id='settings.enable_content_warnings_auto_unfold' defaultMessage='Automatically unfold content-warnings' />
-          <span className='hint'>
-            <FormattedMessage
-              id='settings.deprecated_setting'
-              defaultMessage="This setting is now controlled from Mastodon's {settings_page_link}"
-              values={{
-                settings_page_link: (
-                  <a href={preferenceLink('user_setting_expand_spoilers')}>
-                    <FormattedMessage
-                      id='settings.shared_settings_link'
-                      defaultMessage='user preferences'
-                    />
-                  </a>
-                )
-              }}
-            />
-          </span>
-        </DeprecatedLocalSettingsPageItem>
+          <FormattedMessage id='settings.content_warnings_shared_state' defaultMessage='Show/hide content of all copies at once' />
+          <span className='hint'><FormattedMessage id='settings.content_warnings_shared_state_hint' defaultMessage='Reproduce upstream Mastodon behavior by having the Content Warning button affect all copies of a post at once. This will prevent automatic collapsing of any copy of a toot with unfolded CW' /></span>
+        </LocalSettingsPageItem>
         <LocalSettingsPageItem
           settings={settings}
-          item={['content_warnings', 'filter']}
-          id='mastodon-settings--content_warnings-auto_unfold'
+          item={['content_warnings', 'media_outside']}
+          id='mastodon-settings--content_warnings-media_outside'
           onChange={onChange}
-          placeholder={intl.formatMessage(messages.regexp)}
-          disabled={!expandSpoilers}
         >
-          <FormattedMessage id='settings.content_warnings_filter' defaultMessage='Content warnings to not automatically unfold:' />
+          <FormattedMessage id='settings.content_warnings_media_outside' defaultMessage='Display media attachments outside content warnings' />
+          <span className='hint'><FormattedMessage id='settings.content_warnings_media_outside_hint' defaultMessage='Reproduce upstream Mastodon behavior by having the Content Warning toggle not affect media attachments' /></span>
         </LocalSettingsPageItem>
+        <section>
+          <h2><FormattedMessage id='settings.content_warnings_unfold_opts' defaultMessage='Auto-unfolding options' /></h2>
+          <DeprecatedLocalSettingsPageItem
+            id='mastodon-settings--content_warnings-auto_unfold'
+            value={expandSpoilers}
+          >
+            <FormattedMessage id='settings.enable_content_warnings_auto_unfold' defaultMessage='Automatically unfold content-warnings' />
+            <span className='hint'>
+              <FormattedMessage
+                id='settings.deprecated_setting'
+                defaultMessage="This setting is now controlled from Mastodon's {settings_page_link}"
+                values={{
+                  settings_page_link: (
+                    <a href={preferenceLink('user_setting_expand_spoilers')}>
+                      <FormattedMessage
+                        id='settings.shared_settings_link'
+                        defaultMessage='user preferences'
+                      />
+                    </a>
+                  )
+                }}
+              />
+            </span>
+          </DeprecatedLocalSettingsPageItem>
+          <LocalSettingsPageItem
+            settings={settings}
+            item={['content_warnings', 'filter']}
+            id='mastodon-settings--content_warnings-auto_unfold'
+            onChange={onChange}
+            placeholder={intl.formatMessage(messages.regexp)}
+            disabled={!expandSpoilers}
+          >
+            <FormattedMessage id='settings.content_warnings_filter' defaultMessage='Content warnings to not automatically unfold:' />
+          </LocalSettingsPageItem>
+        </section>
       </div>
     ),
     ({ intl, onChange, settings }) => (
@@ -366,6 +387,7 @@ class LocalSettingsPage extends React.PureComponent {
           onChange={onChange}
         >
           <FormattedMessage id='settings.enable_collapsed' defaultMessage='Enable collapsed toots' />
+          <span className='hint'><FormattedMessage id='settings.enable_collapsed_hint' defaultMessage='Collapsed posts have parts of their contents hidden to take up less screen space. This is distinct from the Content Warning feature' /></span>
         </LocalSettingsPageItem>
         <LocalSettingsPageItem
           settings={settings}
@@ -457,6 +479,7 @@ class LocalSettingsPage extends React.PureComponent {
             dependsOn={[['collapsed', 'enabled']]}
           >
             <FormattedMessage id='settings.image_backgrounds_media' defaultMessage='Preview collapsed toot media' />
+            <span className='hint'><FormattedMessage id='settings.image_backgrounds_media_hint' defaultMessage='If the post has any media attachment, use the first one as a background' /></span>
           </LocalSettingsPageItem>
         </section>
       </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/components/admin_report.js b/app/javascript/flavours/glitch/features/notifications/components/admin_report.js
new file mode 100644
index 000000000..80beeb9da
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/admin_report.js
@@ -0,0 +1,108 @@
+//  Package imports.
+import React from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import { HotKeys } from 'react-hotkeys';
+import classNames from 'classnames';
+
+// Our imports.
+import Permalink from 'flavours/glitch/components/permalink';
+import AccountContainer from 'flavours/glitch/containers/account_container';
+import NotificationOverlayContainer from '../containers/overlay_container';
+import Icon from 'flavours/glitch/components/icon';
+import Report from './report';
+
+const messages = defineMessages({
+  adminReport: { id: 'notification.admin.report', defaultMessage: '{name} reported {target}' },
+});
+
+export default class AdminReport extends ImmutablePureComponent {
+
+  static propTypes = {
+    hidden: PropTypes.bool,
+    id: PropTypes.string.isRequired,
+    account: ImmutablePropTypes.map.isRequired,
+    notification: ImmutablePropTypes.map.isRequired,
+    unread: PropTypes.bool,
+    report: ImmutablePropTypes.map.isRequired,
+  };
+
+  handleMoveUp = () => {
+    const { notification, onMoveUp } = this.props;
+    onMoveUp(notification.get('id'));
+  }
+
+  handleMoveDown = () => {
+    const { notification, onMoveDown } = this.props;
+    onMoveDown(notification.get('id'));
+  }
+
+  handleOpen = () => {
+    this.handleOpenProfile();
+  }
+
+  handleOpenProfile = () => {
+    const { notification } = this.props;
+    this.context.router.history.push(`/@${notification.getIn(['account', 'acct'])}`);
+  }
+
+  handleMention = e => {
+    e.preventDefault();
+
+    const { notification, onMention } = this.props;
+    onMention(notification.get('account'), this.context.router.history);
+  }
+
+  getHandlers () {
+    return {
+      moveUp: this.handleMoveUp,
+      moveDown: this.handleMoveDown,
+      open: this.handleOpen,
+      openProfile: this.handleOpenProfile,
+      mention: this.handleMention,
+      reply: this.handleMention,
+    };
+  }
+
+  render () {
+    const { intl, account, notification, unread, report } = this.props;
+
+    //  Links to the display name.
+    const displayName = account.get('display_name_html') || account.get('username');
+    const link = (
+      <bdi><Permalink
+        className='notification__display-name'
+        href={account.get('url')}
+        title={account.get('acct')}
+        to={`/@${account.get('acct')}`}
+        dangerouslySetInnerHTML={{ __html: displayName }}
+      /></bdi>
+    );
+
+    const targetAccount = report.get('target_account');
+    const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
+    const targetLink = <bdi><Permalink className='notification__display-name' href={targetAccount.get('url')} title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
+
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className={classNames('notification notification-admin-report focusable', { unread })} tabIndex='0'>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon id='flag' fixedWidth />
+            </div>
+
+            <span title={notification.get('created_at')}>
+              <FormattedMessage id='notification.admin.report' defaultMessage='{name} reported {target}' values={{ name: link, target: targetLink }} />
+            </span>
+          </div>
+
+          <Report account={account} report={notification.get('report')} hidden={this.props.hidden} />
+          <NotificationOverlayContainer notification={notification} />
+        </div>
+      </HotKeys>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index 0be2a7e13..42ab9de35 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -6,10 +6,14 @@ import ClearColumnButton from './clear_column_button';
 import GrantPermissionButton from './grant_permission_button';
 import SettingToggle from './setting_toggle';
 import PillBarButton from './pill_bar_button';
-import { isStaff } from 'flavours/glitch/util/initial_state';
+import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_REPORTS } from 'flavours/glitch/permissions';
 
 export default class ColumnSettings extends React.PureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     settings: ImmutablePropTypes.map.isRequired,
     pushSettings: ImmutablePropTypes.map.isRequired,
@@ -167,7 +171,7 @@ export default class ColumnSettings extends React.PureComponent {
           </div>
         </div>
 
-        {isStaff && (
+        {(this.context.identity.permissions & PERMISSION_MANAGE_USERS === PERMISSION_MANAGE_USERS) && (
           <div role='group' aria-labelledby='notifications-admin-sign-up'>
             <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.sign_up' defaultMessage='New sign-ups:' /></span>
 
@@ -179,6 +183,19 @@ export default class ColumnSettings extends React.PureComponent {
             </div>
           </div>
         )}
+
+        {(this.context.identity.permissions & PERMISSION_MANAGE_REPORTS === PERMISSION_MANAGE_REPORTS) && (
+          <div role='group' aria-labelledby='notifications-admin-report'>
+            <span id='notifications-status' className='column-settings__section'><FormattedMessage id='notifications.column_settings.admin.report' defaultMessage='New reports:' /></span>
+
+            <div className='column-settings__pillbar'>
+              <PillBarButton disabled={browserPermission === 'denied'} prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'admin.report']} onChange={onChange} label={alertStr} />
+              {showPushSettings && <PillBarButton prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'admin.report']} onChange={this.onPushChange} label={pushStr} />}
+              <PillBarButton prefix='notifications' settings={settings} settingPath={['shows', 'admin.report']} onChange={onChange} label={showStr} />
+              <PillBarButton prefix='notifications' settings={settings} settingPath={['sounds', 'admin.report']} onChange={onChange} label={soundStr} />
+            </div>
+          </div>
+        )}
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
index e0cd3c7a6..d676a4207 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -9,6 +9,7 @@ import StatusContainer from 'flavours/glitch/containers/status_container';
 import NotificationFollow from './follow';
 import NotificationFollowRequestContainer from '../containers/follow_request_container';
 import NotificationAdminSignup from './admin_signup';
+import NotificationAdminReportContainer from '../containers/admin_report_container';
 
 export default class Notification extends ImmutablePureComponent {
 
@@ -77,6 +78,19 @@ export default class Notification extends ImmutablePureComponent {
           unread={this.props.unread}
         />
       );
+    case 'admin.report':
+      return (
+        <NotificationAdminReportContainer
+          hidden={hidden}
+          id={notification.get('id')}
+          account={notification.get('account')}
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          unread={this.props.unread}
+        />
+      );
     case 'mention':
       return (
         <StatusContainer
diff --git a/app/javascript/flavours/glitch/features/notifications/components/report.js b/app/javascript/flavours/glitch/features/notifications/components/report.js
new file mode 100644
index 000000000..46a307250
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/components/report.js
@@ -0,0 +1,62 @@
+import React, { Fragment } from 'react';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import PropTypes from 'prop-types';
+import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
+import ImmutablePureComponent from 'react-immutable-pure-component';
+import AvatarOverlay from 'flavours/glitch/components/avatar_overlay';
+import RelativeTimestamp from 'flavours/glitch/components/relative_timestamp';
+
+const messages = defineMessages({
+  openReport: { id: 'report_notification.open', defaultMessage: 'Open report' },
+  other: { id: 'report_notification.categories.other', defaultMessage: 'Other' },
+  spam: { id: 'report_notification.categories.spam', defaultMessage: 'Spam' },
+  violation: { id: 'report_notification.categories.violation', defaultMessage: 'Rule violation' },
+});
+
+export default @injectIntl
+class Report extends ImmutablePureComponent {
+
+  static propTypes = {
+    account: ImmutablePropTypes.map.isRequired,
+    report: ImmutablePropTypes.map.isRequired,
+    hidden: PropTypes.bool,
+    intl: PropTypes.object.isRequired,
+  };
+
+  render () {
+    const { intl, hidden, report, account } = this.props;
+
+    if (!report) {
+      return null;
+    }
+
+    if (hidden) {
+      return (
+        <Fragment>
+          {report.get('id')}
+        </Fragment>
+      );
+    }
+
+    return (
+      <div className='notification__report'>
+        <div className='notification__report__avatar'>
+          <AvatarOverlay account={report.get('target_account')} friend={account} />
+        </div>
+
+        <div className='notification__report__details'>
+          <div>
+            <RelativeTimestamp timestamp={report.get('created_at')} short={false} /> · <FormattedMessage id='report_notification.attached_statuses' defaultMessage='{count, plural, one {{count} post} other {{count} posts}} attached' values={{ count: report.get('status_ids').size }} />
+            <br />
+            <strong>{intl.formatMessage(messages[report.get('category')])}</strong>
+          </div>
+
+          <div className='notification__report__actions'>
+            <a href={`/admin/reports/${report.get('id')}`} className='button' target='_blank' rel='noopener noreferrer'>{intl.formatMessage(messages.openReport)}</a>
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+}
diff --git a/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js b/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js
new file mode 100644
index 000000000..4198afce8
--- /dev/null
+++ b/app/javascript/flavours/glitch/features/notifications/containers/admin_report_container.js
@@ -0,0 +1,13 @@
+import { connect } from 'react-redux';
+import { makeGetReport } from 'flavours/glitch/selectors';
+import AdminReport from '../components/admin_report';
+
+const mapStateToProps = (state, { notification }) => {
+  const getReport = makeGetReport();
+
+  return {
+    report: notification.get('report') ? getReport(state, notification.get('report'), notification.getIn(['report', 'target_account', 'id'])) : null,
+  };
+};
+
+export default connect(mapStateToProps)(AdminReport);
diff --git a/app/javascript/flavours/glitch/features/status/components/action_bar.js b/app/javascript/flavours/glitch/features/status/components/action_bar.js
index a67a045da..ef0f0f2b7 100644
--- a/app/javascript/flavours/glitch/features/status/components/action_bar.js
+++ b/app/javascript/flavours/glitch/features/status/components/action_bar.js
@@ -4,9 +4,10 @@ import IconButton from 'flavours/glitch/components/icon_button';
 import ImmutablePropTypes from 'react-immutable-proptypes';
 import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
 import { defineMessages, injectIntl } from 'react-intl';
-import { me, isStaff } from 'flavours/glitch/util/initial_state';
+import { me } from 'flavours/glitch/util/initial_state';
 import { accountAdminLink, statusAdminLink } from 'flavours/glitch/util/backend_links';
 import classNames from 'classnames';
+import { PERMISSION_MANAGE_USERS } from 'flavours/glitch/permissions';
 
 const messages = defineMessages({
   delete: { id: 'status.delete', defaultMessage: 'Delete' },
@@ -41,6 +42,7 @@ class ActionBar extends React.PureComponent {
 
   static contextTypes = {
     router: PropTypes.object,
+    identity: PropTypes.object,
   };
 
   static propTypes = {
@@ -182,7 +184,7 @@ class ActionBar extends React.PureComponent {
       menu.push({ text: intl.formatMessage(messages.mute, { name: status.getIn(['account', 'username']) }), action: this.handleMuteClick });
       menu.push({ text: intl.formatMessage(messages.block, { name: status.getIn(['account', 'username']) }), action: this.handleBlockClick });
       menu.push({ text: intl.formatMessage(messages.report, { name: status.getIn(['account', 'username']) }), action: this.handleReport });
-      if (isStaff && (accountAdminLink || statusAdminLink)) {
+      if ((this.context.identity.permissions & PERMISSION_MANAGE_USERS) === PERMISSION_MANAGE_USERS && (accountAdminLink || statusAdminLink)) {
         menu.push(null);
         if (accountAdminLink !== undefined) {
           menu.push({
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index f4e6c24c5..301a2add6 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -122,14 +122,27 @@ class DetailedStatus extends ImmutablePureComponent {
       return null;
     }
 
-    let media           = [];
-    let mediaIcons      = [];
     let applicationLink = '';
     let reblogLink = '';
     let reblogIcon = 'retweet';
     let favouriteLink = '';
     let edited = '';
 
+    //  Depending on user settings, some media are considered as parts of the
+    //  contents (affected by CW) while other will be displayed outside of the
+    //  CW.
+    let contentMedia = [];
+    let contentMediaIcons = [];
+    let extraMedia = [];
+    let extraMediaIcons = [];
+    let media = contentMedia;
+    let mediaIcons = contentMediaIcons;
+
+    if (settings.getIn(['content_warnings', 'media_outside'])) {
+      media = extraMedia;
+      mediaIcons = extraMediaIcons;
+    }
+
     if (this.props.measureHeight) {
       outerStyle.height = `${this.state.height}px`;
     }
@@ -199,8 +212,8 @@ class DetailedStatus extends ImmutablePureComponent {
     }
 
     if (status.get('poll')) {
-      media.push(<PollContainer pollId={status.get('poll')} />);
-      mediaIcons.push('tasks');
+      contentMedia.push(<PollContainer pollId={status.get('poll')} />);
+      contentMediaIcons.push('tasks');
     }
 
     if (status.get('application')) {
@@ -282,8 +295,9 @@ class DetailedStatus extends ImmutablePureComponent {
 
           <StatusContent
             status={status}
-            media={media}
-            mediaIcons={mediaIcons}
+            media={contentMedia}
+            extraMedia={extraMedia}
+            mediaIcons={contentMediaIcons}
             expanded={expanded}
             collapsed={false}
             onExpandedToggle={onToggleHidden}
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 653fabeae..9c86d54db 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -26,7 +26,14 @@ import {
   directCompose,
 } from 'flavours/glitch/actions/compose';
 import { changeLocalSetting } from 'flavours/glitch/actions/local_settings';
-import { muteStatus, unmuteStatus, deleteStatus, editStatus } from 'flavours/glitch/actions/statuses';
+import {
+  muteStatus,
+  unmuteStatus,
+  deleteStatus,
+  editStatus,
+  hideStatus,
+  revealStatus
+} from 'flavours/glitch/actions/statuses';
 import { initMuteModal } from 'flavours/glitch/actions/mutes';
 import { initBlockModal } from 'flavours/glitch/actions/blocks';
 import { initReport } from 'flavours/glitch/actions/reports';
@@ -215,11 +222,19 @@ class Status extends ImmutablePureComponent {
     return updated ? update : null;
   }
 
-  handleExpandedToggle = () => {
-    if (this.props.status.get('spoiler_text')) {
+  handleToggleHidden = () => {
+    const { status } = this.props;
+
+    if (this.props.settings.getIn(['content_warnings', 'shared_state'])) {
+      if (status.get('hidden')) {
+        this.props.dispatch(revealStatus(status.get('id')));
+      } else {
+        this.props.dispatch(hideStatus(status.get('id')));
+      }
+    } else if (this.props.status.get('spoiler_text')) {
       this.setExpansion(!this.state.isExpanded);
     }
-  };
+  }
 
   handleToggleMediaVisibility = () => {
     this.setState({ showMedia: !this.state.showMedia });
@@ -354,7 +369,19 @@ class Status extends ImmutablePureComponent {
   }
 
   handleToggleAll = () => {
-    const { isExpanded } = this.state;
+    const { status, ancestorsIds, descendantsIds, settings } = this.props;
+    const statusIds = [status.get('id')].concat(ancestorsIds.toJS(), descendantsIds.toJS());
+    let { isExpanded } = this.state;
+
+    if (settings.getIn(['content_warnings', 'shared_state']))
+      isExpanded = !status.get('hidden');
+
+    if (!isExpanded) {
+      this.props.dispatch(revealStatus(statusIds));
+    } else {
+      this.props.dispatch(hideStatus(statusIds));
+    }
+
     this.setState({ isExpanded: !isExpanded, threadExpanded: !isExpanded });
   }
 
@@ -513,9 +540,8 @@ class Status extends ImmutablePureComponent {
 
   render () {
     let ancestors, descendants;
-    const { setExpansion } = this;
     const { status, settings, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
-    const { fullscreen, isExpanded } = this.state;
+    const { fullscreen } = this.state;
 
     if (status === null) {
       return (
@@ -526,6 +552,8 @@ class Status extends ImmutablePureComponent {
       );
     }
 
+    const isExpanded = settings.getIn(['content_warnings', 'shared_state']) ? !status.get('hidden') : this.state.isExpanded;
+
     if (ancestorsIds && ancestorsIds.size > 0) {
       ancestors = <div>{this.renderChildren(ancestorsIds)}</div>;
     }
@@ -543,7 +571,7 @@ class Status extends ImmutablePureComponent {
       bookmark: this.handleHotkeyBookmark,
       mention: this.handleHotkeyMention,
       openProfile: this.handleHotkeyOpenProfile,
-      toggleSpoiler: this.handleExpandedToggle,
+      toggleSpoiler: this.handleToggleHidden,
       toggleSensitive: this.handleHotkeyToggleSensitive,
       openMedia: this.handleHotkeyOpenMedia,
     };
@@ -574,7 +602,7 @@ class Status extends ImmutablePureComponent {
                   onOpenVideo={this.handleOpenVideo}
                   onOpenMedia={this.handleOpenMedia}
                   expanded={isExpanded}
-                  onToggleHidden={this.handleExpandedToggle}
+                  onToggleHidden={this.handleToggleHidden}
                   domain={domain}
                   showMedia={this.state.showMedia}
                   onToggleMediaVisibility={this.handleToggleMediaVisibility}
diff --git a/app/javascript/flavours/glitch/features/ui/components/image_loader.js b/app/javascript/flavours/glitch/features/ui/components/image_loader.js
index c6f16a792..dfa0efe49 100644
--- a/app/javascript/flavours/glitch/features/ui/components/image_loader.js
+++ b/app/javascript/flavours/glitch/features/ui/components/image_loader.js
@@ -1,10 +1,10 @@
-import React from 'react';
-import PropTypes from 'prop-types';
 import classNames from 'classnames';
+import PropTypes from 'prop-types';
+import React, { PureComponent } from 'react';
 import { LoadingBar } from 'react-redux-loading-bar';
 import ZoomableImage from './zoomable_image';
 
-export default class ImageLoader extends React.PureComponent {
+export default class ImageLoader extends PureComponent {
 
   static propTypes = {
     alt: PropTypes.string,
@@ -43,7 +43,7 @@ export default class ImageLoader extends React.PureComponent {
     this.loadImage(this.props);
   }
 
-  componentWillReceiveProps (nextProps) {
+  UNSAFE_componentWillReceiveProps (nextProps) {
     if (this.props.src !== nextProps.src) {
       this.loadImage(nextProps);
     }
@@ -139,14 +139,18 @@ export default class ImageLoader extends React.PureComponent {
 
     return (
       <div className={className}>
-        <LoadingBar loading={loading ? 1 : 0} className='loading-bar' style={{ width: this.state.width || width }} />
         {loading ? (
-          <canvas
-            className='image-loader__preview-canvas'
-            ref={this.setCanvasRef}
-            width={width}
-            height={height}
-          />
+          <>
+            <div className='loading-bar__container' style={{ width: this.state.width || width }}>
+              <LoadingBar className='loading-bar' loading={1} />
+            </div>
+            <canvas
+              className='image-loader__preview-canvas'
+              ref={this.setCanvasRef}
+              width={width}
+              height={height}
+            />
+          </>
         ) : (
           <ZoomableImage
             alt={alt}
diff --git a/app/javascript/flavours/glitch/features/ui/components/link_footer.js b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
index 5d566e516..040e967f2 100644
--- a/app/javascript/flavours/glitch/features/ui/components/link_footer.js
+++ b/app/javascript/flavours/glitch/features/ui/components/link_footer.js
@@ -3,10 +3,11 @@ import React from 'react';
 import PropTypes from 'prop-types';
 import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
 import { Link } from 'react-router-dom';
-import { invitesEnabled, limitedFederationMode, version, repository, source_url } from 'flavours/glitch/util/initial_state';
+import { limitedFederationMode, version, repository, source_url } from 'flavours/glitch/util/initial_state';
 import { signOutLink, securityLink } from 'flavours/glitch/util/backend_links';
 import { logOut } from 'flavours/glitch/util/log_out';
 import { openModal } from 'flavours/glitch/actions/modal';
+import { PERMISSION_INVITE_USERS } from 'flavours/glitch/permissions';
 
 const messages = defineMessages({
   logoutMessage: { id: 'confirmations.logout.message', defaultMessage: 'Are you sure you want to log out?' },
@@ -28,6 +29,10 @@ export default @injectIntl
 @connect(null, mapDispatchToProps)
 class LinkFooter extends React.PureComponent {
 
+  static contextTypes = {
+    identity: PropTypes.object,
+  };
+
   static propTypes = {
     onLogout: PropTypes.func.isRequired,
     intl: PropTypes.object.isRequired,
@@ -46,7 +51,7 @@ class LinkFooter extends React.PureComponent {
     return (
       <div className='getting-started__footer'>
         <ul>
-          {invitesEnabled && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
+          {((this.context.identity.permissions & PERMISSION_INVITE_USERS) === PERMISSION_INVITE_USERS) && <li><a href='/invites' target='_blank'><FormattedMessage id='getting_started.invite' defaultMessage='Invite people' /></a> · </li>}
           {!!securityLink && <li><a href='/auth/edit'><FormattedMessage id='getting_started.security' defaultMessage='Security' /></a> · </li>}
           {!limitedFederationMode && <li><a href='/about/more' target='_blank'><FormattedMessage id='navigation_bar.info' defaultMessage='About this server' /></a> · </li>}
           <li><a href='https://joinmastodon.org/apps' target='_blank'><FormattedMessage id='navigation_bar.apps' defaultMessage='Mobile apps' /></a> · </li>
diff --git a/app/javascript/flavours/glitch/packs/settings.js b/app/javascript/flavours/glitch/packs/settings.js
index 0a53e1c25..de88d4f52 100644
--- a/app/javascript/flavours/glitch/packs/settings.js
+++ b/app/javascript/flavours/glitch/packs/settings.js
@@ -2,6 +2,7 @@ import 'packs/public-path';
 import loadPolyfills from 'flavours/glitch/util/load_polyfills';
 import ready from 'flavours/glitch/util/ready';
 import loadKeyboardExtensions from 'flavours/glitch/util/load_keyboard_extensions';
+import 'cocoon-js-vanilla';
 
 function main() {
   const { delegate } = require('@rails/ujs');
diff --git a/app/javascript/flavours/glitch/permissions.js b/app/javascript/flavours/glitch/permissions.js
new file mode 100644
index 000000000..752ddd6c5
--- /dev/null
+++ b/app/javascript/flavours/glitch/permissions.js
@@ -0,0 +1,3 @@
+export const PERMISSION_INVITE_USERS   = 0x0000000000010000;
+export const PERMISSION_MANAGE_USERS   = 0x0000000000000400;
+export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index d4cdc124f..62ce29f0c 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -25,7 +25,9 @@ const initialState = ImmutableMap({
   tag_misleading_links: true,
   rewrite_mentions: 'no',
   content_warnings : ImmutableMap({
-    filter      : null,
+    filter       : null,
+    media_outside: false,
+    shared_state : false,
   }),
   collapsed : ImmutableMap({
     enabled     : true,
diff --git a/app/javascript/flavours/glitch/reducers/meta.js b/app/javascript/flavours/glitch/reducers/meta.js
index a98dc436a..0f3ab3b84 100644
--- a/app/javascript/flavours/glitch/reducers/meta.js
+++ b/app/javascript/flavours/glitch/reducers/meta.js
@@ -4,12 +4,13 @@ import { Map as ImmutableMap } from 'immutable';
 const initialState = ImmutableMap({
   streaming_api_base_url: null,
   access_token: null,
+  permissions: '0',
 });
 
 export default function meta(state = initialState, action) {
   switch(action.type) {
   case STORE_HYDRATE:
-    return state.merge(action.state.get('meta'));
+    return state.merge(action.state.get('meta')).set('permissions', action.state.getIn(['role', 'permissions']));
   default:
     return state;
   }
diff --git a/app/javascript/flavours/glitch/reducers/notifications.js b/app/javascript/flavours/glitch/reducers/notifications.js
index 859a8b3a1..f538af7fa 100644
--- a/app/javascript/flavours/glitch/reducers/notifications.js
+++ b/app/javascript/flavours/glitch/reducers/notifications.js
@@ -31,7 +31,7 @@ import {
 } from 'flavours/glitch/actions/markers';
 import { DOMAIN_BLOCK_SUCCESS } from 'flavours/glitch/actions/domain_blocks';
 import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from 'flavours/glitch/actions/timelines';
-import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import { fromJS, Map as ImmutableMap, List as ImmutableList } from 'immutable';
 import compareId from 'flavours/glitch/util/compare_id';
 
 const initialState = ImmutableMap({
@@ -58,6 +58,7 @@ const notificationToMap = (state, notification) => ImmutableMap({
   account: notification.account.id,
   markedForDelete: state.get('markNewForDelete'),
   status: notification.status ? notification.status.id : null,
+  report: notification.report ? fromJS(notification.report) : null,
 });
 
 const normalizeNotification = (state, notification, usePendingItems) => {
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index 0c28b2959..1d99441a1 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -43,6 +43,7 @@ const initialState = ImmutableMap({
       status: false,
       update: false,
       'admin.sign_up': false,
+      'admin.report': false,
     }),
 
     quickFilter: ImmutableMap({
@@ -64,6 +65,7 @@ const initialState = ImmutableMap({
       status: true,
       update: true,
       'admin.sign_up': true,
+      'admin.report': true,
     }),
 
     sounds: ImmutableMap({
@@ -76,6 +78,7 @@ const initialState = ImmutableMap({
       status: true,
       update: true,
       'admin.sign_up': true,
+      'admin.report': true,
     }),
   }),
 
diff --git a/app/javascript/flavours/glitch/reducers/statuses.js b/app/javascript/flavours/glitch/reducers/statuses.js
index 5db766b96..333e4b45c 100644
--- a/app/javascript/flavours/glitch/reducers/statuses.js
+++ b/app/javascript/flavours/glitch/reducers/statuses.js
@@ -10,6 +10,9 @@ import {
 import {
   STATUS_MUTE_SUCCESS,
   STATUS_UNMUTE_SUCCESS,
+  STATUS_REVEAL,
+  STATUS_HIDE,
+  STATUS_COLLAPSE,
 } from 'flavours/glitch/actions/statuses';
 import {
   TIMELINE_DELETE,
@@ -56,6 +59,24 @@ export default function statuses(state = initialState, action) {
     return state.setIn([action.id, 'muted'], true);
   case STATUS_UNMUTE_SUCCESS:
     return state.setIn([action.id, 'muted'], false);
+  case STATUS_REVEAL:
+    return state.withMutations(map => {
+      action.ids.forEach(id => {
+        if (!(state.get(id) === undefined)) {
+          map.setIn([id, 'hidden'], false);
+        }
+      });
+    });
+  case STATUS_HIDE:
+    return state.withMutations(map => {
+      action.ids.forEach(id => {
+        if (!(state.get(id) === undefined)) {
+          map.setIn([id, 'hidden'], true);
+        }
+      });
+    });
+  case STATUS_COLLAPSE:
+    return state.setIn([action.id, 'collapsed'], action.isCollapsed);
   case TIMELINE_DELETE:
     return deleteStatus(state, action.id, action.references);
   default:
diff --git a/app/javascript/flavours/glitch/selectors/index.js b/app/javascript/flavours/glitch/selectors/index.js
index 99afe5355..d9aa8f140 100644
--- a/app/javascript/flavours/glitch/selectors/index.js
+++ b/app/javascript/flavours/glitch/selectors/index.js
@@ -171,14 +171,15 @@ export const getAlerts = createSelector([getAlertsBase], (base) => {
   return arr;
 });
 
-export const makeGetNotification = () => {
-  return createSelector([
-    (_, base)             => base,
-    (state, _, accountId) => state.getIn(['accounts', accountId]),
-  ], (base, account) => {
-    return base.set('account', account);
-  });
-};
+export const makeGetNotification = () => createSelector([
+  (_, base)             => base,
+  (state, _, accountId) => state.getIn(['accounts', accountId]),
+], (base, account) => base.set('account', account));
+
+export const makeGetReport = () => createSelector([
+  (_, base) => base,
+  (state, _, targetAccountId) => state.getIn(['accounts', targetAccountId]),
+], (base, targetAccount) => base.set('target_account', targetAccount));
 
 export const getAccountGallery = createSelector([
   (state, id) => state.getIn(['timelines', `account:${id}:media`, 'items'], ImmutableList()),
diff --git a/app/javascript/flavours/glitch/styles/accounts.scss b/app/javascript/flavours/glitch/styles/accounts.scss
index 87e35236c..ac1be6ad8 100644
--- a/app/javascript/flavours/glitch/styles/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/accounts.scss
@@ -211,9 +211,9 @@
   font-size: 12px;
   line-height: 12px;
   font-weight: 500;
-  color: $ui-secondary-color;
-  background-color: rgba($ui-secondary-color, 0.1);
-  border: 1px solid rgba($ui-secondary-color, 0.5);
+  color: var(--user-role-accent, $ui-secondary-color);
+  background-color: var(--user-role-background, rgba($ui-secondary-color, 0.1));
+  border: 1px solid var(--user-role-border, rgba($ui-secondary-color, 0.5));
 
   &.moderator {
     color: $success-green;
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 9553aa4ae..77890c467 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -75,6 +75,13 @@ $content-width: 840px;
       height: 100px;
     }
 
+    .logo--wordmark {
+      display: inherit;
+      margin: inherit;
+      width: inherit;
+      height: 20px;
+    }
+
     @media screen and (max-width: $no-columns-breakpoint) {
       & > a:first-child {
         display: none;
@@ -927,7 +934,8 @@ a.name-tag,
   text-align: center;
 }
 
-.applications-list__item {
+.applications-list__item,
+.filters-list__item {
   padding: 15px 0;
   background: $ui-base-color;
   border: 1px solid lighten($ui-base-color, 4%);
@@ -935,7 +943,12 @@ a.name-tag,
   margin-top: 15px;
 }
 
-.announcements-list {
+.user-role {
+  color: var(--user-role-accent);
+}
+
+.announcements-list,
+.filters-list {
   border: 1px solid lighten($ui-base-color, 4%);
   border-radius: 4px;
 
@@ -970,6 +983,17 @@ a.name-tag,
     &__meta {
       padding: 0 15px;
       color: $dark-text-color;
+
+      a {
+        color: inherit;
+        text-decoration: underline;
+
+        &:hover,
+        &:focus,
+        &:active {
+          text-decoration: none;
+        }
+      }
     }
 
     &__action-bar {
@@ -988,6 +1012,33 @@ a.name-tag,
   }
 }
 
+.filters-list__item {
+  &__title {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 0;
+  }
+
+  &__permissions {
+    margin-top: 0;
+    margin-bottom: 10px;
+  }
+
+  .expiration {
+    font-size: 13px;
+  }
+
+  &.expired {
+    .expiration {
+      color: lighten($error-red, 12%);
+    }
+
+    .permissions-list__item__icon {
+      color: $dark-text-color;
+    }
+  }
+}
+
 .dashboard__counters.admin-account-counters {
   margin-top: 10px;
 }
diff --git a/app/javascript/flavours/glitch/styles/components/accounts.scss b/app/javascript/flavours/glitch/styles/components/accounts.scss
index 377cdd91f..4e912b18b 100644
--- a/app/javascript/flavours/glitch/styles/components/accounts.scss
+++ b/app/javascript/flavours/glitch/styles/components/accounts.scss
@@ -105,6 +105,8 @@
   position: relative;
   @include avatar-size(48px);
 
+  position: relative;
+
   &-base {
     @include avatar-radius();
     @include avatar-size(36px);
@@ -243,6 +245,33 @@
   margin-right: 10px;
 }
 
+.notification__report {
+  padding: 8px 10px;
+  padding-left: 68px;
+  position: relative;
+  border-bottom: 1px solid lighten($ui-base-color, 8%);
+  min-height: 54px;
+
+  &__details {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    color: $darker-text-color;
+    font-size: 15px;
+    line-height: 22px;
+
+    strong {
+      font-weight: 500;
+    }
+  }
+
+  &__avatar {
+    position: absolute;
+    left: 10px;
+    top: 10px;
+  }
+}
+
 .notification__message {
   margin-left: 42px;
   padding: 8px 0 0 26px;
diff --git a/app/javascript/flavours/glitch/styles/components/index.scss b/app/javascript/flavours/glitch/styles/components/index.scss
index 7f9ed2186..b54c3f696 100644
--- a/app/javascript/flavours/glitch/styles/components/index.scss
+++ b/app/javascript/flavours/glitch/styles/components/index.scss
@@ -442,10 +442,14 @@
     object-fit: contain;
   }
 
-  .loading-bar {
+  .loading-bar__container {
     position: relative;
   }
 
+  .loading-bar {
+    position: absolute;
+  }
+
   &.image-loader--amorphous .image-loader__preview-canvas {
     display: none;
   }
diff --git a/app/javascript/flavours/glitch/styles/components/search.scss b/app/javascript/flavours/glitch/styles/components/search.scss
index f7415368b..17a34db62 100644
--- a/app/javascript/flavours/glitch/styles/components/search.scss
+++ b/app/javascript/flavours/glitch/styles/components/search.scss
@@ -176,6 +176,13 @@
       padding-right: 15px;
       margin-left: 5px;
       color: $secondary-text-color;
+      text-decoration: none;
+
+      &__asterisk {
+        color: $darker-text-color;
+        font-size: 18px;
+        vertical-align: super;
+      }
     }
 
     &__sparkline {
diff --git a/app/javascript/flavours/glitch/styles/components/single_column.scss b/app/javascript/flavours/glitch/styles/components/single_column.scss
index d10fc1d3e..3843bcd68 100644
--- a/app/javascript/flavours/glitch/styles/components/single_column.scss
+++ b/app/javascript/flavours/glitch/styles/components/single_column.scss
@@ -154,6 +154,16 @@
       padding-top: 15px;
     }
 
+    .notification__report {
+      padding: 15px 15px 15px (48px + 15px * 2);
+      min-height: 48px + 2px;
+
+      &__avatar {
+        left: 15px;
+        top: 17px;
+      }
+    }
+
     .status {
       padding: 15px;
       min-height: 48px + 2px;
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index de8bd2d45..9ed656e13 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -241,6 +241,10 @@ code {
     }
   }
 
+  .input.with_block_label.user_role_permissions_as_keys ul {
+    columns: unset;
+  }
+
   .input.datetime .label_input select {
     display: inline-block;
     width: auto;
@@ -1059,3 +1063,34 @@ code {
     }
   }
 }
+
+.keywords-table {
+  thead {
+    th {
+      white-space: nowrap;
+    }
+
+    th:first-child {
+      width: 100%;
+    }
+  }
+
+  tfoot {
+    td {
+      border: 0;
+    }
+  }
+
+  .input.string {
+    margin-bottom: 0;
+  }
+
+  .label_input__wrapper {
+    margin-top: 10px;
+  }
+
+  .table-action-link {
+    margin-top: 10px;
+    white-space: nowrap;
+  }
+}
diff --git a/app/javascript/flavours/glitch/util/content_warning.js b/app/javascript/flavours/glitch/util/content_warning.js
index baeb97881..383a34226 100644
--- a/app/javascript/flavours/glitch/util/content_warning.js
+++ b/app/javascript/flavours/glitch/util/content_warning.js
@@ -1,26 +1,31 @@
 import { expandSpoilers } from 'flavours/glitch/util/initial_state';
 
-export function autoUnfoldCW (settings, status) {
-  if (!expandSpoilers) {
+function _autoUnfoldCW(spoiler_text, skip_unfold_regex) {
+  if (!expandSpoilers)
     return false;
-  }
-
-  const rawRegex = settings.getIn(['content_warnings', 'filter']);
 
-  if (!rawRegex) {
+  if (!skip_unfold_regex)
     return true;
-  }
 
-  let regex      = null;
+  let regex = null;
 
   try {
-    regex = rawRegex && new RegExp(rawRegex.trim(), 'i');
+    regex = new RegExp(skip_unfold_regex.trim(), 'i');
   } catch (e) {
-    // Bad regex, don't affect filters
+    // Bad regex, skip filters
+    return true;
   }
 
-  if (!(status && regex)) {
-    return undefined;
-  }
-  return !regex.test(status.get('spoiler_text'));
+  return !regex.test(spoiler_text);
+}
+
+export function autoHideCW(settings, spoiler_text) {
+  return !_autoUnfoldCW(spoiler_text, settings.getIn(['content_warnings', 'filter']));
+}
+
+export function autoUnfoldCW(settings, status) {
+  if (!status)
+    return false;
+
+  return _autoUnfoldCW(status.get('spoiler_text'), settings.getIn(['content_warnings', 'filter']));
 }
diff --git a/app/javascript/flavours/glitch/util/initial_state.js b/app/javascript/flavours/glitch/util/initial_state.js
index b6eab0c87..90dada4b3 100644
--- a/app/javascript/flavours/glitch/util/initial_state.js
+++ b/app/javascript/flavours/glitch/util/initial_state.js
@@ -23,14 +23,12 @@ export const me = getMeta('me');
 export const searchEnabled = getMeta('search_enabled');
 export const maxChars = (initialState && initialState.max_toot_chars) || 500;
 export const pollLimits = (initialState && initialState.poll_limits);
-export const invitesEnabled = getMeta('invites_enabled');
 export const limitedFederationMode = getMeta('limited_federation_mode');
 export const repository = getMeta('repository');
 export const source_url = getMeta('source_url');
 export const version = getMeta('version');
 export const mascot = getMeta('mascot');
 export const profile_directory = getMeta('profile_directory');
-export const isStaff = getMeta('is_staff');
 export const defaultContentType = getMeta('default_content_type');
 export const forceSingleColumn = getMeta('advanced_layout') === false;
 export const useBlurhash = getMeta('use_blurhash');