about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorClaire <claire.github-309c@sitedethib.com>2023-04-22 10:06:11 +0200
committerClaire <claire.github-309c@sitedethib.com>2023-04-22 10:06:11 +0200
commitabfdafef1ededdb87f018414edd6b25fa9a70525 (patch)
tree7a9855d79d125333a6b1307215b73dd507475320 /app
parentf30c5e7f15f967019245d2c78f3c2e89800eb838 (diff)
parent4db8230194258a9a1c3d17d7261608515f3f2067 (diff)
Merge branch 'main' into glitch-soc/merge-upstream
Conflicts:
- `app/controllers/auth/setup_controller.rb`:
  Upstream removed a method close to a glitch-soc theming-related method.
  Removed the method like upstream did.
Diffstat (limited to 'app')
-rw-r--r--app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb72
-rw-r--r--app/controllers/api/v1/admin/trends/links_controller.rb31
-rw-r--r--app/controllers/api/v1/admin/trends/statuses_controller.rb31
-rw-r--r--app/controllers/api/v1/admin/trends/tags_controller.rb23
-rw-r--r--app/controllers/auth/setup_controller.rb19
-rw-r--r--app/helpers/application_helper.rb4
-rw-r--r--app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap6
-rw-r--r--app/javascript/mastodon/components/animated_number.jsx76
-rw-r--r--app/javascript/mastodon/components/animated_number.tsx58
-rw-r--r--app/javascript/mastodon/components/avatar_overlay.jsx51
-rw-r--r--app/javascript/mastodon/components/avatar_overlay.tsx51
-rw-r--r--app/javascript/mastodon/components/gifv.jsx76
-rw-r--r--app/javascript/mastodon/components/gifv.tsx68
-rw-r--r--app/javascript/mastodon/components/status.jsx2
-rw-r--r--app/javascript/mastodon/components/status_content.jsx6
-rw-r--r--app/javascript/mastodon/features/direct_timeline/components/conversation.jsx2
-rw-r--r--app/javascript/mastodon/features/status/index.jsx10
-rw-r--r--app/javascript/mastodon/features/ui/components/filter_modal.jsx2
-rw-r--r--app/javascript/mastodon/features/ui/components/focal_point_modal.jsx2
-rw-r--r--app/javascript/mastodon/features/ui/components/media_modal.jsx2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json21
-rw-r--r--app/javascript/mastodon/locales/en.json4
-rw-r--r--app/javascript/mastodon/locales/es-MX.json1
-rw-r--r--app/javascript/packs/public.jsx50
-rw-r--r--app/javascript/styles/mastodon/forms.scss86
-rw-r--r--app/mailers/notification_mailer.rb10
-rw-r--r--app/models/account_filter.rb2
-rw-r--r--app/models/preview_card_provider.rb1
-rw-r--r--app/serializers/rest/admin/trends/link_serializer.rb9
-rw-r--r--app/serializers/rest/admin/trends/links/preview_card_provider_serializer.rb10
-rw-r--r--app/serializers/rest/admin/trends/status_serializer.rb9
-rw-r--r--app/services/notify_service.rb1
-rw-r--r--app/views/auth/registrations/new.html.haml8
-rw-r--r--app/views/auth/registrations/rules.html.haml2
-rw-r--r--app/views/auth/setup/show.html.haml26
-rw-r--r--app/views/auth/shared/_links.html.haml2
-rw-r--r--app/views/auth/shared/_progress.html.haml25
37 files changed, 583 insertions, 276 deletions
diff --git a/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb b/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb
new file mode 100644
index 000000000..5d9fcc82c
--- /dev/null
+++ b/app/controllers/api/v1/admin/trends/links/preview_card_providers_controller.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+class Api::V1::Admin::Trends::Links::PreviewCardProvidersController < Api::BaseController
+  include Authorization
+
+  LIMIT = 100
+
+  before_action -> { authorize_if_got_token! :'admin:read' }, only: :index
+  before_action -> { authorize_if_got_token! :'admin:write' }, except: :index
+  before_action :set_providers, only: :index
+
+  after_action :verify_authorized
+  after_action :insert_pagination_headers, only: :index
+
+  PAGINATION_PARAMS = %i(limit).freeze
+
+  def index
+    authorize :preview_card_provider, :index?
+
+    render json: @providers, each_serializer: REST::Admin::Trends::Links::PreviewCardProviderSerializer
+  end
+
+  def approve
+    authorize :preview_card_provider, :review?
+
+    provider = PreviewCardProvider.find(params[:id])
+    provider.update(trendable: true, reviewed_at: Time.now.utc)
+    render json: provider, serializer: REST::Admin::Trends::Links::PreviewCardProviderSerializer
+  end
+
+  def reject
+    authorize :preview_card_provider, :review?
+
+    provider = PreviewCardProvider.find(params[:id])
+    provider.update(trendable: false, reviewed_at: Time.now.utc)
+    render json: provider, serializer: REST::Admin::Trends::Links::PreviewCardProviderSerializer
+  end
+
+  private
+
+  def set_providers
+    @providers = PreviewCardProvider.all.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id))
+  end
+
+  def insert_pagination_headers
+    set_pagination_headers(next_path, prev_path)
+  end
+
+  def next_path
+    api_v1_admin_trends_links_preview_card_providers_url(pagination_params(max_id: pagination_max_id)) if records_continue?
+  end
+
+  def prev_path
+    api_v1_admin_trends_links_preview_card_providers_url(pagination_params(min_id: pagination_since_id)) unless @providers.empty?
+  end
+
+  def pagination_max_id
+    @providers.last.id
+  end
+
+  def pagination_since_id
+    @providers.first.id
+  end
+
+  def records_continue?
+    @providers.size == limit_param(LIMIT)
+  end
+
+  def pagination_params(core_params)
+    params.slice(*PAGINATION_PARAMS).permit(*PAGINATION_PARAMS).merge(core_params)
+  end
+end
diff --git a/app/controllers/api/v1/admin/trends/links_controller.rb b/app/controllers/api/v1/admin/trends/links_controller.rb
index cc6388980..7f4ca4828 100644
--- a/app/controllers/api/v1/admin/trends/links_controller.rb
+++ b/app/controllers/api/v1/admin/trends/links_controller.rb
@@ -1,7 +1,36 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::Trends::LinksController < Api::V1::Trends::LinksController
-  before_action -> { authorize_if_got_token! :'admin:read' }
+  include Authorization
+
+  before_action -> { authorize_if_got_token! :'admin:read' }, only: :index
+  before_action -> { authorize_if_got_token! :'admin:write' }, except: :index
+
+  after_action :verify_authorized, except: :index
+
+  def index
+    if current_user&.can?(:manage_taxonomies)
+      render json: @links, each_serializer: REST::Admin::Trends::LinkSerializer
+    else
+      super
+    end
+  end
+
+  def approve
+    authorize :preview_card, :review?
+
+    link = PreviewCard.find(params[:id])
+    link.update(trendable: true)
+    render json: link, serializer: REST::Admin::Trends::LinkSerializer
+  end
+
+  def reject
+    authorize :preview_card, :review?
+
+    link = PreviewCard.find(params[:id])
+    link.update(trendable: false)
+    render json: link, serializer: REST::Admin::Trends::LinkSerializer
+  end
 
   private
 
