about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorThibaut Girka <thib@sitedethib.com>2020-11-07 18:19:34 +0100
committerThibaut Girka <thib@sitedethib.com>2020-11-07 18:23:02 +0100
commit412218af2ecd4cd004708d781e574208742dea4c (patch)
treec0bf823109c7481ec9242a0263e091ec4aaeafeb /app
parent5a9fc749c3eab8d3c93dd282fa89c20a5cb0e994 (diff)
parentee8cf246cfe8e05914ad7dcf81596f8535b3e161 (diff)
Merge branch 'master' into glitch-soc/merge-upstream
Diffstat (limited to 'app')
-rw-r--r--app/controllers/admin/accounts_controller.rb7
-rw-r--r--app/controllers/admin/announcements_controller.rb2
-rw-r--r--app/controllers/api/v1/admin/accounts_controller.rb8
-rw-r--r--app/helpers/statuses_helper.rb16
-rw-r--r--app/javascript/mastodon/components/column.js6
-rw-r--r--app/javascript/mastodon/components/dropdown_menu.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js4
-rw-r--r--app/javascript/mastodon/features/compose/components/privacy_dropdown.js4
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js6
-rw-r--r--app/javascript/mastodon/features/ui/components/zoomable_image.js69
-rw-r--r--app/javascript/mastodon/is_mobile.js4
-rw-r--r--app/lib/activitypub/activity/create.rb2
-rw-r--r--app/models/account.rb14
-rw-r--r--app/models/account_warning.rb2
-rw-r--r--app/models/admin/account_action.rb9
-rw-r--r--app/models/admin/action_log_filter.rb2
-rw-r--r--app/models/announcement.rb1
-rw-r--r--app/policies/account_policy.rb8
-rw-r--r--app/serializers/activitypub/note_serializer.rb4
-rw-r--r--app/serializers/rest/status_serializer.rb8
-rw-r--r--app/services/suspend_account_service.rb10
-rw-r--r--app/services/unsuspend_account_service.rb10
-rw-r--r--app/views/accounts/show.html.haml4
-rw-r--r--app/views/admin/accounts/show.html.haml7
-rw-r--r--app/views/home/index.html.haml8
-rw-r--r--app/views/layouts/_theme.html.haml6
-rwxr-xr-xapp/views/layouts/application.html.haml6
-rw-r--r--app/views/layouts/embedded.html.haml6
-rw-r--r--app/views/layouts/error.html.haml2
-rw-r--r--app/views/media/player.html.haml6
-rw-r--r--app/views/statuses/_detailed_status.html.haml6
-rw-r--r--app/views/statuses/_simple_status.html.haml6
-rw-r--r--app/views/statuses/_status.html.haml8
33 files changed, 193 insertions, 72 deletions
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index b9b75727d..1dd7430e0 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -53,6 +53,13 @@ module Admin
       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct)
     end
 
+    def unsensitive
+      authorize @account, :unsensitive?
+      @account.unsensitize!
+      log_action :unsensitive, @account
+      redirect_to admin_account_path(@account.id)
+    end
+
     def unsilence
       authorize @account, :unsilence?
       @account.unsilence!
diff --git a/app/controllers/admin/announcements_controller.rb b/app/controllers/admin/announcements_controller.rb
index 494fd13d0..351b9a991 100644
--- a/app/controllers/admin/announcements_controller.rb
+++ b/app/controllers/admin/announcements_controller.rb
@@ -71,7 +71,7 @@ class Admin::AnnouncementsController < Admin::BaseController
   private
 
   def set_announcements
-    @announcements = AnnouncementFilter.new(filter_params).results.page(params[:page])
+    @announcements = AnnouncementFilter.new(filter_params).results.reverse_chronological.page(params[:page])
   end
 
   def set_announcement
diff --git a/app/controllers/api/v1/admin/accounts_controller.rb b/app/controllers/api/v1/admin/accounts_controller.rb
index 3af572f25..63cc521ed 100644
--- a/app/controllers/api/v1/admin/accounts_controller.rb
+++ b/app/controllers/api/v1/admin/accounts_controller.rb
@@ -22,6 +22,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
     active
     pending
     disabled
+    sensitized
     silenced
     suspended
     username
@@ -68,6 +69,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController
     render json: @account, serializer: REST::Admin::AccountSerializer
   end
 
+  def unsensitive
+    authorize @account, :unsensitive?
+    @account.unsensitize!
+    log_action :unsensitive, @account
+    render json: @account, serializer: REST::Admin::AccountSerializer
+  end
+
   def unsilence
     authorize @account, :unsilence?
     @account.unsilence!
diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb
index a51597cf3..daed9048f 100644
--- a/app/helpers/statuses_helper.rb
+++ b/app/helpers/statuses_helper.rb
@@ -4,8 +4,12 @@ module StatusesHelper
   EMBEDDED_CONTROLLER = 'statuses'
   EMBEDDED_ACTION = 'embed'
 
