about summary refs log tree commit diff
diff options
context:
space:
mode:
authorStarfall <us@starfall.systems>2020-12-10 18:39:04 -0600
committerStarfall <us@starfall.systems>2020-12-10 18:39:04 -0600
commitab127fd7941b7c84e6d6fe3071d41f52affb143c (patch)
tree7daabe20eb581a2b9215ed9c4f161bcab89019bd
parente68391fbaaf409cc01f74c8cf7076520a43ae06e (diff)
parent4acb5c90a271779a050c4d3b54562bd29359e5c0 (diff)
Merge branch 'glitch' into main
-rw-r--r--app/controllers/about_controller.rb6
-rw-r--r--app/controllers/auth/registrations_controller.rb11
-rw-r--r--app/controllers/concerns/registration_spam_concern.rb9
-rw-r--r--app/javascript/flavours/glitch/actions/markers.js13
-rw-r--r--app/javascript/flavours/glitch/features/list_timeline/index.js8
-rw-r--r--app/javascript/flavours/glitch/features/notifications/index.js8
-rw-r--r--app/javascript/flavours/glitch/features/ui/components/columns_area.js13
-rw-r--r--app/javascript/flavours/glitch/packs/public.js11
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss8
-rw-r--r--app/javascript/flavours/glitch/theme.yml2
-rw-r--r--app/javascript/flavours/vanilla/theme.yml2
-rw-r--r--app/javascript/mastodon/components/status.js16
-rw-r--r--app/javascript/mastodon/containers/status_container.js9
-rw-r--r--app/javascript/mastodon/features/list_timeline/index.js8
-rw-r--r--app/javascript/mastodon/features/notifications/index.js8
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js9
-rw-r--r--app/javascript/mastodon/features/status/containers/detailed_status_container.js4
-rw-r--r--app/javascript/mastodon/features/status/index.js19
-rw-r--r--app/javascript/mastodon/features/ui/components/columns_area.js13
-rw-r--r--app/javascript/mastodon/selectors/index.js12
-rw-r--r--app/javascript/packs/public.js11
-rw-r--r--app/javascript/styles/mastodon/forms.scss8
-rw-r--r--app/lib/feed_manager.rb4
-rw-r--r--app/models/list.rb4
-rw-r--r--app/models/user.rb7
-rw-r--r--app/validators/registration_form_time_validator.rb9
-rw-r--r--app/views/about/_registration.html.haml3
-rw-r--r--app/views/auth/registrations/new.html.haml3
-rw-r--r--app/views/shared/_error_messages.html.haml3
-rw-r--r--config/locales/en.yml1
-rw-r--r--config/locales/simple_form.en.yml1
-rw-r--r--spec/controllers/auth/registrations_controller_spec.rb4
-rw-r--r--spec/lib/feed_manager_spec.rb6
33 files changed, 192 insertions, 61 deletions
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index 5d5db937c..5ff6990d7 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -1,13 +1,17 @@
 # frozen_string_literal: true
 
 class AboutController < ApplicationController
+  include RegistrationSpamConcern
+
   before_action :set_pack
+
   layout 'public'
 
   before_action :require_open_federation!, only: [:show, :more]
   before_action :set_body_classes, only: :show
   before_action :set_instance_presenter
-  before_action :set_expires_in, only: [:show, :more, :terms]
+  before_action :set_expires_in, only: [:more, :terms]
+  before_action :set_registration_form_time, only: :show
 
   skip_before_action :require_functional!, only: [:more, :terms]
 
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index 23e5a22e1..6429bd969 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -2,6 +2,7 @@
 
 class Auth::RegistrationsController < Devise::RegistrationsController
   include Devise::Controllers::Rememberable
+  include RegistrationSpamConcern
 
   layout :determine_layout
 
@@ -14,6 +15,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   before_action :set_body_classes, only: [:new, :create, :edit, :update]
   before_action :require_not_suspended!, only: [:update]
   before_action :set_cache_headers, only: [:edit, :update]
+  before_action :set_registration_form_time, only: :new
 
   skip_before_action :require_functional!, only: [:edit, :update]
 
@@ -46,16 +48,17 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   def build_resource(hash = nil)
     super(hash)
 