diff --git a/app/controllers/api/v1/admin/trends/statuses_controller.rb b/app/controllers/api/v1/admin/trends/statuses_controller.rb
index c39f77363..34b6580df 100644
--- a/app/controllers/api/v1/admin/trends/statuses_controller.rb
+++ b/app/controllers/api/v1/admin/trends/statuses_controller.rb
@@ -1,7 +1,36 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::Trends::StatusesController < Api::V1::Trends::StatusesController
-  before_action -> { authorize_if_got_token! :'admin:read' }
+  include Authorization
+
+  before_action -> { authorize_if_got_token! :'admin:read' }, only: :index
+  before_action -> { authorize_if_got_token! :'admin:write' }, except: :index
+
+  after_action :verify_authorized, except: :index
+
+  def index
+    if current_user&.can?(:manage_taxonomies)
+      render json: @statuses, each_serializer: REST::Admin::Trends::StatusSerializer
+    else
+      super
+    end
+  end
+
+  def approve
+    authorize [:admin, :status], :review?
+
+    status = Status.find(params[:id])
+    status.update(trendable: true)
+    render json: status, serializer: REST::Admin::Trends::StatusSerializer
+  end
+
+  def reject
+    authorize [:admin, :status], :review?
+
+    status = Status.find(params[:id])
+    status.update(trendable: false)
+    render json: status, serializer: REST::Admin::Trends::StatusSerializer
+  end
 
   private
 
diff --git a/app/controllers/api/v1/admin/trends/tags_controller.rb b/app/controllers/api/v1/admin/trends/tags_controller.rb
index e77df3021..2eeea9522 100644
--- a/app/controllers/api/v1/admin/trends/tags_controller.rb
+++ b/app/controllers/api/v1/admin/trends/tags_controller.rb
@@ -1,7 +1,12 @@
 # frozen_string_literal: true
 
 class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
-  before_action -> { authorize_if_got_token! :'admin:read' }
+  include Authorization
+
+  before_action -> { authorize_if_got_token! :'admin:read' }, only: :index
+  before_action -> { authorize_if_got_token! :'admin:write' }, except: :index
+
+  after_action :verify_authorized, except: :index
 
   def index
     if current_user&.can?(:manage_taxonomies)
@@ -11,6 +16,22 @@ class Api::V1::Admin::Trends::TagsController < Api::V1::Trends::TagsController
     end
   end
 
+  def approve
+    authorize :tag, :review?
+
+    tag = Tag.find(params[:id])
+    tag.update(trendable: true, reviewed_at: Time.now.utc)
+    render json: tag, serializer: REST::Admin::TagSerializer
+  end
+
+  def reject
+    authorize :tag, :review?
+
+    tag = Tag.find(params[:id])
+    tag.update(trendable: false, reviewed_at: Time.now.utc)
+    render json: tag, serializer: REST::Admin::TagSerializer
+  end
+
   private
 
   def enabled?
diff --git a/app/controllers/auth/setup_controller.rb b/app/controllers/auth/setup_controller.rb
index db5a866f2..3ee35d141 100644
--- a/app/controllers/auth/setup_controller.rb
+++ b/app/controllers/auth/setup_controller.rb
@@ -11,15 +11,7 @@ class Auth::SetupController < ApplicationController
 
   skip_before_action :require_functional!
 
-  def show
-    flash.now[:notice] = begin
-      if @user.pending?
-        I18n.t('devise.registrations.signed_up_but_pending')
-      else
-        I18n.t('devise.registrations.signed_up_but_unconfirmed')
-      end
-    end
-  end
+  def show; end
 
   def update
     # This allows updating the e-mail without entering a password as is required
@@ -27,14 +19,13 @@ class Auth::SetupController < ApplicationController
     # that were not confirmed yet
 
     if @user.update(user_params)
-      redirect_to auth_setup_path, notice: I18n.t('devise.confirmations.send_instructions')
+      @user.resend_confirmation_instructions unless @user.confirmed?
+      redirect_to auth_setup_path, notice: I18n.t('auth.setup.new_confirmation_instructions_sent')
     else
       render :show
     end
   end
 
-  helper_method :missing_email?
-
   private
 
   def require_unconfirmed_or_pending!
@@ -53,10 +44,6 @@ class Auth::SetupController < ApplicationController
     params.require(:user).permit(:email)
   end
 
-  def missing_email?
-    truthy_param?(:missing_email)
-  end
-
   def set_pack
     use_pack 'auth'
   end
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 2cac2de59..1228ce36c 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -117,6 +117,10 @@ module ApplicationHelper
     content_tag(:i, nil, attributes.merge(class: class_names.join(' ')))
   end
 