-  def link_to_more(url)
-    link_to t('statuses.show_more'), url, class: 'load-more load-gap'
+  def link_to_newer(url)
+    link_to t('statuses.show_newer'), url, class: 'load-more load-gap'
+  end
+
+  def link_to_older(url)
+    link_to t('statuses.show_older'), url, class: 'load-more load-gap'
   end
 
   def nothing_here(extra_classes = '')
@@ -117,6 +121,14 @@ module StatusesHelper
     end
   end
 
+  def sensitized?(status, account)
+    if !account.nil? && account.id == status.account_id
+      status.sensitive
+    else
+      status.account.sensitized? || status.sensitive
+    end
+  end
+
   private
 
   def simplified_text(text)
diff --git a/app/javascript/mastodon/components/column.js b/app/javascript/mastodon/components/column.js
index 55e3bfd5e..239824a4f 100644
--- a/app/javascript/mastodon/components/column.js
+++ b/app/javascript/mastodon/components/column.js
@@ -1,6 +1,6 @@
 import React from 'react';
 import PropTypes from 'prop-types';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { scrollTop } from '../scroll';
 
 export default class Column extends React.PureComponent {
@@ -35,9 +35,9 @@ export default class Column extends React.PureComponent {
 
   componentDidMount () {
     if (this.props.bindToDocument) {
-      document.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      document.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     } else {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
   }
 
diff --git a/app/javascript/mastodon/components/dropdown_menu.js b/app/javascript/mastodon/components/dropdown_menu.js
index 09e3c9df8..c6b4b1187 100644
--- a/app/javascript/mastodon/components/dropdown_menu.js
+++ b/app/javascript/mastodon/components/dropdown_menu.js
@@ -5,9 +5,9 @@ import IconButton from './icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
 import Motion from '../features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 let id = 0;
 
 class DropdownMenu extends React.PureComponent {
diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
index bac136e5e..dc4f48060 100644
--- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js
@@ -5,7 +5,7 @@ import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components'
 import Overlay from 'react-overlays/lib/Overlay';
 import classNames from 'classnames';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
 import { assetHost } from 'mastodon/utils/config';
 
@@ -29,7 +29,7 @@ const messages = defineMessages({
 let EmojiPicker, Emoji; // load asynchronously
 
 const backgroundImageFn = () => `${assetHost}/emoji/sheet_10.png`;
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 class ModifierPickerMenu extends React.PureComponent {
 
diff --git a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
index 5223025fb..309f46290 100644
--- a/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
+++ b/app/javascript/mastodon/features/compose/components/privacy_dropdown.js
@@ -5,7 +5,7 @@ import IconButton from '../../../components/icon_button';
 import Overlay from 'react-overlays/lib/Overlay';
 import Motion from '../../ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import classNames from 'classnames';
 import Icon from 'mastodon/components/icon';
 
@@ -21,7 +21,7 @@ const messages = defineMessages({
   change_privacy: { id: 'privacy.change', defaultMessage: 'Adjust status privacy' },
 });
 
-const listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+const listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 class PrivacyDropdownMenu extends React.PureComponent {
 
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index ecc0b8f0b..36a84fcbf 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -31,7 +31,7 @@ import Icon from 'mastodon/components/icon';
 import ComposePanel from './compose_panel';
 import NavigationPanel from './navigation_panel';
 
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 import { scrollRight } from '../../../scroll';
 
 const componentMap = {
@@ -80,7 +80,7 @@ class ColumnsArea extends ImmutablePureComponent {
 
   componentDidMount() {
     if (!this.props.singleColumn) {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
 
     this.lastIndex   = getIndex(this.context.router.history.location.pathname);
@@ -97,7 +97,7 @@ class ColumnsArea extends ImmutablePureComponent {
 
   componentDidUpdate(prevProps) {
     if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
-      this.node.addEventListener('wheel', this.handleWheel,  detectPassiveEvents.hasSupport ? { passive: true } : false);
+      this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
     this.lastIndex = getIndex(this.context.router.history.location.pathname);
     this.setState({ shouldAnimate: true });
diff --git a/app/javascript/mastodon/features/ui/components/zoomable_image.js b/app/javascript/mastodon/features/ui/components/zoomable_image.js
index 402196727..1cf263cb9 100644
--- a/app/javascript/mastodon/features/ui/components/zoomable_image.js
+++ b/app/javascript/mastodon/features/ui/components/zoomable_image.js
@@ -113,7 +113,8 @@ class ZoomableImage extends React.PureComponent {
   state = {
     scale: MIN_SCALE,
     zoomMatrix: {
-      type: null, // 'full-width' 'full-height'
+      type: null, // 'width' 'height'
+      fullScreen: null, // bool
       rate: null, // full screen scale rate
       clientWidth: null,
       clientHeight: null,
@@ -122,12 +123,15 @@ class ZoomableImage extends React.PureComponent {
       clientHeightFixed: null,
       scrollTop: null,
       scrollLeft: null,
+      translateX: null,
+      translateY: null,
     },
     zoomState: 'expand', // 'expand' 'compress'
     navigationHidden: false,
     dragPosition: { top: 0, left: 0, x: 0, y: 0 },
     dragged: false,
     lockScroll: { x: 0, y: 0 },
+    lockTranslate: { x: 0, y: 0 },
   }
 
   removers = [];
@@ -168,18 +172,24 @@ class ZoomableImage extends React.PureComponent {
   }
 
   componentDidUpdate () {
+    this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
+
+    if (this.state.scale === MIN_SCALE) {
+      this.container.style.removeProperty('cursor');
+    }
+  }
+
+  UNSAFE_componentWillReceiveProps () {
+    // reset when slide to next image
     if (this.props.zoomButtonHidden) {
-      this.setState({ scale: MIN_SCALE }, () => {
+      this.setState({
+        scale: MIN_SCALE,
+        lockTranslate: { x: 0, y: 0 },
+      }, () => {
         this.container.scrollLeft = 0;
         this.container.scrollTop = 0;
       });
     }
-
-    this.setState({ zoomState: this.state.scale >= this.state.zoomMatrix.rate ? 'compress' : 'expand' });
-
-    if (this.state.scale === 1) {
-      this.container.style.removeProperty('cursor');
-    }
   }
 
   removeEventListeners () {
@@ -192,7 +202,7 @@ class ZoomableImage extends React.PureComponent {
 
     const event = normalizeWheel(e);
 
-    if (this.state.zoomMatrix.type === 'full-width') {
+    if (this.state.zoomMatrix.type === 'width') {
       // full width, scroll vertical
       this.container.scrollTop = Math.max(this.container.scrollTop + event.pixelY, this.state.lockScroll.y);
     } else {
@@ -268,7 +278,7 @@ class ZoomableImage extends React.PureComponent {
   }
 
   zoom(nextScale, midpoint) {
-    const { scale } = this.state;
+    const { scale, zoomMatrix } = this.state;
     const { scrollLeft, scrollTop } = this.container;
 
     // math memo:
@@ -283,6 +293,15 @@ class ZoomableImage extends React.PureComponent {
     this.setState({ scale: nextScale }, () => {
       this.container.scrollLeft = nextScrollLeft;
       this.container.scrollTop = nextScrollTop;
+      // reset the translateX/Y constantly
+      if (nextScale < zoomMatrix.rate) {
+        this.setState({
+          lockTranslate: {
+            x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
+            y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY * ((nextScale - MIN_SCALE) / (zoomMatrix.rate - MIN_SCALE)),
+          },
+        });
+      }
     });
   }
 
@@ -307,14 +326,18 @@ class ZoomableImage extends React.PureComponent {
     const { offsetWidth, offsetHeight } = this.image;
     const clientHeightFixed = clientHeight - NAV_BAR_HEIGHT;
 
-    const type = width/height < clientWidth / clientHeightFixed ? 'full-width' : 'full-height';
-    const rate = type === 'full-width' ? clientWidth / offsetWidth : clientHeightFixed / offsetHeight;
-    const scrollTop = type === 'full-width' ?  (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
+    const type = width / height < clientWidth / clientHeightFixed ? 'width' : 'height';
+    const fullScreen = type === 'width' ?  width > clientWidth : height > clientHeightFixed;
+    const rate = type === 'width' ? Math.min(clientWidth, width) / offsetWidth : Math.min(clientHeightFixed, height) / offsetHeight;
+    const scrollTop = type === 'width' ?  (clientHeight - offsetHeight) / 2 - NAV_BAR_HEIGHT : (clientHeightFixed - offsetHeight) / 2;
     const scrollLeft = (clientWidth - offsetWidth) / 2;
+    const translateX = type === 'width' ? (width - offsetWidth) / (2 * rate) : 0;
+    const translateY = type === 'height' ? (height - offsetHeight) / (2 * rate) : 0;
 
     this.setState({
       zoomMatrix: {
         type: type,
+        fullScreen: fullScreen,
         rate: rate,
         clientWidth: clientWidth,
         clientHeight: clientHeight,
@@ -323,6 +346,8 @@ class ZoomableImage extends React.PureComponent {
         clientHeightFixed: clientHeightFixed,
         scrollTop: scrollTop,
         scrollLeft: scrollLeft,
+        translateX: translateX,
+        translateY: translateY,
       },
     });
   }
@@ -340,6 +365,10 @@ class ZoomableImage extends React.PureComponent {
           x: 0,
           y: 0,
         },
+        lockTranslate: {
+          x: 0,
+          y: 0,
+        },
       }, () => {
         this.container.scrollLeft = 0;
         this.container.scrollTop = 0;
@@ -351,6 +380,10 @@ class ZoomableImage extends React.PureComponent {
           x: zoomMatrix.scrollLeft,
           y: zoomMatrix.scrollTop,
         },
+        lockTranslate: {
+          x: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateX,
+          y: zoomMatrix.fullScreen ? 0 : zoomMatrix.translateY,
+        },
       }, () => {
         this.container.scrollLeft = zoomMatrix.scrollLeft;
         this.container.scrollTop = zoomMatrix.scrollTop;
@@ -371,15 +404,15 @@ class ZoomableImage extends React.PureComponent {
 
   render () {
     const { alt, src, width, height, intl } = this.props;
-    const { scale } = this.state;
-    const overflow = scale === 1 ? 'hidden' : 'scroll';
-    const zoomButtonSshouldHide = !this.state.navigationHidden && !this.props.zoomButtonHidden ? '' : 'media-modal__zoom-button--hidden';
+    const { scale, lockTranslate } = this.state;
+    const overflow = scale === MIN_SCALE ? 'hidden' : 'scroll';
+    const zoomButtonShouldHide = this.state.navigationHidden || this.props.zoomButtonHidden || this.state.zoomMatrix.rate <= MIN_SCALE ? 'media-modal__zoom-button--hidden' : '';
     const zoomButtonTitle = this.state.zoomState === 'compress' ? intl.formatMessage(messages.compress) : intl.formatMessage(messages.expand);
 
     return (
       <React.Fragment>
         <IconButton
-          className={`media-modal__zoom-button ${zoomButtonSshouldHide}`}
+          className={`media-modal__zoom-button ${zoomButtonShouldHide}`}
           title={zoomButtonTitle}
           icon={this.state.zoomState}
           onClick={this.handleZoomClick}
@@ -402,7 +435,7 @@ class ZoomableImage extends React.PureComponent {
             width={width}
             height={height}
             style={{
-              transform: `scale(${scale})`,
+              transform: `scale(${scale}) translate(-${lockTranslate.x}px, -${lockTranslate.y}px)`,
               transformOrigin: '0 0',
             }}
             draggable={false}
diff --git a/app/javascript/mastodon/is_mobile.js b/app/javascript/mastodon/is_mobile.js
index f96df1ebb..5a8c3db08 100644
--- a/app/javascript/mastodon/is_mobile.js
+++ b/app/javascript/mastodon/is_mobile.js
@@ -1,4 +1,4 @@
-import detectPassiveEvents from 'detect-passive-events';
+import { supportsPassiveEvents } from 'detect-passive-events';
 
 const LAYOUT_BREAKPOINT = 630;
 
@@ -9,7 +9,7 @@ export function isMobile(width) {
 const iOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
 
 let userTouching = false;
-let listenerOptions = detectPassiveEvents.hasSupport ? { passive: true } : false;
+let listenerOptions = supportsPassiveEvents ? { passive: true } : false;
 
 function touchListener() {
   userTouching = true;
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 3a9f83978..d56d47a2d 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -111,7 +111,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
         created_at: @object['published'],
         override_timestamps: @options[:override_timestamps],
         reply: @object['inReplyTo'].present?,
-        sensitive: @object['sensitive'] || false,
+        sensitive: @account.sensitized? || @object['sensitive'] || false,
         visibility: visibility_from_audience,
         thread: replied_to_status,
         conversation: conversation_from_uri(@object['conversation']),
diff --git a/app/models/account.rb b/app/models/account.rb
index 38f235baa..14df68058 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -50,6 +50,7 @@
 #  avatar_storage_schema_version :integer
 #  header_storage_schema_version :integer
 #  devices_url                   :string
+#  sensitized_at                 :datetime
 #
 
 class Account < ApplicationRecord
@@ -96,6 +97,7 @@ class Account < ApplicationRecord
   scope :partitioned, -> { order(Arel.sql('row_number() over (partition by domain)')) }
   scope :silenced, -> { where.not(silenced_at: nil) }
   scope :suspended, -> { where.not(suspended_at: nil) }
+  scope :sensitized, -> { where.not(sensitized_at: nil) }
   scope :without_suspended, -> { where(suspended_at: nil) }
   scope :without_silenced, -> { where(silenced_at: nil) }
   scope :recent, -> { reorder(id: :desc) }
@@ -238,6 +240,18 @@ class Account < ApplicationRecord
     end
   end
 
+  def sensitized?
+    sensitized_at.present?
+  end
+
+  def sensitize!(date = Time.now.utc)
+    update!(sensitized_at: date)
+  end
+
+  def unsensitize!
+    update!(sensitized_at: nil)
+  end
+
   def memorialize!
     update!(memorial: true)
   end
diff --git a/app/models/account_warning.rb b/app/models/account_warning.rb
index 157e6c04d..5efc924d5 100644
--- a/app/models/account_warning.rb
+++ b/app/models/account_warning.rb
@@ -13,7 +13,7 @@
 #
 
 class AccountWarning < ApplicationRecord
-  enum action: %i(none disable silence suspend), _suffix: :action
+  enum action: %i(none disable sensitive silence suspend), _suffix: :action
 
   belongs_to :account, inverse_of: :account_warnings
   belongs_to :target_account, class_name: 'Account', inverse_of: :targeted_account_warnings
diff --git a/app/models/admin/account_action.rb b/app/models/admin/account_action.rb
index c4ac09520..11ce737f3 100644
--- a/app/models/admin/account_action.rb
+++ b/app/models/admin/account_action.rb
@@ -8,6 +8,7 @@ class Admin::AccountAction
   TYPES = %w(
     none
     disable
+    sensitive
     silence
     suspend
   ).freeze
@@ -64,6 +65,8 @@ class Admin::AccountAction
     case type
     when 'disable'
       handle_disable!
+    when 'sensitive'
+      handle_sensitive!
     when 'silence'
       handle_silence!
     when 'suspend'
@@ -109,6 +112,12 @@ class Admin::AccountAction
     target_account.user&.disable!
   end
 
+  def handle_sensitive!
+    authorize(target_account, :sensitive?)
+    log_action(:sensitive, target_account)
+    target_account.sensitize!
+  end
+
   def handle_silence!
     authorize(target_account, :silence?)
     log_action(:silence, target_account)
diff --git a/app/models/admin/action_log_filter.rb b/app/models/admin/action_log_filter.rb
index 0ba7e1609..3a1b67e06 100644
--- a/app/models/admin/action_log_filter.rb
+++ b/app/models/admin/action_log_filter.rb
@@ -35,9 +35,11 @@ class Admin::ActionLogFilter
     reopen_report: { target_type: 'Report', action: 'reopen' }.freeze,
     reset_password_user: { target_type: 'User', action: 'reset_password' }.freeze,
     resolve_report: { target_type: 'Report', action: 'resolve' }.freeze,
+    sensitive_account: { target_type: 'Account', action: 'sensitive' }.freeze,
     silence_account: { target_type: 'Account', action: 'silence' }.freeze,
     suspend_account: { target_type: 'Account', action: 'suspend' }.freeze,
     unassigned_report: { target_type: 'Report', action: 'unassigned' }.freeze,
+    unsensitive_account: { target_type: 'Account', action: 'unsensitive' }.freeze,
     unsilence_account: { target_type: 'Account', action: 'unsilence' }.freeze,
     unsuspend_account: { target_type: 'Account', action: 'unsuspend' }.freeze,
     update_announcement: { target_type: 'Announcement', action: 'update' }.freeze,
diff --git a/app/models/announcement.rb b/app/models/announcement.rb
index c493604c2..f8183aabc 100644
--- a/app/models/announcement.rb
+++ b/app/models/announcement.rb
@@ -22,6 +22,7 @@ class Announcement < ApplicationRecord
   scope :published, -> { where(published: true) }
   scope :without_muted, ->(account) { joins("LEFT OUTER JOIN announcement_mutes ON announcement_mutes.announcement_id = announcements.id AND announcement_mutes.account_id = #{account.id}").where('announcement_mutes.id IS NULL') }
   scope :chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.published_at, announcements.created_at) ASC')) }
+  scope :reverse_chronological, -> { order(Arel.sql('COALESCE(announcements.starts_at, announcements.scheduled_at, announcements.published_at, announcements.created_at) DESC')) }
 
   has_many :announcement_mutes, dependent: :destroy
   has_many :announcement_reactions, dependent: :destroy
diff --git a/app/policies/account_policy.rb b/app/policies/account_policy.rb
index 1b105e92a..679119075 100644
--- a/app/policies/account_policy.rb
+++ b/app/policies/account_policy.rb
@@ -25,6 +25,14 @@ class AccountPolicy < ApplicationPolicy
     staff?
   end
 
+  def sensitive?
+    staff? && !record.user&.staff?
+  end
+
+  def unsensitive?
+    staff?
+  end
+
   def silence?
     staff? && !record.user&.staff?
   end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index a0965790e..4ac699ddf 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -110,6 +110,10 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
     ActivityPub::TagManager.instance.cc(object)
   end
 
+  def sensitive
+    object.account.sensitized? || object.sensitive
+  end
+
   def virtual_tags
     object.active_mentions.to_a.sort_by(&:id) + object.tags + object.emojis
   end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 58e7bd4e4..b5dcf6208 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -60,6 +60,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
     end
   end
 
+  def sensitive
+    if current_user? && current_user.account_id == object.account_id
+      object.sensitive
+    else
+      object.account.sensitized? || object.sensitive
+    end
+  end
+
   def uri
     ActivityPub::TagManager.instance.uri_for(object)
   end
diff --git a/app/services/suspend_account_service.rb b/app/services/suspend_account_service.rb
index 5a079c3ac..f08c41e17 100644
--- a/app/services/suspend_account_service.rb
+++ b/app/services/suspend_account_service.rb
@@ -18,7 +18,7 @@ class SuspendAccountService < BaseService
 
   def unmerge_from_home_timelines!
     @account.followers_for_local_distribution.find_each do |follower|
-      FeedManager.instance.unmerge_from_timeline(@account, follower)
+      FeedManager.instance.unmerge_from_home(@account, follower)
     end
   end
 
@@ -39,11 +39,15 @@ class SuspendAccountService < BaseService
         styles.each do |style|
           case Paperclip::Attachment.default_options[:storage]
           when :s3
-            attachment.s3_object(style).acl.put(:private)
+            attachment.s3_object(style).acl.put(acl: 'private')
           when :fog
             # Not supported
           when :filesystem
-            FileUtils.chmod(0o600 & ~File.umask, attachment.path(style))
+            begin
+              FileUtils.chmod(0o600 & ~File.umask, attachment.path(style)) unless attachment.path(style).nil?
+            rescue Errno::ENOENT
+              Rails.logger.warn "Tried to change permission on non-existent file #{attachment.path(style)}"
+            end
           end
         end
       end
diff --git a/app/services/unsuspend_account_service.rb b/app/services/unsuspend_account_service.rb
index 3e731ddd9..91dbc9c18 100644
--- a/app/services/unsuspend_account_service.rb
+++ b/app/services/unsuspend_account_service.rb
@@ -18,7 +18,7 @@ class UnsuspendAccountService < BaseService
 
   def merge_into_home_timelines!
     @account.followers_for_local_distribution.find_each do |follower|
-      FeedManager.instance.merge_into_timeline(@account, follower)
+      FeedManager.instance.merge_into_home(@account, follower)
     end
   end
 
@@ -39,11 +39,15 @@ class UnsuspendAccountService < BaseService
         styles.each do |style|
           case Paperclip::Attachment.default_options[:storage]
           when :s3
-            attachment.s3_object(style).acl.put(Paperclip::Attachment.default_options[:s3_permissions])
+            attachment.s3_object(style).acl.put(acl: Paperclip::Attachment.default_options[:s3_permissions])
           when :fog
             # Not supported
           when :filesystem
-            FileUtils.chmod(0o666 & ~File.umask, attachment.path(style))
+            begin
+              FileUtils.chmod(0o666 & ~File.umask, attachment.path(style)) unless attachment.path(style).nil?
+            rescue Errno::ENOENT
+              Rails.logger.warn "Tried to change permission on non-existent file #{attachment.path(style)}"
+            end
           end
         end
       end
diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml
index c9688ea88..1a81b96f6 100644
--- a/app/views/accounts/show.html.haml
+++ b/app/views/accounts/show.html.haml
@@ -39,12 +39,12 @@
             = render partial: 'statuses/status', collection: @pinned_statuses, as: :status, locals: { pinned: true }
 
           - if @newer_url
-            .entry= link_to_more @newer_url
+            .entry= link_to_newer @newer_url
 
           = render partial: 'statuses/status', collection: @statuses, as: :status
 
           - if @older_url
-            .entry= link_to_more @older_url
+            .entry= link_to_older @older_url
 
   .column-1
     - if @account.memorial?
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index f0a216f6b..d5978eddd 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -69,6 +69,8 @@
           = t('admin.accounts.confirming')
         - elsif @account.local? && !@account.user_approved?
           = t('admin.accounts.pending')
+        - elsif @account.sensitized?
+          = t('admin.accounts.sensitive')
         - else
           = t('admin.accounts.no_limits_imposed')
       .dashboard__counters__label= t 'admin.accounts.login_status'
@@ -192,6 +194,11 @@
           - else
             = link_to t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable'), class: 'button' if can?(:disable, @account.user)
 
+        - if @account.sensitized?
+          = link_to t('admin.accounts.undo_sensitized'), unsensitive_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsensitive, @account)
+        - elsif !@account.local? || @account.user_approved?
+          = link_to t('admin.accounts.sensitive'), new_admin_account_action_path(@account.id, type: 'sensitive'), class: 'button' if can?(:sensitive, @account)
+
         - if @account.silenced?
           = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account)
         - elsif !@account.local? || @account.user_approved?
diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml
index 9530e612a..ce47418d4 100644
--- a/app/views/home/index.html.haml
+++ b/app/views/home/index.html.haml
@@ -1,8 +1,8 @@
 - content_for :header_tags do
-  = preload_link_tag asset_pack_path('features/getting_started.js'), crossorigin: 'anonymous'
-  = preload_link_tag asset_pack_path('features/compose.js'), crossorigin: 'anonymous'
-  = preload_link_tag asset_pack_path('features/home_timeline.js'), crossorigin: 'anonymous'
-  = preload_link_tag asset_pack_path('features/notifications.js'), crossorigin: 'anonymous'
+  = preload_pack_asset 'features/getting_started.js', crossorigin: 'anonymous'
+  = preload_pack_asset 'features/compose.js', crossorigin: 'anonymous'
+  = preload_pack_asset 'features/home_timeline.js', crossorigin: 'anonymous'
+  = preload_pack_asset 'features/notifications.js', crossorigin: 'anonymous'
 
   %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key}
   = render_initial_state
diff --git a/app/views/layouts/_theme.html.haml b/app/views/layouts/_theme.html.haml
index 066d9de42..92de64b0d 100644
--- a/app/views/layouts/_theme.html.haml
+++ b/app/views/layouts/_theme.html.haml
@@ -2,12 +2,12 @@
   - if theme[:pack] != 'common' && theme[:common]
     = render partial: 'layouts/theme', object: theme[:common]
   - if theme[:pack]
-    = javascript_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", integrity: true, crossorigin: 'anonymous'
+    = javascript_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", crossorigin: 'anonymous'
     - if theme[:skin]
       - if !theme[:flavour] || theme[:skin] == 'default'
-        = stylesheet_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", integrity: true, media: 'all'
+        = stylesheet_pack_tag theme[:flavour] ? "flavours/#{theme[:flavour]}/#{theme[:pack]}" : "core/#{theme[:pack]}", media: 'all', crossorigin: 'anonymous'
       - else
-        = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}"
+        = stylesheet_pack_tag "skins/#{theme[:flavour]}/#{theme[:skin]}/#{theme[:pack]}", crossorigin: 'anonymous'
     - if theme[:preload]
       - theme[:preload].each do |link|
         %link{ href: asset_pack_path("#{link}.js"), crossorigin: 'anonymous', rel: 'preload', as: 'script' }/
diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml
index 1481f6973..32681773f 100755
--- a/app/views/layouts/application.html.haml
+++ b/app/views/layouts/application.html.haml
@@ -21,12 +21,12 @@
 
     %title= content_for?(:page_title) ? safe_join([yield(:page_title).chomp.html_safe, title], ' - ') : title
 
-    = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous'
+    = javascript_pack_tag "locales", crossorigin: 'anonymous'
     - if @theme
       - if @theme[:supported_locales].include? I18n.locale.to_s
-        = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous'
       - elsif @theme[:supported_locales].include? 'en'
-        = javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous'
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/en", crossorigin: 'anonymous'
     = csrf_meta_tags
     %meta{ name: 'style-nonce', content: request.content_security_policy_nonce }
 
diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml
index 69b206f69..431bd260c 100644
--- a/app/views/layouts/embedded.html.haml
+++ b/app/views/layouts/embedded.html.haml
@@ -12,12 +12,12 @@
       %link{ rel: 'dns-prefetch', href: storage_host }/
 
     = render_initial_state
-    = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous'
+    = javascript_pack_tag "locales", crossorigin: 'anonymous'
     - if @theme
       - if @theme[:supported_locales].include? I18n.locale.to_s
-        = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous'
       - elsif @theme[:supported_locales].include? 'en'
-        = javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous'
+        = javascript_pack_tag "locales/#{@theme[:flavour]}/en", crossorigin: 'anonymous'
     = render partial: 'layouts/theme', object: @core
     = render partial: 'layouts/theme', object: @theme
 
diff --git a/app/views/layouts/error.html.haml b/app/views/layouts/error.html.haml
index f8315afb5..55da5de3f 100644
--- a/app/views/layouts/error.html.haml
+++ b/app/views/layouts/error.html.haml
@@ -5,7 +5,7 @@
     %meta{ charset: 'utf-8' }/
     %title= safe_join([yield(:page_title), Setting.default_settings['site_title']], ' - ')
     %meta{ content: 'width=device-width,initial-scale=1', name: 'viewport' }/
-    = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous'
+    = javascript_pack_tag "locales", crossorigin: 'anonymous'
     = render partial: 'layouts/theme', object: (@core || { pack: 'common' })
     = render partial: 'layouts/theme', object: (@theme || { pack: 'error', flavour: 'glitch', common: { pack: 'common', flavour: 'glitch', skin: 'default' } })
   %body.error
diff --git a/app/views/media/player.html.haml b/app/views/media/player.html.haml
index bd5610a0b..7369628a4 100644
--- a/app/views/media/player.html.haml
+++ b/app/views/media/player.html.haml
@@ -1,11 +1,11 @@
 - content_for :header_tags do
   = render_initial_state
-  = javascript_pack_tag "locales", integrity: true, crossorigin: 'anonymous'
+  = javascript_pack_tag "locales", crossorigin: 'anonymous'
   - if @theme
     - if @theme[:supported_locales].include? I18n.locale.to_s
-      = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", integrity: true, crossorigin: 'anonymous'
+      = javascript_pack_tag "locales/#{@theme[:flavour]}/#{I18n.locale}", crossorigin: 'anonymous'
     - elsif @theme[:supported_locales].include? 'en'
-      = javascript_pack_tag "locales/#{@theme[:flavour]}/en", integrity: true, crossorigin: 'anonymous'
+      = javascript_pack_tag "locales/#{@theme[:flavour]}/en", crossorigin: 'anonymous'
   = render partial: 'layouts/theme', object: @core
   = render partial: 'layouts/theme', object: @theme
 
diff --git a/app/views/statuses/_detailed_status.html.haml b/app/views/statuses/_detailed_status.html.haml
index b3e9c44fc..a4dd8534f 100644
--- a/app/views/statuses/_detailed_status.html.haml
+++ b/app/views/statuses/_detailed_status.html.haml
@@ -29,17 +29,17 @@
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
+      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 670, height: 380, detailed: true, inline: true, alt: video.description do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - elsif status.media_attachments.first.audio?
       - audio = status.media_attachments.first
       = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 670, height: 380, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
-      = react_component :media_gallery, height: 380, sensitive: status.sensitive?, standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
+      = react_component :media_gallery, height: 380, sensitive: sensitized?(status, current_account), standalone: true, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
   - elsif status.preview_card
-    = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
 
   .detailed-status__meta
     %data.dt-published{ value: status.created_at.to_time.iso8601 }
diff --git a/app/views/statuses/_simple_status.html.haml b/app/views/statuses/_simple_status.html.haml
index d095a1613..199061c46 100644
--- a/app/views/statuses/_simple_status.html.haml
+++ b/app/views/statuses/_simple_status.html.haml
@@ -35,17 +35,17 @@
   - if !status.media_attachments.empty?
     - if status.media_attachments.first.video?
       - video = status.media_attachments.first
-      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: status.sensitive?, width: 610, height: 343, inline: true, alt: video.description do
+      = react_component :video, src: full_asset_url(video.file.url(:original)), preview: full_asset_url(video.thumbnail.present? ? video.thumbnail.url : video.file.url(:small)), blurhash: video.blurhash, sensitive: sensitized?(status, current_account), width: 610, height: 343, inline: true, alt: video.description do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - elsif status.media_attachments.first.audio?
       - audio = status.media_attachments.first
       = react_component :audio, src: full_asset_url(audio.file.url(:original)), poster: full_asset_url(audio.thumbnail.present? ? audio.thumbnail.url : status.account.avatar_static_url), backgroundColor: audio.file.meta.dig('colors', 'background'), foregroundColor: audio.file.meta.dig('colors', 'foreground'), accentColor: audio.file.meta.dig('colors', 'accent'), width: 610, height: 343, alt: audio.description, duration: audio.file.meta.dig('original', 'duration') do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
     - else
-      = react_component :media_gallery, height: 343, sensitive: status.sensitive?, autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
+      = react_component :media_gallery, height: 343, sensitive: sensitized?(status, current_account), autoplay: autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
         = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
   - elsif status.preview_card
-    = react_component :card, sensitive: status.sensitive?, 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
+    = react_component :card, sensitive: sensitized?(status, current_account), 'maxDescription': 160, card: ActiveModelSerializers::SerializableResource.new(status.preview_card, serializer: REST::PreviewCardSerializer).as_json
 
   - if !status.in_reply_to_id.nil? && status.in_reply_to_account_id == status.account.id
     = link_to ActivityPub::TagManager.instance.url_for(status), class: 'status__content__read-more-button', target: stream_link_target, rel: 'noopener noreferrer' do
diff --git a/app/views/statuses/_status.html.haml b/app/views/statuses/_status.html.haml
index 0e3652503..650f9b679 100644
--- a/app/views/statuses/_status.html.haml
+++ b/app/views/statuses/_status.html.haml
@@ -17,7 +17,7 @@
 - if status.reply? && include_threads
   - if @next_ancestor
     .entry{ class: entry_classes }
-      = link_to_more ActivityPub::TagManager.instance.url_for(@next_ancestor)
+      = link_to_older ActivityPub::TagManager.instance.url_for(@next_ancestor)
 
   = render partial: 'statuses/status', collection: @ancestors, as: :status, locals: { is_predecessor: true, direct_reply_id: status.in_reply_to_id }, autoplay: autoplay
 
@@ -44,16 +44,16 @@
 - if include_threads
   - if @since_descendant_thread_id
     .entry{ class: entry_classes }
-      = link_to_more short_account_status_url(status.account.username, status, max_descendant_thread_id: @since_descendant_thread_id + 1)
+      = link_to_newer short_account_status_url(status.account.username, status, max_descendant_thread_id: @since_descendant_thread_id + 1)
   - @descendant_threads.each do |thread|
     = render partial: 'statuses/status', collection: thread[:statuses], as: :status, locals: { is_successor: true, parent_id: status.id }, autoplay: autoplay
 
     - if thread[:next_status]
       .entry{ class: entry_classes }
-        = link_to_more ActivityPub::TagManager.instance.url_for(thread[:next_status])
+        = link_to_newer ActivityPub::TagManager.instance.url_for(thread[:next_status])
   - if @next_descendant_thread
     .entry{ class: entry_classes }
-      = link_to_more short_account_status_url(status.account.username, status, since_descendant_thread_id: @max_descendant_thread_id - 1)
+      = link_to_newer short_account_status_url(status.account.username, status, since_descendant_thread_id: @max_descendant_thread_id - 1)
 
 - if include_threads && !embedded_view? && !user_signed_in?
   .entry{ class: entry_classes }