-    resource.locale      = I18n.locale
-    resource.invite_code = params[:invite_code] if resource.invite_code.blank?
-    resource.sign_up_ip  = request.remote_ip
+    resource.locale                 = I18n.locale
+    resource.invite_code            = params[:invite_code] if resource.invite_code.blank?
+    resource.registration_form_time = session[:registration_form_time]
+    resource.sign_up_ip             = request.remote_ip
 
     resource.build_account if resource.account.nil?
   end
 
   def configure_sign_up_params
     devise_parameter_sanitizer.permit(:sign_up) do |u|
-      u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement)
+      u.permit({ account_attributes: [:username], invite_request_attributes: [:text] }, :email, :password, :password_confirmation, :invite_code, :agreement, :website, :confirm_password)
     end
   end
 
diff --git a/app/controllers/concerns/registration_spam_concern.rb b/app/controllers/concerns/registration_spam_concern.rb
new file mode 100644
index 000000000..af434c985
--- /dev/null
+++ b/app/controllers/concerns/registration_spam_concern.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module RegistrationSpamConcern
+  extend ActiveSupport::Concern
+
+  def set_registration_form_time
+    session[:registration_form_time] = Time.now.utc
+  end
+end
diff --git a/app/javascript/flavours/glitch/actions/markers.js b/app/javascript/flavours/glitch/actions/markers.js
index c0e7a93af..a086def97 100644
--- a/app/javascript/flavours/glitch/actions/markers.js
+++ b/app/javascript/flavours/glitch/actions/markers.js
@@ -1,7 +1,6 @@
 import api from 'flavours/glitch/util/api';
 import { debounce } from 'lodash';
 import compareId from 'flavours/glitch/util/compare_id';
-import { showAlertForError } from './alerts';
 
 export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
 export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
@@ -29,15 +28,19 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
       },
       body: JSON.stringify(params),
     });