+  def check_icon
+    content_tag(:svg, tag(:path, 'fill-rule': 'evenodd', 'clip-rule': 'evenodd', d: 'M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z'), xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 20 20', fill: 'currentColor')
+  end
+
   def visibility_icon(status)
     if status.public_visibility?
       fa_icon('globe', title: I18n.t('statuses.visibilities.public'))
diff --git a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap
index f8385357a..fbd44ecc5 100644
--- a/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap
+++ b/app/javascript/mastodon/components/__tests__/__snapshots__/avatar_overlay-test.jsx.snap
@@ -3,6 +3,8 @@
 exports[`<AvatarOverlay renders a overlay avatar 1`] = `
 <div
   className="account__avatar-overlay"
+  onMouseEnter={[Function]}
+  onMouseLeave={[Function]}
   style={
     {
       "height": 46,
@@ -15,8 +17,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
   >
     <div
       className="account__avatar"
-      onMouseEnter={[Function]}
-      onMouseLeave={[Function]}
       style={
         {
           "height": "36px",
@@ -35,8 +35,6 @@ exports[`<AvatarOverlay renders a overlay avatar 1`] = `
   >
     <div
       className="account__avatar"
-      onMouseEnter={[Function]}
-      onMouseLeave={[Function]}
       style={
         {
           "height": "24px",
diff --git a/app/javascript/mastodon/components/animated_number.jsx b/app/javascript/mastodon/components/animated_number.jsx
deleted file mode 100644
index ce688f04f..000000000
--- a/app/javascript/mastodon/components/animated_number.jsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ShortNumber from 'mastodon/components/short_number';
-import TransitionMotion from 'react-motion/lib/TransitionMotion';
-import spring from 'react-motion/lib/spring';
-import { reduceMotion } from 'mastodon/initial_state';
-
-const obfuscatedCount = count => {
-  if (count < 0) {
-    return 0;
-  } else if (count <= 1) {
-    return count;
-  } else {
-    return '1+';
-  }
-};
-
-export default class AnimatedNumber extends React.PureComponent {
-
-  static propTypes = {
-    value: PropTypes.number.isRequired,
-    obfuscate: PropTypes.bool,
-  };
-
-  state = {
-    direction: 1,
-  };
-
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.value > this.props.value) {
-      this.setState({ direction: 1 });
-    } else if (nextProps.value < this.props.value) {
-      this.setState({ direction: -1 });
-    }
-  }
-
-  willEnter = () => {
-    const { direction } = this.state;
-
-    return { y: -1 * direction };
-  };
-
-  willLeave = () => {
-    const { direction } = this.state;
-
-    return { y: spring(1 * direction, { damping: 35, stiffness: 400 }) };
-  };
-
-  render () {
-    const { value, obfuscate } = this.props;
-    const { direction } = this.state;
-
-    if (reduceMotion) {
-      return obfuscate ? obfuscatedCount(value) : <ShortNumber value={value} />;
-    }
-
-    const styles = [{
-      key: `${value}`,
-      data: value,
-      style: { y: spring(0, { damping: 35, stiffness: 400 }) },
-    }];
-
-    return (
-      <TransitionMotion styles={styles} willEnter={this.willEnter} willLeave={this.willLeave}>
-        {items => (
-          <span className='animated-number'>
-            {items.map(({ key, data, style }) => (
-              <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
-            ))}
-          </span>
-        )}
-      </TransitionMotion>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/components/animated_number.tsx b/app/javascript/mastodon/components/animated_number.tsx
new file mode 100644
index 000000000..1673ff41b
--- /dev/null
+++ b/app/javascript/mastodon/components/animated_number.tsx
@@ -0,0 +1,58 @@
+import React, { useCallback, useState } from 'react';
+import ShortNumber from './short_number';
+import { TransitionMotion, spring } from 'react-motion';
+import { reduceMotion } from '../initial_state';
+
+const obfuscatedCount = (count: number) => {
+  if (count < 0) {
+    return 0;
+  } else if (count <= 1) {
+    return count;
+  } else {
+    return '1+';
+  }
+};
+
+type Props = {
+  value: number;
+  obfuscate?: boolean;
+}
+export const AnimatedNumber: React.FC<Props> = ({
+  value,
+  obfuscate,
+})=> {
+  const [previousValue, setPreviousValue] = useState(value);
+  const [direction, setDirection] = useState<1|-1>(1);
+
+  if (previousValue !== value) {
+    setPreviousValue(value);
+    setDirection(value > previousValue ? 1 : -1);
+  }
+
+  const willEnter = useCallback(() => ({ y: -1 * direction }), [direction]);
+  const willLeave = useCallback(() => ({ y: spring(1 * direction, { damping: 35, stiffness: 400 }) }), [direction]);
+
+  if (reduceMotion) {
+    return obfuscate ? <>{obfuscatedCount(value)}</> : <ShortNumber value={value} />;
+  }
+
+  const styles = [{
+    key: `${value}`,
+    data: value,
+    style: { y: spring(0, { damping: 35, stiffness: 400 }) },
+  }];
+
+  return (
+    <TransitionMotion styles={styles} willEnter={willEnter} willLeave={willLeave}>
+      {items => (
+        <span className='animated-number'>
+          {items.map(({ key, data, style }) => (
+            <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <ShortNumber value={data} />}</span>
+          ))}
+        </span>
+      )}
+    </TransitionMotion>
+  );
+};
+
+export default AnimatedNumber;
diff --git a/app/javascript/mastodon/components/avatar_overlay.jsx b/app/javascript/mastodon/components/avatar_overlay.jsx
deleted file mode 100644
index 034e8ba56..000000000
--- a/app/javascript/mastodon/components/avatar_overlay.jsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-import ImmutablePropTypes from 'react-immutable-proptypes';
-import { autoPlayGif } from '../initial_state';
-import Avatar from './avatar';
-
-export default class AvatarOverlay extends React.PureComponent {
-
-  static propTypes = {
-    account: ImmutablePropTypes.map.isRequired,
-    friend: ImmutablePropTypes.map.isRequired,
-    animate: PropTypes.bool,
-    size: PropTypes.number,
-    baseSize: PropTypes.number,
-    overlaySize: PropTypes.number,
-  };
-
-  static defaultProps = {
-    animate: autoPlayGif,
-    size: 46,
-    baseSize: 36,
-    overlaySize: 24,
-  };
-
-  state = {
-    hovering: false,
-  };
-
-  handleMouseEnter = () => {
-    if (this.props.animate) return;
-    this.setState({ hovering: true });
-  };
-
-  handleMouseLeave = () => {
-    if (this.props.animate) return;
-    this.setState({ hovering: false });
-  };
-
-  render() {
-    const { account, friend, animate, size, baseSize, overlaySize } = this.props;
-    const { hovering } = this.state;
-
-    return (
-      <div className='account__avatar-overlay' style={{ width: size, height: size }}>
-        <div className='account__avatar-overlay-base'><Avatar animate={hovering || animate} account={account} size={baseSize} /></div>
-        <div className='account__avatar-overlay-overlay'><Avatar animate={hovering || animate} account={friend} size={overlaySize} /></div>
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/components/avatar_overlay.tsx b/app/javascript/mastodon/components/avatar_overlay.tsx
new file mode 100644
index 000000000..5c65a928c
--- /dev/null
+++ b/app/javascript/mastodon/components/avatar_overlay.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import type { Account } from '../../types/resources';
+import { useHovering } from '../../hooks/useHovering';
+import { autoPlayGif } from '../initial_state';
+
+type Props = {
+  account: Account;
+  friend: Account;
+  size?: number;
+  baseSize?: number;
+  overlaySize?: number;
+};
+
+export const AvatarOverlay: React.FC<Props> = ({
+  account,
+  friend,
+  size = 46,
+  baseSize = 36,
+  overlaySize = 24,
+}) => {
+  const { hovering, handleMouseEnter, handleMouseLeave } = useHovering(autoPlayGif);
+  const accountSrc = hovering ? account?.get('avatar') : account?.get('avatar_static');
+  const friendSrc = hovering ? friend?.get('avatar') : friend?.get('avatar_static');
+
+  return (
+    <div
+      className='account__avatar-overlay' style={{ width: size, height: size }}
+      onMouseEnter={handleMouseEnter}
+      onMouseLeave={handleMouseLeave}
+    >
+      <div className='account__avatar-overlay-base'>
+        <div
+          className='account__avatar'
+          style={{ width: `${baseSize}px`, height: `${baseSize}px` }}
+        >
+          {accountSrc && <img src={accountSrc} alt={account?.get('acct')} />}
+        </div>
+      </div>
+      <div className='account__avatar-overlay-overlay'>
+        <div
+          className='account__avatar'
+          style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }}
+        >
+          {friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />}
+        </div>
+      </div>
+    </div>
+  );
+};
+
+export default AvatarOverlay;
diff --git a/app/javascript/mastodon/components/gifv.jsx b/app/javascript/mastodon/components/gifv.jsx
deleted file mode 100644
index 1ce7e7c29..000000000
--- a/app/javascript/mastodon/components/gifv.jsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class GIFV extends React.PureComponent {
-
-  static propTypes = {
-    src: PropTypes.string.isRequired,
-    alt: PropTypes.string,
-    lang: PropTypes.string,
-    width: PropTypes.number,
-    height: PropTypes.number,
-    onClick: PropTypes.func,
-  };
-
-  state = {
-    loading: true,
-  };
-
-  handleLoadedData = () => {
-    this.setState({ loading: false });
-  };
-
-  componentWillReceiveProps (nextProps) {
-    if (nextProps.src !== this.props.src) {
-      this.setState({ loading: true });
-    }
-  }
-
-  handleClick = e => {
-    const { onClick } = this.props;
-
-    if (onClick) {
-      e.stopPropagation();
-      onClick();
-    }
-  };
-
-  render () {
-    const { src, width, height, alt, lang } = this.props;
-    const { loading } = this.state;
-
-    return (
-      <div className='gifv' style={{ position: 'relative' }}>
-        {loading && (
-          <canvas
-            width={width}
-            height={height}
-            role='button'
-            tabIndex={0}
-            aria-label={alt}
-            title={alt}
-            lang={lang}
-            onClick={this.handleClick}
-          />
-        )}
-
-        <video
-          src={src}
-          role='button'
-          tabIndex={0}
-          aria-label={alt}
-          title={alt}
-          lang={lang}
-          muted
-          loop
-          autoPlay
-          playsInline
-          onClick={this.handleClick}
-          onLoadedData={this.handleLoadedData}
-          style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
-        />
-      </div>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/components/gifv.tsx b/app/javascript/mastodon/components/gifv.tsx
new file mode 100644
index 000000000..8968170c5
--- /dev/null
+++ b/app/javascript/mastodon/components/gifv.tsx
@@ -0,0 +1,68 @@
+import React, { useCallback, useState } from 'react';
+
+type Props = {
+  src: string;
+  key: string;
+  alt?: string;
+  lang?: string;
+  width: number;
+  height: number;
+  onClick?: () => void;
+}
+
+export const GIFV: React.FC<Props> = ({
+  src,
+  alt,
+  lang,
+  width,
+  height,
+  onClick,
+})=> {
+  const [loading, setLoading] = useState(true);
+
+  const handleLoadedData: React.ReactEventHandler<HTMLVideoElement> = useCallback(() => {
+    setLoading(false);
+  }, [setLoading]);
+
+  const handleClick: React.MouseEventHandler = useCallback((e) => {
+    if (onClick) {
+      e.stopPropagation();
+      onClick();
+    }
+  }, [onClick]);
+
+  return (
+    <div className='gifv' style={{ position: 'relative' }}>
+      {loading && (
+        <canvas
+          width={width}
+          height={height}
+          role='button'
+          tabIndex={0}
+          aria-label={alt}
+          title={alt}
+          lang={lang}
+          onClick={handleClick}
+        />
+      )}
+
+      <video
+        src={src}
+        role='button'
+        tabIndex={0}
+        aria-label={alt}
+        title={alt}
+        lang={lang}
+        muted
+        loop
+        autoPlay
+        playsInline
+        onClick={handleClick}
+        onLoadedData={handleLoadedData}
+        style={{ position: loading ? 'absolute' : 'static', top: 0, left: 0 }}
+      />
+    </div>
+  );
+};
+
+export default GIFV;
diff --git a/app/javascript/mastodon/components/status.jsx b/app/javascript/mastodon/components/status.jsx
index 923dc892d..60a77a39c 100644
--- a/app/javascript/mastodon/components/status.jsx
+++ b/app/javascript/mastodon/components/status.jsx
@@ -541,7 +541,7 @@ class Status extends ImmutablePureComponent {
               expanded={!status.get('hidden')}
               onExpandedToggle={this.handleExpandedToggle}
               onTranslate={this.handleTranslate}
-              collapsable
+              collapsible
               onCollapsedToggle={this.handleCollapsedToggle}
             />
 
diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx
index fb953b9dd..60f820bc5 100644
--- a/app/javascript/mastodon/components/status_content.jsx
+++ b/app/javascript/mastodon/components/status_content.jsx
@@ -65,7 +65,7 @@ class StatusContent extends React.PureComponent {
     onExpandedToggle: PropTypes.func,
     onTranslate: PropTypes.func,
     onClick: PropTypes.func,
-    collapsable: PropTypes.bool,
+    collapsible: PropTypes.bool,
     onCollapsedToggle: PropTypes.func,
     languages: ImmutablePropTypes.map,
     intl: PropTypes.object,
@@ -112,10 +112,10 @@ class StatusContent extends React.PureComponent {
     }
 
     if (status.get('collapsed', null) === null && onCollapsedToggle) {
-      const { collapsable, onClick } = this.props;
+      const { collapsible, onClick } = this.props;
 
       const collapsed =
-          collapsable
+          collapsible
           && onClick
           && node.clientHeight > MAX_HEIGHT
           && status.get('spoiler_text').length === 0;
diff --git a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
index d0dbffe65..11f2790bf 100644
--- a/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
+++ b/app/javascript/mastodon/features/direct_timeline/components/conversation.jsx
@@ -165,7 +165,7 @@ class Conversation extends ImmutablePureComponent {
               onClick={this.handleClick}
               expanded={!lastStatus.get('hidden')}
               onExpandedToggle={this.handleShowMore}
-              collapsable
+              collapsible
             />
 
             {lastStatus.get('media_attachments').size > 0 && (
diff --git a/app/javascript/mastodon/features/status/index.jsx b/app/javascript/mastodon/features/status/index.jsx
index b547741f7..900b19c31 100644
--- a/app/javascript/mastodon/features/status/index.jsx
+++ b/app/javascript/mastodon/features/status/index.jsx
@@ -69,6 +69,7 @@ const messages = defineMessages({
   redraftMessage: { id: 'confirmations.redraft.message', defaultMessage: 'Are you sure you want to delete this status and re-draft it? Favourites and boosts will be lost, and replies to the original post will be orphaned.' },
   revealAll: { id: 'status.show_more_all', defaultMessage: 'Show more for all' },
   hideAll: { id: 'status.show_less_all', defaultMessage: 'Show less for all' },
+  statusTitleWithAttachments: { id: 'status.title.with_attachments', defaultMessage: '{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}' },
   detailedStatus: { id: 'status.detailed_status', defaultMessage: 'Detailed conversation view' },
   replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
   replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
@@ -166,13 +167,14 @@ const truncate = (str, num) => {
   }
 };
 
-const titleFromStatus = status => {
+const titleFromStatus = (intl, status) => {
   const displayName = status.getIn(['account', 'display_name']);
   const username = status.getIn(['account', 'username']);
-  const prefix = displayName.trim().length === 0 ? username : displayName;
+  const user = displayName.trim().length === 0 ? username : displayName;
   const text = status.get('search_index');
+  const attachmentCount = status.get('media_attachments').size;
 
-  return `${prefix}: "${truncate(text, 30)}"`;
+  return text ? `${user}: "${truncate(text, 30)}"` : intl.formatMessage(messages.statusTitleWithAttachments, { user, attachmentCount });
 };
 
 class Status extends ImmutablePureComponent {
@@ -670,7 +672,7 @@ class Status extends ImmutablePureComponent {
         </ScrollContainer>
 
         <Helmet>
-          <title>{titleFromStatus(status)}</title>
+          <title>{titleFromStatus(intl, status)}</title>
           <meta name='robots' content={(isLocal && isIndexable) ? 'all' : 'noindex'} />
         </Helmet>
       </Column>
diff --git a/app/javascript/mastodon/features/ui/components/filter_modal.jsx b/app/javascript/mastodon/features/ui/components/filter_modal.jsx
index 32ebaf7b7..8d77fb3df 100644
--- a/app/javascript/mastodon/features/ui/components/filter_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/filter_modal.jsx
@@ -131,4 +131,4 @@ class FilterModal extends ImmutablePureComponent {
 
 }
 
-export default connect(injectIntl(FilterModal));
+export default connect()(injectIntl(FilterModal));
diff --git a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
index 11c4c5237..2a1e4c8bb 100644
--- a/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/focal_point_modal.jsx
@@ -383,7 +383,7 @@ class FocalPointModal extends ImmutablePureComponent {
             {focals && (
               <div className={classNames('focal-point', { dragging })} ref={this.setRef} onMouseDown={this.handleMouseDown} onTouchStart={this.handleTouchStart}>
                 {media.get('type') === 'image' && <ImageLoader src={media.get('url')} width={width} height={height} alt='' />}
-                {media.get('type') === 'gifv' && <GIFV src={media.get('url')} width={width} height={height} />}
+                {media.get('type') === 'gifv' && <GIFV src={media.get('url')} key={media.get('url')} width={width} height={height} />}
 
                 <div className='focal-point__preview'>
                   <strong><FormattedMessage id='upload_modal.preview_label' defaultMessage='Preview ({ratio})' values={{ ratio: '16:9' }} /></strong>
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
index 52bd75453..ec6ddc0e1 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx
@@ -186,7 +186,7 @@ class MediaModal extends ImmutablePureComponent {
             src={image.get('url')}
             width={width}
             height={height}
-            key={image.get('preview_url')}
+            key={image.get('url')}
             alt={image.get('description')}
             lang={language}
             onClick={this.toggleNavigation}
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 1351945eb..6d6683808 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -3733,6 +3733,10 @@
         "id": "status.show_less_all"
       },
       {
+        "defaultMessage": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",
+        "id": "status.title.with_attachments"
+      },
+      {
         "defaultMessage": "Detailed conversation view",
         "id": "status.detailed_status"
       },
@@ -4354,5 +4358,22 @@
       }
     ],
     "path": "app/javascript/mastodon/features/video/index.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "That username is taken. Try another",
+        "id": "username.taken"
+      },
+      {
+        "defaultMessage": "Password confirmation exceeds the maximum password length",
+        "id": "password_confirmation.exceeds_maxlength"
+      },
+      {
+        "defaultMessage": "Password confirmation does not match",
+        "id": "password_confirmation.mismatching"
+      }
+    ],
+    "path": "app/javascript/packs/public.json"
   }
 ]
\ No newline at end of file
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index ae2d5a999..31fa3ca3a 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -443,6 +443,8 @@
   "notifications_permission_banner.enable": "Enable desktop notifications",
   "notifications_permission_banner.how_to_control": "To receive notifications when Mastodon isn't open, enable desktop notifications. You can control precisely which types of interactions generate desktop notifications through the {icon} button above once they're enabled.",
   "notifications_permission_banner.title": "Never miss a thing",
+  "password_confirmation.exceeds_maxlength": "Password confirmation exceeds the maximum password length",
+  "password_confirmation.mismatching": "Password confirmation does not match",
   "picture_in_picture.restore": "Put it back",
   "poll.closed": "Closed",
   "poll.refresh": "Refresh",
@@ -598,6 +600,7 @@
   "status.show_more": "Show more",
   "status.show_more_all": "Show more for all",
   "status.show_original": "Show original",
+  "status.title.with_attachments": "{user} posted {attachmentCount, plural, one {an attachment} other {{attachmentCount} attachments}}",
   "status.translate": "Translate",
   "status.translated_from_with": "Translated from {lang} using {provider}",
   "status.uncached_media_warning": "Not available",
@@ -650,6 +653,7 @@
   "upload_modal.preview_label": "Preview ({ratio})",
   "upload_progress.label": "Uploading...",
   "upload_progress.processing": "Processing…",
+  "username.taken": "That username is taken. Try another",
   "video.close": "Close video",
   "video.download": "Download file",
   "video.exit_fullscreen": "Exit full screen",
diff --git a/app/javascript/mastodon/locales/es-MX.json b/app/javascript/mastodon/locales/es-MX.json
index 0085563d2..886976823 100644
--- a/app/javascript/mastodon/locales/es-MX.json
+++ b/app/javascript/mastodon/locales/es-MX.json
@@ -597,6 +597,7 @@
   "status.show_more": "Mostrar más",
   "status.show_more_all": "Mostrar más para todo",
   "status.show_original": "Mostrar original",
+  "status.title.with_attachments": "{user} publicó {attachmentCount, plural, one {un archivo adjunto} other {{attachmentCount} archivos adjuntos}}",
   "status.translate": "Traducir",
   "status.translated_from_with": "Traducido del {lang} usando {provider}",
   "status.uncached_media_warning": "No disponible",
diff --git a/app/javascript/packs/public.jsx b/app/javascript/packs/public.jsx
index ad6bf237f..606ddc3bf 100644
--- a/app/javascript/packs/public.jsx
+++ b/app/javascript/packs/public.jsx
@@ -4,6 +4,15 @@ import ready from '../mastodon/ready';
 import { start } from '../mastodon/common';
 import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
 import 'cocoon-js-vanilla';
+import axios from 'axios';
+import { throttle } from 'lodash';
+import { defineMessages } from 'react-intl';
+
+const messages = defineMessages({
+  usernameTaken: { id: 'username.taken', defaultMessage: 'That username is taken. Try another' },
+  passwordExceedsLength: { id: 'password_confirmation.exceeds_maxlength', defaultMessage: 'Password confirmation exceeds the maximum password length' },
+  passwordDoesNotMatch: { id: 'password_confirmation.mismatching', defaultMessage: 'Password confirmation does not match' },
+});
 
 start();
 
@@ -13,7 +22,7 @@ function main() {
   const { delegate } = require('@rails/ujs');
   const emojify = require('../mastodon/features/emoji/emoji').default;
   const { getLocale } = require('../mastodon/locales');
-  const { messages } = getLocale();
+  const { localeData } = getLocale();
   const React = require('react');
   const ReactDOM = require('react-dom');
   const { createBrowserHistory } = require('history');
@@ -58,6 +67,11 @@ function main() {
       hour12: false,
     });
 
+    const formatMessage = ({ id, defaultMessage }, values) => {
+      const messageFormat = new IntlMessageFormat(localeData[id] || defaultMessage, locale);
+      return messageFormat.format(values);
+    };
+
     [].forEach.call(document.querySelectorAll('.emojify'), (content) => {
       content.innerHTML = emojify(content.innerHTML);
     });
@@ -77,7 +91,7 @@ function main() {
         date.getMonth() === today.getMonth() &&
         date.getFullYear() === today.getFullYear();
     };
-    const todayFormat = new IntlMessageFormat(messages['relative_format.today'] || 'Today at {time}', locale);
+    const todayFormat = new IntlMessageFormat(localeData['relative_format.today'] || 'Today at {time}', locale);
 
     [].forEach.call(document.querySelectorAll('time.relative-formatted'), (content) => {
       const datetime = new Date(content.getAttribute('datetime'));
@@ -103,7 +117,7 @@ function main() {
       const timeGiven = content.getAttribute('datetime').includes('T');
       content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime);
       content.textContent = timeAgoString({
-        formatMessage: ({ id, defaultMessage }, values) => (new IntlMessageFormat(messages[id] || defaultMessage, locale)).format(values),
+        formatMessage,
         formatDate: (date, options) => (new Intl.DateTimeFormat(locale, options)).format(date),
       }, datetime, now, now.getFullYear(), timeGiven);
     });
@@ -133,17 +147,19 @@ function main() {
       scrollToDetailedStatus();
     }
 
-    delegate(document, '#registration_user_password_confirmation,#registration_user_password', 'input', () => {
-      const password = document.getElementById('registration_user_password');
-      const confirmation = document.getElementById('registration_user_password_confirmation');
-      if (confirmation.value && confirmation.value.length > password.maxLength) {
-        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
-      } else if (password.value && password.value !== confirmation.value) {
-        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
+    delegate(document, '#user_account_attributes_username', 'input', throttle(() => {
+      const username = document.getElementById('user_account_attributes_username');
+
+      if (username.value && username.value.length > 0) {
+        axios.get('/api/v1/accounts/lookup', { params: { acct: username.value } }).then(() => {
+          username.setCustomValidity(formatMessage(messages.usernameTaken));
+        }).catch(() => {
+          username.setCustomValidity('');
+        });
       } else {
-        confirmation.setCustomValidity('');
+        username.setCustomValidity('');
       }
-    });
+    }, 500, { leading: false, trailing: true }));
 
     delegate(document, '#user_password,#user_password_confirmation', 'input', () => {
       const password = document.getElementById('user_password');
@@ -151,9 +167,9 @@ function main() {
       if (!confirmation) return;
 
       if (confirmation.value && confirmation.value.length > password.maxLength) {
-        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.exceeds_maxlength'] || 'Password confirmation exceeds the maximum password length', locale)).format());
+        confirmation.setCustomValidity(formatMessage(messages.passwordExceedsLength));
       } else if (password.value && password.value !== confirmation.value) {
-        confirmation.setCustomValidity((new IntlMessageFormat(messages['password_confirmation.mismatching'] || 'Password confirmation does not match', locale)).format());
+        confirmation.setCustomValidity(formatMessage(messages.passwordDoesNotMatch));
       } else {
         confirmation.setCustomValidity('');
       }
@@ -167,10 +183,10 @@ function main() {
 
       if (statusEl.dataset.spoiler === 'expanded') {
         statusEl.dataset.spoiler = 'folded';
-        this.textContent = (new IntlMessageFormat(messages['status.show_more'] || 'Show more', locale)).format();
+        this.textContent = (new IntlMessageFormat(localeData['status.show_more'] || 'Show more', locale)).format();
       } else {
         statusEl.dataset.spoiler = 'expanded';
-        this.textContent = (new IntlMessageFormat(messages['status.show_less'] || 'Show less', locale)).format();
+        this.textContent = (new IntlMessageFormat(localeData['status.show_less'] || 'Show less', locale)).format();
       }
 
       return false;
@@ -178,7 +194,7 @@ function main() {
 
     [].forEach.call(document.querySelectorAll('.status__content__spoiler-link'), (spoilerLink) => {
       const statusEl = spoilerLink.parentNode.parentNode;
-      const message = (statusEl.dataset.spoiler === 'expanded') ? (messages['status.show_less'] || 'Show less') : (messages['status.show_more'] || 'Show more');
+      const message = (statusEl.dataset.spoiler === 'expanded') ? (localeData['status.show_less'] || 'Show less') : (localeData['status.show_more'] || 'Show more');
       spoilerLink.textContent = (new IntlMessageFormat(message, locale)).format();
     });
   });
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index 7d4bde5e9..dc52bcd87 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -1112,3 +1112,89 @@ code {
     white-space: nowrap;
   }
 }
+
+.progress-tracker {
+  display: flex;
+  align-items: center;
+  padding-bottom: 30px;
+  margin-bottom: 30px;
+
+  li {
+    flex: 0 0 auto;
+    position: relative;
+  }
+
+  .separator {
+    height: 2px;
+    background: $ui-base-lighter-color;
+    flex: 1 1 auto;
+
+    &.completed {
+      background: $highlight-text-color;
+    }
+  }
+
+  .circle {
+    box-sizing: border-box;
+    position: relative;
+    width: 30px;
+    height: 30px;
+    border-radius: 50%;
+    border: 2px solid $ui-base-lighter-color;
+    flex: 0 0 auto;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    svg {
+      width: 16px;
+    }
+  }
+
+  .label {
+    position: absolute;
+    font-size: 14px;
+    font-weight: 500;
+    color: $secondary-text-color;
+    padding-top: 10px;
+    text-align: center;
+    width: 100px;
+    left: 50%;
+    transform: translateX(-50%);
+  }
+
+  li:first-child .label {
+    left: auto;
+    inset-inline-start: 0;
+    text-align: start;
+    transform: none;
+  }
+
+  li:last-child .label {
+    left: auto;
+    inset-inline-end: 0;
+    text-align: end;
+    transform: none;
+  }
+
+  .active .circle {
+    border-color: $highlight-text-color;
+
+    &::before {
+      content: '';
+      width: 10px;
+      height: 10px;
+      border-radius: 50%;
+      background: $highlight-text-color;
+      position: absolute;
+      left: 50%;
+      top: 50%;
+      transform: translate(-50%, -50%);
+    }
+  }
+
+  .completed .circle {
+    border-color: $highlight-text-color;
+    background: $highlight-text-color;
+  }
+}
diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb
index ab73826ab..c428fd30d 100644
--- a/app/mailers/notification_mailer.rb
+++ b/app/mailers/notification_mailer.rb
@@ -14,7 +14,7 @@ class NotificationMailer < ApplicationMailer
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
-      mail to: @me.user.email, subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
+      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.mention.subject', name: @status.account.acct)
     end
   end
 
@@ -25,7 +25,7 @@ class NotificationMailer < ApplicationMailer
     return unless @me.user.functional?
 
     locale_for_account(@me) do
-      mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
+      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.follow.subject', name: @account.acct)
     end
   end
 
@@ -38,7 +38,7 @@ class NotificationMailer < ApplicationMailer
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
-      mail to: @me.user.email, subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
+      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.favourite.subject', name: @account.acct)
     end
   end
 
@@ -51,7 +51,7 @@ class NotificationMailer < ApplicationMailer
 
     locale_for_account(@me) do
       thread_by_conversation(@status.conversation)
-      mail to: @me.user.email, subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
+      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.reblog.subject', name: @account.acct)
     end
   end
 
@@ -62,7 +62,7 @@ class NotificationMailer < ApplicationMailer
     return unless @me.user.functional?
 
     locale_for_account(@me) do
-      mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
+      mail to: email_address_with_name(@me.user.email, @me.user.account.username), subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct)
     end
   end
 
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index 1666ea883..55d34e85c 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -55,7 +55,7 @@ class AccountFilter
     when 'by_domain'
       Account.where(domain: value.to_s.strip)
     when 'username'
-      Account.matches_username(value.to_s.strip)
+      Account.matches_username(value.to_s.strip.delete_prefix('@'))
     when 'display_name'
       Account.matches_display_name(value.to_s.strip)
     when 'email'
diff --git a/app/models/preview_card_provider.rb b/app/models/preview_card_provider.rb
index 1dd95fc91..9f5f6d3cb 100644
--- a/app/models/preview_card_provider.rb
+++ b/app/models/preview_card_provider.rb
@@ -18,6 +18,7 @@
 #
 
 class PreviewCardProvider < ApplicationRecord
+  include Paginable
   include DomainNormalizable
   include Attachmentable
 
diff --git a/app/serializers/rest/admin/trends/link_serializer.rb b/app/serializers/rest/admin/trends/link_serializer.rb
new file mode 100644
index 000000000..c93e6c178
--- /dev/null
+++ b/app/serializers/rest/admin/trends/link_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class REST::Admin::Trends::LinkSerializer < REST::Trends::LinkSerializer
+  attributes :id, :requires_review
+
+  def requires_review
+    object.requires_review?
+  end
+end
diff --git a/app/serializers/rest/admin/trends/links/preview_card_provider_serializer.rb b/app/serializers/rest/admin/trends/links/preview_card_provider_serializer.rb
new file mode 100644
index 000000000..fba0259fb
--- /dev/null
+++ b/app/serializers/rest/admin/trends/links/preview_card_provider_serializer.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class REST::Admin::Trends::Links::PreviewCardProviderSerializer < ActiveModel::Serializer
+  attributes :id, :domain, :trendable, :reviewed_at,
+             :requested_review_at, :requires_review
+
+  def requires_review
+    object.requires_review?
+  end
+end
diff --git a/app/serializers/rest/admin/trends/status_serializer.rb b/app/serializers/rest/admin/trends/status_serializer.rb
new file mode 100644
index 000000000..e46be30ab
--- /dev/null
+++ b/app/serializers/rest/admin/trends/status_serializer.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class REST::Admin::Trends::StatusSerializer < REST::StatusSerializer
+  attributes :requires_review
+
+  def requires_review
+    object.requires_review?
+  end
+end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index 069f370cf..ad9e6e3d6 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -7,6 +7,7 @@ class NotifyService < BaseService
     admin.report
     admin.sign_up
     update
+    poll
   ).freeze
 
   def call(recipient, type, activity)
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index 0d8fd800f..4df0f95d5 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -5,6 +5,8 @@
   = render partial: 'shared/og', locals: { description: description_for_sign_up }
 
 = simple_form_for(resource, as: resource_name, url: registration_path(resource_name), html: { novalidate: false }) do |f|
+  = render 'auth/shared/progress', stage: 'details'
+
   %h1.title= t('auth.sign_up.title', domain: site_hostname)
   %p.lead= t('auth.sign_up.preamble')
 
@@ -18,7 +20,7 @@
   .fields-group
     = f.simple_fields_for :account do |ff|
       = ff.input :display_name, wrapper: :with_label, label: false, required: false, input_html: { 'aria-label': t('simple_form.labels.defaults.display_name'), autocomplete: 'off', placeholder: t('simple_form.labels.defaults.display_name') }
-      = ff.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label': t('simple_form.labels.defaults.username'), autocomplete: 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}", hint: false
+      = ff.input :username, wrapper: :with_label, label: false, required: true, input_html: { 'aria-label': t('simple_form.labels.defaults.username'), autocomplete: 'off', placeholder: t('simple_form.labels.defaults.username'), pattern: '[a-zA-Z0-9_]+', maxlength: 30 }, append: "@#{site_hostname}"
     = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'username' }, hint: false
     = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label': t('simple_form.labels.defaults.password'), autocomplete: 'new-password', minlength: User.password_length.first, maxlength: User.password_length.last }, hint: false
     = 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: 'new-password' }, hint: false
@@ -26,9 +28,11 @@
     = 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?
+    %p.lead= t('auth.sign_up.manual_review', domain: site_hostname)
+
     .fields-group
       = f.simple_fields_for :invite_request, resource.invite_request || resource.build_invite_request do |invite_request_fields|
-        = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: Setting.require_invite_text
+        = invite_request_fields.input :text, as: :text, wrapper: :with_block_label, required: Setting.require_invite_text, label: false, hint: false
 
 
   = hidden_field_tag :accept, params[:accept]
diff --git a/app/views/auth/registrations/rules.html.haml b/app/views/auth/registrations/rules.html.haml
index 8e7a90cbe..aa16ab957 100644
--- a/app/views/auth/registrations/rules.html.haml
+++ b/app/views/auth/registrations/rules.html.haml
@@ -5,6 +5,8 @@
   = render partial: 'shared/og', locals: { description: description_for_sign_up }
 
 .simple_form
+  = render 'auth/shared/progress', stage: 'rules'
+
   %h1.title= t('auth.rules.title')
   %p.lead= t('auth.rules.preamble', domain: site_hostname)
 
diff --git a/app/views/auth/setup/show.html.haml b/app/views/auth/setup/show.html.haml
index 1a6611ceb..913b0c913 100644
--- a/app/views/auth/setup/show.html.haml
+++ b/app/views/auth/setup/show.html.haml
@@ -1,20 +1,22 @@
 - content_for :page_title do
   = t('auth.setup.title')
 
-- if missing_email?
-  = simple_form_for(@user, url: auth_setup_path) do |f|
-    = render 'shared/error_messages', object: @user
+= simple_form_for(@user, url: auth_setup_path) do |f|
+  = render 'auth/shared/progress', stage: 'confirm'
 
-    .fields-group
-      %p.hint= t('auth.setup.email_below_hint_html')
+  %h1.title= t('auth.setup.title')
+  %p.lead= t('auth.setup.email_settings_hint_html', email: content_tag(:strong, @user.email))
 
-    .fields-group
-      = f.input :email, required: true, hint: false, input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'off' }
+  = render 'shared/error_messages', object: @user
 
-    .actions
-      = f.submit t('admin.accounts.change_email.label'), class: 'button'
-- else
-  .simple_form
-    %p.hint= t('auth.setup.email_settings_hint_html', email: content_tag(:strong, @user.email))
+  %p.lead
+    %strong= t('auth.setup.link_not_received')
+  %p.lead= t('auth.setup.email_below_hint_html')
+
+  .fields-group
+    = f.input :email, required: true, hint: false, input_html: { 'aria-label': t('simple_form.labels.defaults.email'), autocomplete: 'off' }
+
+  .actions
+    = f.submit t('auth.resend_confirmation'), class: 'button'
 
 .form-footer= render 'auth/shared/links'
diff --git a/app/views/auth/shared/_links.html.haml b/app/views/auth/shared/_links.html.haml
index f078e2f7e..757ef0a09 100644
--- a/app/views/auth/shared/_links.html.haml
+++ b/app/views/auth/shared/_links.html.haml
@@ -14,5 +14,5 @@
   - if controller_name != 'confirmations' && (!user_signed_in? || !current_user.confirmed? || current_user.unconfirmed_email.present?)
     %li= link_to t('auth.didnt_get_confirmation'), new_user_confirmation_path
 
-  - if user_signed_in? && controller_name != 'setup'
+  - if user_signed_in?
     %li= link_to t('auth.logout'), destroy_user_session_path, data: { method: :delete }
diff --git a/app/views/auth/shared/_progress.html.haml b/app/views/auth/shared/_progress.html.haml
new file mode 100644
index 000000000..578f62fa9
--- /dev/null
+++ b/app/views/auth/shared/_progress.html.haml
@@ -0,0 +1,25 @@
+- progress_index = { rules: 0, details: 1, confirm: 2 }[stage.to_sym]
+
+%ol.progress-tracker
+  %li{ class: progress_index.positive? ? 'completed' : 'active' }
+    .circle
+      - if progress_index.positive?
+        = check_icon
+    .label= t('auth.progress.rules')
+  %li.separator{ class: progress_index.positive? ? 'completed' : nil }
+  %li{ class: [progress_index > 1 && 'completed', progress_index == 1 && 'active'] }
+    .circle
+      - if progress_index > 1
+        = check_icon
+    .label= t('auth.progress.details')
+  %li.separator{ class: progress_index > 1 ? 'completed' : nil }
+  %li{ class: [progress_index > 2 && 'completed', progress_index == 2 && 'active'] }
+    .circle
+      - if progress_index > 2
+        = check_icon
+    .label= t('auth.progress.confirm')
+  - if approved_registrations?
+    %li.separator{ class: progress_index > 2 ? 'completed' : nil }
+    %li
+      .circle
+      .label= t('auth.progress.review')