+
     return;
   } else if (navigator && navigator.sendBeacon) {
     // Failing that, we can use sendBeacon, but we have to encode the data as
     // FormData for DoorKeeper to recognize the token.
     const formData = new FormData();
+
     formData.append('bearer_token', accessToken);
+
     for (const [id, value] of Object.entries(params)) {
       formData.append(`${id}[last_read_id]`, value.last_read_id);
     }
+
     if (navigator.sendBeacon('/api/v1/markers', formData)) {
       return;
     }
@@ -85,11 +88,9 @@ const debouncedSubmitMarkers = debounce((dispatch, getState) => {
     return;
   }
 
-  api().post('/api/v1/markers', params).then(() => {
+  api(getState).post('/api/v1/markers', params).then(() => {
     dispatch(submitMarkersSuccess(params));
-  }).catch(error => {
-    dispatch(showAlertForError(error));
-  });
+  }).catch(() => {});
 }, 300000, { leading: true, trailing: true });
 
 export function submitMarkersSuccess({ home, notifications }) {
@@ -102,9 +103,11 @@ export function submitMarkersSuccess({ home, notifications }) {
 
 export function submitMarkers(params = {}) {
   const result = (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
+
   if (params.immediate === true) {
     debouncedSubmitMarkers.flush();
   }
+
   return result;
 };
 
diff --git a/app/javascript/flavours/glitch/features/list_timeline/index.js b/app/javascript/flavours/glitch/features/list_timeline/index.js
index 70e530bae..d826c8ccd 100644
--- a/app/javascript/flavours/glitch/features/list_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/list_timeline/index.js
@@ -19,9 +19,9 @@ import RadioButton from 'flavours/glitch/components/radio_button';
 const messages = defineMessages({
   deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' },
   deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' },
-  all_replies:   { id: 'lists.replies_policy.all_replies', defaultMessage: 'Any followed user' },
-  no_replies:    { id: 'lists.replies_policy.no_replies', defaultMessage: 'No one' },
-  list_replies:  { id: 'lists.replies_policy.list_replies', defaultMessage: 'Members of the list' },
+  followed:   { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
+  none:    { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
+  list:  { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
 });
 
 const mapStateToProps = (state, props) => ({
@@ -193,7 +193,7 @@ class ListTimeline extends React.PureComponent {
                 <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' />
               </span>
               <div className='column-settings__row'>
-                { ['no_replies', 'list_replies', 'all_replies'].map(policy => (
+                { ['none', 'list', 'followed'].map(policy => (
                   <RadioButton name='order' value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
                 ))}
               </div>
diff --git a/app/javascript/flavours/glitch/features/notifications/index.js b/app/javascript/flavours/glitch/features/notifications/index.js
index 97434b586..73d969517 100644
--- a/app/javascript/flavours/glitch/features/notifications/index.js
+++ b/app/javascript/flavours/glitch/features/notifications/index.js
@@ -37,10 +37,16 @@ const messages = defineMessages({
   markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
 });
 
+const getExcludedTypes = createSelector([
+  state => state.getIn(['settings', 'notifications', 'shows']),
+], (shows) => {
+  return ImmutableList(shows.filter(item => !item).keys());
+});
+
 const getNotifications = createSelector([
   state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
   state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
-  state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
+  getExcludedTypes,
   state => state.getIn(['notifications', 'items']),
 ], (showFilterBar, allowedType, excludedTypes, notifications) => {
   if (!showFilterBar || allowedType === 'all') {
diff --git a/app/javascript/flavours/glitch/features/ui/components/columns_area.js b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
index 729ade212..640be19ab 100644
--- a/app/javascript/flavours/glitch/features/ui/components/columns_area.js
+++ b/app/javascript/flavours/glitch/features/ui/components/columns_area.js
@@ -75,7 +75,9 @@ class ColumnsArea extends ImmutablePureComponent {
   }
 
   componentWillReceiveProps() {
-    this.setState({ shouldAnimate: false });
+    if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) {
+      this.setState({ shouldAnimate: false });
+    }
   }
 
   componentDidMount() {
@@ -99,8 +101,13 @@ class ColumnsArea extends ImmutablePureComponent {
     if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
       this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
-    this.lastIndex = getIndex(this.context.router.history.location.pathname);
-    this.setState({ shouldAnimate: true });
+
+    const newIndex = getIndex(this.context.router.history.location.pathname);
+
+    if (this.lastIndex !== newIndex) {
+      this.lastIndex = newIndex;
+      this.setState({ shouldAnimate: true });
+    }
   }
 
   componentWillUnmount () {
diff --git a/app/javascript/flavours/glitch/packs/public.js b/app/javascript/flavours/glitch/packs/public.js
index c74e5c9af..dccdbc8d0 100644
--- a/app/javascript/flavours/glitch/packs/public.js
+++ b/app/javascript/flavours/glitch/packs/public.js
@@ -151,6 +151,17 @@ function main() {
       target.style.display = 'block';
     }
   });
+
+  // Empty the honeypot fields in JS in case something like an extension
+  // automatically filled them.
+  delegate(document, '#registration_new_user,#new_user', 'submit', () => {
+    ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
+      const field = document.getElementById(id);
+      if (field) {
+        field.value = '';
+      }
+    });
+  });
 }
 
 loadPolyfills()
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index cf60ce7d6..f973cad22 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -339,6 +339,7 @@ code {
   input[type=number],
   input[type=email],
   input[type=password],
+  input[type=url],
   textarea {
     box-sizing: border-box;
     font-size: 16px;
@@ -979,3 +980,10 @@ code {
     flex-direction: row;
   }
 }
+
+.input.user_confirm_password,
+.input.user_website {
+  &:not(.field_with_errors) {
+    display: none;
+  }
+}
diff --git a/app/javascript/flavours/glitch/theme.yml b/app/javascript/flavours/glitch/theme.yml
index 0fd627f19..2a98e4c29 100644
--- a/app/javascript/flavours/glitch/theme.yml
+++ b/app/javascript/flavours/glitch/theme.yml
@@ -2,7 +2,7 @@
 pack:
   about: packs/about.js
   admin: packs/public.js
-  auth:
+  auth: packs/public.js
   common:
     filename: packs/common.js
     stylesheet: true
diff --git a/app/javascript/flavours/vanilla/theme.yml b/app/javascript/flavours/vanilla/theme.yml
index 42e26daea..74e9fb1b5 100644
--- a/app/javascript/flavours/vanilla/theme.yml
+++ b/app/javascript/flavours/vanilla/theme.yml
@@ -2,7 +2,7 @@
 pack:
   about: about.js
   admin: public.js
-  auth:
+  auth: public.js
   common:
     filename: common.js
     stylesheet: true
diff --git a/app/javascript/mastodon/components/status.js b/app/javascript/mastodon/components/status.js
index 22098d57e..295e83f58 100644
--- a/app/javascript/mastodon/components/status.js
+++ b/app/javascript/mastodon/components/status.js
@@ -97,7 +97,7 @@ class Status extends ImmutablePureComponent {
     cachedMediaWidth: PropTypes.number,
     scrollKey: PropTypes.string,
     deployPictureInPicture: PropTypes.func,
-    pictureInPicture: PropTypes.shape({
+    pictureInPicture: ImmutablePropTypes.contains({
       inUse: PropTypes.bool,
       available: PropTypes.bool,
     }),
@@ -203,15 +203,15 @@ class Status extends ImmutablePureComponent {
 
   handleHotkeyOpenMedia = e => {
     const { onOpenMedia, onOpenVideo } = this.props;
-    const statusId = this._properStatus().get('id');
+    const status = this._properStatus();
 
     e.preventDefault();
 
     if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
-        onOpenVideo(statusId, status.getIn(['media_attachments', 0]), { startTime: 0 });
+        onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), { startTime: 0 });
       } else {
-        onOpenMedia(statusId, status.get('media_attachments'), 0);
+        onOpenMedia(status.get('id'), status.get('media_attachments'), 0);
       }
     }
   }
@@ -354,7 +354,7 @@ class Status extends ImmutablePureComponent {
       status  = status.get('reblog');
     }
 
-    if (pictureInPicture.inUse) {
+    if (pictureInPicture.get('inUse')) {
       media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
     } else if (status.get('media_attachments').size > 0) {
       if (this.props.muted) {
@@ -381,7 +381,7 @@ class Status extends ImmutablePureComponent {
                 width={this.props.cachedMediaWidth}
                 height={110}
                 cacheWidth={this.props.cacheMediaWidth}
-                deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined}
+                deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
               />
             )}
           </Bundle>
@@ -404,7 +404,7 @@ class Status extends ImmutablePureComponent {
                 sensitive={status.get('sensitive')}
                 onOpenVideo={this.handleOpenVideo}
                 cacheWidth={this.props.cacheMediaWidth}
-                deployPictureInPicture={pictureInPicture.available ? this.handleDeployPictureInPicture : undefined}
+                deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
                 visible={this.state.showMedia}
                 onToggleVisibility={this.handleToggleMediaVisibility}
               />
@@ -432,7 +432,7 @@ class Status extends ImmutablePureComponent {
     } else if (status.get('spoiler_text').length === 0 && status.get('card')) {
       media = (
         <Card
-          onOpenMedia={this.props.onOpenMedia}
+          onOpenMedia={this.handleOpenMedia}
           card={status.get('card')}
           compact
           cacheWidth={this.props.cacheMediaWidth}
diff --git a/app/javascript/mastodon/containers/status_container.js b/app/javascript/mastodon/containers/status_container.js
index ef520b96a..d6bcb8973 100644
--- a/app/javascript/mastodon/containers/status_container.js
+++ b/app/javascript/mastodon/containers/status_container.js
@@ -1,7 +1,7 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import Status from '../components/status';
-import { makeGetStatus } from '../selectors';
+import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
 import {
   replyCompose,
   mentionCompose,
@@ -54,14 +54,11 @@ const messages = defineMessages({
 
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
+  const getPictureInPicture = makeGetPictureInPicture();
 
   const mapStateToProps = (state, props) => ({
     status: getStatus(state, props),
-
-    pictureInPicture: {
-      inUse: state.getIn(['meta', 'layout']) !== 'mobile' && state.get('picture_in_picture').statusId === props.id,
-      available: state.getIn(['meta', 'layout']) !== 'mobile',
-    },
+    pictureInPicture: getPictureInPicture(state, props),
   });
 
   return mapStateToProps;
diff --git a/app/javascript/mastodon/features/list_timeline/index.js b/app/javascript/mastodon/features/list_timeline/index.js
index a3be8fbea..02b018247 100644
--- a/app/javascript/mastodon/features/list_timeline/index.js
+++ b/app/javascript/mastodon/features/list_timeline/index.js
@@ -20,9 +20,9 @@ import RadioButton from 'mastodon/components/radio_button';
 const messages = defineMessages({
   deleteMessage: { id: 'confirmations.delete_list.message', defaultMessage: 'Are you sure you want to permanently delete this list?' },
   deleteConfirm: { id: 'confirmations.delete_list.confirm', defaultMessage: 'Delete' },
-  all_replies:   { id: 'lists.replies_policy.all_replies', defaultMessage: 'Any followed user' },
-  no_replies:    { id: 'lists.replies_policy.no_replies', defaultMessage: 'No one' },
-  list_replies:  { id: 'lists.replies_policy.list_replies', defaultMessage: 'Members of the list' },
+  followed:   { id: 'lists.replies_policy.followed', defaultMessage: 'Any followed user' },
+  none:    { id: 'lists.replies_policy.none', defaultMessage: 'No one' },
+  list:  { id: 'lists.replies_policy.list', defaultMessage: 'Members of the list' },
 });
 
 const mapStateToProps = (state, props) => ({
@@ -193,7 +193,7 @@ class ListTimeline extends React.PureComponent {
                 <FormattedMessage id='lists.replies_policy.title' defaultMessage='Show replies to:' />
               </span>
               <div className='column-settings__row'>
-                { ['no_replies', 'list_replies', 'all_replies'].map(policy => (
+                { ['none', 'list', 'followed'].map(policy => (
                   <RadioButton name='order' value={policy} label={intl.formatMessage(messages[policy])} checked={replies_policy === policy} onChange={this.handleRepliesPolicyChange} />
                 ))}
               </div>
diff --git a/app/javascript/mastodon/features/notifications/index.js b/app/javascript/mastodon/features/notifications/index.js
index 73df7f49d..2e0afd863 100644
--- a/app/javascript/mastodon/features/notifications/index.js
+++ b/app/javascript/mastodon/features/notifications/index.js
@@ -32,10 +32,16 @@ const messages = defineMessages({
   markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
 });
 
+const getExcludedTypes = createSelector([
+  state => state.getIn(['settings', 'notifications', 'shows']),
+], (shows) => {
+  return ImmutableList(shows.filter(item => !item).keys());
+});
+
 const getNotifications = createSelector([
   state => state.getIn(['settings', 'notifications', 'quickFilter', 'show']),
   state => state.getIn(['settings', 'notifications', 'quickFilter', 'active']),
-  state => ImmutableList(state.getIn(['settings', 'notifications', 'shows']).filter(item => !item).keys()),
+  getExcludedTypes,
   state => state.getIn(['notifications', 'items']),
 ], (showFilterBar, allowedType, excludedTypes, notifications) => {
   if (!showFilterBar || allowedType === 'all') {
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index e20557eb3..043a749ed 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -41,7 +41,10 @@ class DetailedStatus extends ImmutablePureComponent {
     domain: PropTypes.string.isRequired,
     compact: PropTypes.bool,
     showMedia: PropTypes.bool,
-    usingPiP: PropTypes.bool,
+    pictureInPicture: ImmutablePropTypes.contains({
+      inUse: PropTypes.bool,
+      available: PropTypes.bool,
+    }),
     onToggleMediaVisibility: PropTypes.func,
   };
 
@@ -102,7 +105,7 @@ class DetailedStatus extends ImmutablePureComponent {
   render () {
     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
     const outerStyle = { boxSizing: 'border-box' };
-    const { intl, compact, usingPiP } = this.props;
+    const { intl, compact, pictureInPicture } = this.props;
 
     if (!status) {
       return null;
@@ -118,7 +121,7 @@ class DetailedStatus extends ImmutablePureComponent {
       outerStyle.height = `${this.state.height}px`;
     }
 
-    if (usingPiP) {
+    if (pictureInPicture.get('inUse')) {
       media = <PictureInPicturePlaceholder />;
     } else if (status.get('media_attachments').size > 0) {
       if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
diff --git a/app/javascript/mastodon/features/status/containers/detailed_status_container.js b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
index 6d5c33240..0ac4519c8 100644
--- a/app/javascript/mastodon/features/status/containers/detailed_status_container.js
+++ b/app/javascript/mastodon/features/status/containers/detailed_status_container.js
@@ -1,6 +1,6 @@
 import { connect } from 'react-redux';
 import DetailedStatus from '../components/detailed_status';
-import { makeGetStatus } from '../../../selectors';
+import { makeGetStatus, makeGetPictureInPicture } from '../../../selectors';
 import {
   replyCompose,
   mentionCompose,
@@ -40,10 +40,12 @@ const messages = defineMessages({
 
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
+  const getPictureInPicture = makeGetPictureInPicture();
 
   const mapStateToProps = (state, props) => ({
     status: getStatus(state, props),
     domain: state.getIn(['meta', 'domain']),
+    pictureInPicture: getPictureInPicture(state, props),
   });
 
   return mapStateToProps;
diff --git a/app/javascript/mastodon/features/status/index.js b/app/javascript/mastodon/features/status/index.js
index c5e7ba776..09822f372 100644
--- a/app/javascript/mastodon/features/status/index.js
+++ b/app/javascript/mastodon/features/status/index.js
@@ -43,7 +43,7 @@ import {
 import { initMuteModal } from '../../actions/mutes';
 import { initBlockModal } from '../../actions/blocks';
 import { initReport } from '../../actions/reports';
-import { makeGetStatus } from '../../selectors';
+import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
 import { ScrollContainer } from 'react-router-scroll-4';
 import ColumnBackButton from '../../components/column_back_button';
 import ColumnHeader from '../../components/column_header';
@@ -72,6 +72,7 @@ const messages = defineMessages({
 
 const makeMapStateToProps = () => {
   const getStatus = makeGetStatus();
+  const getPictureInPicture = makeGetPictureInPicture();
 
   const getAncestorsIds = createSelector([
     (_, { id }) => id,
@@ -129,11 +130,12 @@ const makeMapStateToProps = () => {
 
   const mapStateToProps = (state, props) => {
     const status = getStatus(state, { id: props.params.statusId });
-    let ancestorsIds = Immutable.List();
+
+    let ancestorsIds   = Immutable.List();
     let descendantsIds = Immutable.List();
 
     if (status) {
-      ancestorsIds = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
+      ancestorsIds   = getAncestorsIds(state, { id: status.get('in_reply_to_id') });
       descendantsIds = getDescendantsIds(state, { id: status.get('id') });
     }
 
@@ -143,7 +145,7 @@ const makeMapStateToProps = () => {
       descendantsIds,
       askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
       domain: state.getIn(['meta', 'domain']),
-      usingPiP: state.get('picture_in_picture').statusId === props.params.statusId,
+      pictureInPicture: getPictureInPicture(state, { id: props.params.statusId }),
     };
   };
 
@@ -168,7 +170,10 @@ class Status extends ImmutablePureComponent {
     askReplyConfirmation: PropTypes.bool,
     multiColumn: PropTypes.bool,
     domain: PropTypes.string.isRequired,
-    usingPiP: PropTypes.bool,
+    pictureInPicture: ImmutablePropTypes.contains({
+      inUse: PropTypes.bool,
+      available: PropTypes.bool,
+    }),
   };
 
   state = {
@@ -492,7 +497,7 @@ class Status extends ImmutablePureComponent {
 
   render () {
     let ancestors, descendants;
-    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
+    const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, pictureInPicture } = this.props;
     const { fullscreen } = this.state;
 
     if (status === null) {
@@ -550,7 +555,7 @@ class Status extends ImmutablePureComponent {
                   domain={domain}
                   showMedia={this.state.showMedia}
                   onToggleMediaVisibility={this.handleToggleMediaVisibility}
-                  usingPiP={usingPiP}
+                  pictureInPicture={pictureInPicture}
                 />
 
                 <ActionBar
diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js
index 36a84fcbf..6837450eb 100644
--- a/app/javascript/mastodon/features/ui/components/columns_area.js
+++ b/app/javascript/mastodon/features/ui/components/columns_area.js
@@ -75,7 +75,9 @@ class ColumnsArea extends ImmutablePureComponent {
   }
 
   componentWillReceiveProps() {
-    this.setState({ shouldAnimate: false });
+    if (typeof this.pendingIndex !== 'number' && this.lastIndex !== getIndex(this.context.router.history.location.pathname)) {
+      this.setState({ shouldAnimate: false });
+    }
   }
 
   componentDidMount() {
@@ -99,8 +101,13 @@ class ColumnsArea extends ImmutablePureComponent {
     if (this.props.singleColumn !== prevProps.singleColumn && !this.props.singleColumn) {
       this.node.addEventListener('wheel', this.handleWheel, supportsPassiveEvents ? { passive: true } : false);
     }
-    this.lastIndex = getIndex(this.context.router.history.location.pathname);
-    this.setState({ shouldAnimate: true });
+
+    const newIndex = getIndex(this.context.router.history.location.pathname);
+
+    if (this.lastIndex !== newIndex) {
+      this.lastIndex = newIndex;
+      this.setState({ shouldAnimate: true });
+    }
   }
 
   componentWillUnmount () {
diff --git a/app/javascript/mastodon/selectors/index.js b/app/javascript/mastodon/selectors/index.js
index fd3b72f96..1e19db65d 100644
--- a/app/javascript/mastodon/selectors/index.js
+++ b/app/javascript/mastodon/selectors/index.js
@@ -1,5 +1,5 @@
 import { createSelector } from 'reselect';
-import { List as ImmutableList, is } from 'immutable';
+import { List as ImmutableList, Map as ImmutableMap, is } from 'immutable';
 import { me } from '../initial_state';
 
 const getAccountBase         = (state, id) => state.getIn(['accounts', id], null);
@@ -121,6 +121,16 @@ export const makeGetStatus = () => {
   );
 };
 
+export const makeGetPictureInPicture = () => {
+  return createSelector([
+    (state, { id }) => state.get('picture_in_picture').statusId === id,
+    (state) => state.getIn(['meta', 'layout']) !== 'mobile',
+  ], (inUse, available) => ImmutableMap({
+    inUse: inUse && available,
+    available,
+  }));
+};
+
 const getAlertsBase = state => state.get('alerts');
 
 export const getAlerts = createSelector([getAlertsBase], (base) => {
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index 3f6700195..2166d8df0 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -155,6 +155,17 @@ function main() {
       target.style.display = 'block';
     }
   });
+
+  // Empty the honeypot fields in JS in case something like an extension
+  // automatically filled them.
+  delegate(document, '#registration_new_user,#new_user', 'submit', () => {
+    ['user_website', 'user_confirm_password', 'registration_user_website', 'registration_user_confirm_password'].forEach(id => {
+      const field = document.getElementById(id);
+      if (field) {
+        field.value = '';
+      }
+    });
+  });
 }
 
 loadPolyfills()
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index d1d8bda21..f7e03f028 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -348,6 +348,7 @@ code {
   input[type=number],
   input[type=email],
   input[type=password],
+  input[type=url],
   textarea {
     box-sizing: border-box;
     font-size: 16px;
@@ -988,3 +989,10 @@ code {
     flex-direction: row;
   }
 }
+
+.input.user_confirm_password,
+.input.user_website {
+  &:not(.field_with_errors) {
+    display: none;
+  }
+}
diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb
index 3c1f8d6e2..ebd25b398 100644
--- a/app/lib/feed_manager.rb
+++ b/app/lib/feed_manager.rb
@@ -403,8 +403,8 @@ class FeedManager
   def filter_from_list?(status, list)
     if status.reply? && status.in_reply_to_account_id != status.account_id
       should_filter = status.in_reply_to_account_id != list.account_id
-      should_filter &&= !list.show_all_replies?
-      should_filter &&= !(list.show_list_replies? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
+      should_filter &&= !list.show_followed?
+      should_filter &&= !(list.show_list? && ListAccount.where(list_id: list.id, account_id: status.in_reply_to_account_id).exists?)
 
       return !!should_filter
     end
diff --git a/app/models/list.rb b/app/models/list.rb
index 8493046e5..655d55ff6 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -8,7 +8,7 @@
 #  title          :string           default(""), not null
 #  created_at     :datetime         not null
 #  updated_at     :datetime         not null
-#  replies_policy :integer          default("list_replies"), not null
+#  replies_policy :integer          default("list"), not null
 #
 
 class List < ApplicationRecord
@@ -16,7 +16,7 @@ class List < ApplicationRecord
 
   PER_ACCOUNT_LIMIT = 50
 
-  enum replies_policy: [:list_replies, :all_replies, :no_replies], _prefix: :show
+  enum replies_policy: [:list, :followed, :none], _prefix: :show
 
   belongs_to :account, optional: true
 
diff --git a/app/models/user.rb b/app/models/user.rb
index 9bdbac76d..984f04b4e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -89,6 +89,13 @@ class User < ApplicationRecord
   validates_with EmailMxValidator, if: :validate_email_dns?
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
 
+  # Those are honeypot/antispam fields
+  attr_accessor :registration_form_time, :website, :confirm_password
+
+  validates_with RegistrationFormTimeValidator, on: :create
+  validates :website, absence: true, on: :create
+  validates :confirm_password, absence: true, on: :create
+
   scope :recent, -> { order(id: :desc) }
   scope :pending, -> { where(approved: false) }
   scope :approved, -> { where(approved: true) }
diff --git a/app/validators/registration_form_time_validator.rb b/app/validators/registration_form_time_validator.rb
new file mode 100644
index 000000000..ba7c7e6c6
--- /dev/null
+++ b/app/validators/registration_form_time_validator.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class RegistrationFormTimeValidator < ActiveModel::Validator
+  REGISTRATION_FORM_MIN_TIME = 3.seconds.freeze
+
+  def validate(user)
+    user.errors.add(:base, I18n.t('auth.too_fast')) if user.registration_form_time.present? && user.registration_form_time > REGISTRATION_FORM_MIN_TIME.ago
+  end
+end
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index 5d159e9e6..6160ca4d4 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -10,6 +10,9 @@
       = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off', :minlength => User.password_length.first, :maxlength => User.password_length.last }, hint: false, disabled: closed_registrations?
       = f.input :password_confirmation, placeholder: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
 
+      = f.input :confirm_password, as: :string, placeholder: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
+      = f.input :website, as: :url, placeholder: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
+
     - if approved_registrations?
       .fields-group
         = f.simple_fields_for :invite_request do |invite_request_fields|
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index cc72b87ce..de541847f 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -24,6 +24,9 @@
 
   .fields-group
     = f.input :password_confirmation, wrapper: :with_label, label: t('simple_form.labels.defaults.confirm_password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.confirm_password'), :autocomplete => 'off' }
+    = f.input :confirm_password, as: :string, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: t('simple_form.labels.defaults.password')), :autocomplete => 'off' }
+
+  = f.input :website, as: :url, wrapper: :with_label, label: t('simple_form.labels.defaults.honeypot', label: 'Website'), required: false, input_html: { 'aria-label' => t('simple_form.labels.defaults.honeypot', label: 'Website'), :autocomplete => 'off' }
 
   - if approved_registrations? && !@invite.present?
     .fields-group
diff --git a/app/views/shared/_error_messages.html.haml b/app/views/shared/_error_messages.html.haml
index 28becd6c4..4916bd424 100644
--- a/app/views/shared/_error_messages.html.haml
+++ b/app/views/shared/_error_messages.html.haml
@@ -1,3 +1,6 @@
 - if object.errors.any?
   .flash-message.alert#error_explanation
     %strong= t('generic.validation_errors', count: object.errors.count)
+- object.errors[:base].each do |error|
+  .flash-message.alert
+    %strong= error
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 263ffcdc7..59f561aa3 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -751,6 +751,7 @@ en:
       functional: Your account is fully operational.
       pending: Your application is pending review by our staff. This may take some time. You will receive an e-mail if your application is approved.
       redirecting_to: Your account is inactive because it is currently redirecting to %{acct}.
+    too_fast: Form submitted too fast, try again.
     trouble_logging_in: Trouble logging in?
     use_security_key: Use security key
   authorize_follow:
diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml
index f982d50f9..cea777550 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -126,6 +126,7 @@ en:
         expires_in: Expire after
         fields: Profile metadata
         header: Header
+        honeypot: "%{label} (do not fill in)"
         inbox_url: URL of the relay inbox
         irreversible: Drop instead of hide
         locale: Interface language
diff --git a/spec/controllers/auth/registrations_controller_spec.rb b/spec/controllers/auth/registrations_controller_spec.rb
index bef822763..c701a3b8b 100644
--- a/spec/controllers/auth/registrations_controller_spec.rb
+++ b/spec/controllers/auth/registrations_controller_spec.rb
@@ -82,6 +82,10 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
   describe 'POST #create' do
     let(:accept_language) { Rails.application.config.i18n.available_locales.sample.to_s }
 
+    before do
+      session[:registration_form_time] = 5.seconds.ago
+    end
+
     around do |example|
       current_locale = I18n.locale
       example.run
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 78563ee94..2217ad272 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -342,7 +342,7 @@ RSpec.describe FeedManager do
 
     context 'when replies policy is set to no replies' do
       before do
-        list.replies_policy = :no_replies
+        list.replies_policy = :none
       end
 
       it 'pushes statuses that are not replies' do
@@ -365,7 +365,7 @@ RSpec.describe FeedManager do
 
     context 'when replies policy is set to list-only replies' do
       before do
-        list.replies_policy = :list_replies
+        list.replies_policy = :list
       end
 
       it 'pushes statuses that are not replies' do
@@ -394,7 +394,7 @@ RSpec.describe FeedManager do
 
     context 'when replies policy is set to any reply' do
       before do
-        list.replies_policy = :all_replies
+        list.replies_policy = :followed
       end
 
       it 'pushes statuses that are not replies' do