about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
authorreverite <samantha@chalker.io>2019-03-15 05:54:30 -0700
committerreverite <samantha@chalker.io>2019-03-15 05:54:30 -0700
commit75eeb003b09c53d3b4e98046d1c20b0ad8a887bb (patch)
treed2ae669ee583c613a474f8698b7ea718da803819 /app
parent41d1369391d70a9cd25bdf96cfe567975793ef5a (diff)
parentc2fa0f7c40bcc4064e8baaa221665eadd391c001 (diff)
Merge remote-tracking branch 'glitch/master' into production
Diffstat (limited to 'app')
-rw-r--r--app/controllers/about_controller.rb27
-rw-r--r--app/controllers/accounts_controller.rb12
-rw-r--r--app/controllers/admin/accounts_controller.rb17
-rw-r--r--app/controllers/admin/dashboard_controller.rb2
-rw-r--r--app/controllers/admin/settings_controller.rb3
-rw-r--r--app/controllers/api/base_controller.rb4
-rw-r--r--app/controllers/api/v1/accounts/statuses_controller.rb8
-rw-r--r--app/controllers/api/v1/accounts_controller.rb6
-rw-r--r--app/controllers/api/v1/timelines/tag_controller.rb2
-rw-r--r--app/controllers/auth/registrations_controller.rb2
-rw-r--r--app/controllers/concerns/account_controller_concern.rb14
-rw-r--r--app/controllers/public_timelines_controller.rb39
-rw-r--r--app/controllers/settings/exports_controller.rb18
-rw-r--r--app/controllers/tags_controller.rb18
-rw-r--r--app/helpers/admin/filter_helper.rb2
-rw-r--r--app/helpers/application_helper.rb18
-rw-r--r--app/helpers/home_helper.rb18
-rw-r--r--app/helpers/jsonld_helper.rb9
-rw-r--r--app/javascript/flavours/glitch/actions/notifications.js2
-rw-r--r--app/javascript/flavours/glitch/components/media_gallery.js2
-rw-r--r--app/javascript/flavours/glitch/components/status.js2
-rw-r--r--app/javascript/flavours/glitch/components/status_prepend.js9
-rw-r--r--app/javascript/flavours/glitch/containers/timeline_container.js12
-rw-r--r--app/javascript/flavours/glitch/features/account/components/profile_column_header.js4
-rw-r--r--app/javascript/flavours/glitch/features/account_gallery/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/account_timeline/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/followers/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/following/index.js12
-rw-r--r--app/javascript/flavours/glitch/features/local_settings/page/index.js8
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/column_settings.js11
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/filter_bar.js8
-rw-r--r--app/javascript/flavours/glitch/features/notifications/components/notification.js21
-rw-r--r--app/javascript/flavours/glitch/features/standalone/community_timeline/index.js71
-rw-r--r--app/javascript/flavours/glitch/features/standalone/public_timeline/index.js122
-rw-r--r--app/javascript/flavours/glitch/features/status/components/detailed_status.js4
-rw-r--r--app/javascript/flavours/glitch/features/status/index.js11
-rw-r--r--app/javascript/flavours/glitch/features/video/index.js6
-rw-r--r--app/javascript/flavours/glitch/reducers/local_settings.js5
-rw-r--r--app/javascript/flavours/glitch/reducers/push_notifications.js1
-rw-r--r--app/javascript/flavours/glitch/reducers/settings.js3
-rw-r--r--app/javascript/flavours/glitch/styles/about.scss816
-rw-r--r--app/javascript/flavours/glitch/styles/admin.scss8
-rw-r--r--app/javascript/flavours/glitch/styles/forms.scss21
-rw-r--r--app/javascript/flavours/glitch/styles/polls.scss13
-rw-r--r--app/javascript/flavours/glitch/styles/tables.scss4
-rw-r--r--app/javascript/flavours/glitch/styles/widgets.scss5
-rw-r--r--app/javascript/mastodon/actions/notifications.js2
-rw-r--r--app/javascript/mastodon/containers/timeline_container.js12
-rw-r--r--app/javascript/mastodon/features/notifications/components/column_settings.js11
-rw-r--r--app/javascript/mastodon/features/notifications/components/filter_bar.js8
-rw-r--r--app/javascript/mastodon/features/notifications/components/notification.js34
-rw-r--r--app/javascript/mastodon/features/standalone/community_timeline/index.js71
-rw-r--r--app/javascript/mastodon/features/standalone/hashtag_timeline/index.js8
-rw-r--r--app/javascript/mastodon/features/standalone/public_timeline/index.js122
-rw-r--r--app/javascript/mastodon/features/status/components/detailed_status.js2
-rw-r--r--app/javascript/mastodon/locales/defaultMessages.json72
-rw-r--r--app/javascript/mastodon/locales/en.json12
-rw-r--r--app/javascript/mastodon/locales/ja.json12
-rw-r--r--app/javascript/mastodon/reducers/push_notifications.js1
-rw-r--r--app/javascript/mastodon/reducers/settings.js3
-rw-r--r--app/javascript/mastodon/service_worker/web_push_locales.js1
-rw-r--r--app/javascript/styles/mastodon/about.scss816
-rw-r--r--app/javascript/styles/mastodon/admin.scss8
-rw-r--r--app/javascript/styles/mastodon/forms.scss21
-rw-r--r--app/javascript/styles/mastodon/polls.scss12
-rw-r--r--app/javascript/styles/mastodon/tables.scss4
-rw-r--r--app/javascript/styles/mastodon/widgets.scss5
-rw-r--r--app/lib/activitypub/activity/create.rb18
-rw-r--r--app/lib/activitypub/activity/delete.rb9
-rw-r--r--app/lib/activitypub/activity/update.rb15
-rw-r--r--app/lib/request.rb2
-rw-r--r--app/mailers/admin_mailer.rb10
-rw-r--r--app/models/account.rb7
-rw-r--r--app/models/account_filter.rb4
-rw-r--r--app/models/concerns/expireable.rb6
-rw-r--r--app/models/concerns/ldap_authenticable.rb25
-rw-r--r--app/models/concerns/omniauthable.rb2
-rw-r--r--app/models/concerns/pam_authenticable.rb68
-rw-r--r--app/models/concerns/user_roles.rb54
-rw-r--r--app/models/form/admin_settings.rb4
-rw-r--r--app/models/notification.rb8
-rw-r--r--app/models/poll.rb2
-rw-r--r--app/models/tag.rb8
-rw-r--r--app/models/user.rb166
-rw-r--r--app/policies/user_policy.rb10
-rw-r--r--app/presenters/instance_presenter.rb10
-rw-r--r--app/serializers/activitypub/update_poll_serializer.rb27
-rw-r--r--app/serializers/rest/instance_serializer.rb2
-rw-r--r--app/serializers/rest/notification_serializer.rb2
-rw-r--r--app/services/activitypub/fetch_remote_poll_service.rb51
-rw-r--r--app/services/activitypub/fetch_replies_service.rb9
-rw-r--r--app/services/activitypub/process_poll_service.rb64
-rw-r--r--app/services/app_sign_up_service.rb2
-rw-r--r--app/services/notify_service.rb6
-rw-r--r--app/services/post_status_service.rb3
-rw-r--r--app/services/remove_status_service.rb6
-rw-r--r--app/services/vote_service.rb23
-rw-r--r--app/views/about/_features.html.haml25
-rw-r--r--app/views/about/_forms.html.haml15
-rw-r--r--app/views/about/_links.html.haml16
-rw-r--r--app/views/about/_login.html.haml13
-rw-r--r--app/views/about/_registration.html.haml20
-rw-r--r--app/views/about/show.html.haml207
-rw-r--r--app/views/admin/accounts/_account.html.haml10
-rw-r--r--app/views/admin/accounts/index.html.haml7
-rw-r--r--app/views/admin/accounts/show.html.haml16
-rw-r--r--app/views/admin/invites/_invite.html.haml22
-rw-r--r--app/views/admin/invites/index.html.haml6
-rw-r--r--app/views/admin/settings/edit.html.haml10
-rw-r--r--app/views/admin_mailer/new_pending_account.text.erb8
-rw-r--r--app/views/auth/registrations/new.html.haml2
-rw-r--r--app/views/auth/shared/_links.html.haml2
-rw-r--r--app/views/invites/_invite.html.haml20
-rw-r--r--app/views/invites/index.html.haml19
-rw-r--r--app/views/layouts/public.html.haml32
-rw-r--r--app/views/public_timelines/show.html.haml13
-rw-r--r--app/views/remote_follow/new.html.haml5
-rw-r--r--app/views/remote_interaction/new.html.haml5
-rw-r--r--app/views/tags/show.html.haml1
-rw-r--r--app/views/user_mailer/confirmation_instructions.html.haml2
-rw-r--r--app/views/user_mailer/confirmation_instructions.text.erb2
-rw-r--r--app/workers/activitypub/distribute_poll_update_worker.rb65
-rw-r--r--app/workers/poll_expiration_notify_worker.rb24
123 files changed, 1765 insertions, 2098 deletions
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
index ce1e8293c..f459bab19 100644
--- a/app/controllers/about_controller.rb
+++ b/app/controllers/about_controller.rb
@@ -2,21 +2,17 @@
 
 class AboutController < ApplicationController
   before_action :set_pack
-  before_action :set_body_classes
+  layout 'public'
+
   before_action :set_instance_presenter, only: [:show, :more, :terms]
 
   def show
-    serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
-    @initial_state_json   = serializable_resource.to_json
+    @hide_navbar = true
   end
 
-  def more
-    render layout: 'public'
-  end
+  def more; end
 
-  def terms
-    render layout: 'public'
-  end
+  def terms; end
 
   private
 
@@ -27,21 +23,10 @@ class AboutController < ApplicationController
   helper_method :new_user
 
   def set_pack
-    use_pack action_name == 'show' ? 'about' : 'common'
+    use_pack 'common'
   end
 
   def set_instance_presenter
     @instance_presenter = InstancePresenter.new
   end
-
-  def set_body_classes
-    @body_classes = 'with-modals'
-  end
-
-  def initial_state_params
-    {
-      settings: { known_fediverse: Setting.show_known_fediverse_at_about_page },
-      token: current_session&.token,
-    }
-  end
 end
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 442e99089..157ea8569 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -81,11 +81,17 @@ class AccountsController < ApplicationController
   end
 
   def hashtag_scope
-    Status.tagged_with(Tag.find_by(name: params[:tag].downcase)&.id)
+    tag = Tag.find_normalized(params[:tag])
+
+    if tag
+      Status.tagged_with(tag.id)
+    else
+      Status.none
+    end
   end
 
-  def set_account
-    @account = Account.find_local!(params[:username])
+  def username_param
+    params[:username]
   end
 
   def older_url
diff --git a/app/controllers/admin/accounts_controller.rb b/app/controllers/admin/accounts_controller.rb
index 562fba996..e160c603a 100644
--- a/app/controllers/admin/accounts_controller.rb
+++ b/app/controllers/admin/accounts_controller.rb
@@ -2,9 +2,9 @@
 
 module Admin
   class AccountsController < BaseController
-    before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize]
+    before_action :set_account, only: [:show, :subscribe, :unsubscribe, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject]
     before_action :require_remote_account!, only: [:subscribe, :unsubscribe, :redownload]
-    before_action :require_local_account!, only: [:enable, :memorialize]
+    before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
 
     def index
       authorize :account, :index?
@@ -45,6 +45,18 @@ module Admin
       redirect_to admin_account_path(@account.id)
     end
 
+    def approve
+      authorize @account.user, :approve?
+      @account.user.approve!
+      redirect_to admin_accounts_path(pending: '1')
+    end
+
+    def reject
+      authorize @account.user, :reject?
+      SuspendAccountService.new.call(@account, including_user: true, destroy: true)
+      redirect_to admin_accounts_path(pending: '1')
+    end
+
     def unsilence
       authorize @account, :unsilence?
       @account.unsilence!
@@ -114,6 +126,7 @@ module Admin
         :remote,
         :by_domain,
         :active,
+        :pending,
         :silenced,
         :suspended,
         :username,
diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb
index bb923c185..22bbcec19 100644
--- a/app/controllers/admin/dashboard_controller.rb
+++ b/app/controllers/admin/dashboard_controller.rb
@@ -10,7 +10,7 @@ module Admin
       @interactions_week     = Redis.current.get("activity:interactions:#{current_week}") || 0
       @relay_enabled         = Relay.enabled.exists?
       @single_user_mode      = Rails.configuration.x.single_user_mode
-      @registrations_enabled = Setting.open_registrations
+      @registrations_enabled = Setting.registrations_mode != 'none'
       @deletions_enabled     = Setting.open_deletion
       @invites_enabled       = Setting.min_invite_role == 'user'
       @search_enabled        = Chewy.enabled?
diff --git a/app/controllers/admin/settings_controller.rb b/app/controllers/admin/settings_controller.rb
index 9624df96b..a64e98868 100644
--- a/app/controllers/admin/settings_controller.rb
+++ b/app/controllers/admin/settings_controller.rb
@@ -10,7 +10,7 @@ module Admin
       site_description
       site_extended_description
       site_terms
-      open_registrations
+      registrations_mode
       closed_registrations_message
       open_deletion
       timeline_preview
@@ -33,7 +33,6 @@ module Admin
     ).freeze
 
     BOOLEAN_SETTINGS = %w(
-      open_registrations
       open_deletion
       timeline_preview
       show_staff_badge
diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb
index a1dd30918..3a92ee4e4 100644
--- a/app/controllers/api/base_controller.rb
+++ b/app/controllers/api/base_controller.rb
@@ -73,7 +73,9 @@ class Api::BaseController < ApplicationController
     elsif current_user.disabled?
       render json: { error: 'Your login is currently disabled' }, status: 403
     elsif !current_user.confirmed?
-      render json: { error: 'Email confirmation is not completed' }, status: 403
+      render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
+    elsif !current_user.approved?
+      render json: { error: 'Your login is currently pending approval' }, status: 403
     else
       set_user_activity
     end
diff --git a/app/controllers/api/v1/accounts/statuses_controller.rb b/app/controllers/api/v1/accounts/statuses_controller.rb
index ed10f3f6a..8cd8f8e79 100644
--- a/app/controllers/api/v1/accounts/statuses_controller.rb
+++ b/app/controllers/api/v1/accounts/statuses_controller.rb
@@ -69,7 +69,13 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
   end
 
   def hashtag_scope
-    Status.tagged_with(Tag.find_by(name: params[:tagged])&.id)
+    tag = Tag.find_normalized(params[:tagged])
+
+    if tag
+      Status.tagged_with(tag.id)
+    else
+      Status.none
+    end
   end
 
   def pagination_params(core_params)
diff --git a/app/controllers/api/v1/accounts_controller.rb b/app/controllers/api/v1/accounts_controller.rb
index 2ccbc3cbb..b0c62778e 100644
--- a/app/controllers/api/v1/accounts_controller.rb
+++ b/app/controllers/api/v1/accounts_controller.rb
@@ -80,6 +80,10 @@ class Api::V1::AccountsController < Api::BaseController
   end
 
   def check_enabled_registrations
-    forbidden if single_user_mode? || !Setting.open_registrations
+    forbidden if single_user_mode? || !allowed_registrations?
+  end
+
+  def allowed_registrations?
+    Setting.registrations_mode != 'none'
   end
 end
diff --git a/app/controllers/api/v1/timelines/tag_controller.rb b/app/controllers/api/v1/timelines/tag_controller.rb
index 92c32c178..9adc4ad29 100644
--- a/app/controllers/api/v1/timelines/tag_controller.rb
+++ b/app/controllers/api/v1/timelines/tag_controller.rb
@@ -14,7 +14,7 @@ class Api::V1::Timelines::TagController < Api::BaseController
   private
 
   def load_tag
-    @tag = Tag.find_by(name: params[:id].downcase)
+    @tag = Tag.find_normalized(params[:id])
   end
 
   def load_statuses
diff --git a/app/controllers/auth/registrations_controller.rb b/app/controllers/auth/registrations_controller.rb
index efe29b53f..74dd7ff34 100644
--- a/app/controllers/auth/registrations_controller.rb
+++ b/app/controllers/auth/registrations_controller.rb
@@ -66,7 +66,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
   end
 
   def allowed_registrations?
-    Setting.open_registrations || @invite&.valid_for_use?
+    Setting.registrations_mode != 'none' || @invite&.valid_for_use?
   end
 
   def invite_code
diff --git a/app/controllers/concerns/account_controller_concern.rb b/app/controllers/concerns/account_controller_concern.rb
index 6c27ef330..8817fd7de 100644
--- a/app/controllers/concerns/account_controller_concern.rb
+++ b/app/controllers/concerns/account_controller_concern.rb
@@ -7,16 +7,18 @@ module AccountControllerConcern
 
   included do
     layout 'public'
+
     before_action :set_account
+    before_action :check_account_approval
+    before_action :check_account_suspension
     before_action :set_instance_presenter
     before_action :set_link_headers
-    before_action :check_account_suspension
   end
 
   private
 
   def set_account
-    @account = Account.find_local!(params[:account_username])
+    @account = Account.find_local!(username_param)
   end
 
   def set_instance_presenter
@@ -33,6 +35,10 @@ module AccountControllerConcern
     )
   end
 
+  def username_param
+    params[:account_username]
+  end
+
   def webfinger_account_link
     [
       webfinger_account_url,
@@ -58,6 +64,10 @@ module AccountControllerConcern
     webfinger_url(resource: @account.to_webfinger_s)
   end
 
+  def check_account_approval
+    not_found if @account.user_pending?
+  end
+
   def check_account_suspension
     gone if @account.suspended?
   end
diff --git a/app/controllers/public_timelines_controller.rb b/app/controllers/public_timelines_controller.rb
new file mode 100644
index 000000000..c5fe789f4
--- /dev/null
+++ b/app/controllers/public_timelines_controller.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+class PublicTimelinesController < ApplicationController
+  before_action :set_pack
+  layout 'public'
+
+  before_action :check_enabled
+  before_action :set_body_classes
+  before_action :set_instance_presenter
+
+  def show
+    respond_to do |format|
+      format.html do
+        @initial_state_json = ActiveModelSerializers::SerializableResource.new(
+          InitialStatePresenter.new(settings: { known_fediverse: Setting.show_known_fediverse_at_about_page }, token: current_session&.token),
+          serializer: InitialStateSerializer
+        ).to_json
+      end
+    end
+  end
+
+  private
+
+  def check_enabled
+    raise ActiveRecord::RecordNotFound unless Setting.timeline_preview
+  end
+
+  def set_body_classes
+    @body_classes = 'with-modals'
+  end
+
+  def set_instance_presenter
+    @instance_presenter = InstancePresenter.new
+  end
+
+  def set_pack
+    use_pack 'about'
+  end
+end
diff --git a/app/controllers/settings/exports_controller.rb b/app/controllers/settings/exports_controller.rb
index cf8745576..7f76668d5 100644
--- a/app/controllers/settings/exports_controller.rb
+++ b/app/controllers/settings/exports_controller.rb
@@ -9,11 +9,25 @@ class Settings::ExportsController < Settings::BaseController
   end
 
   def create
-    authorize :backup, :create?
+    raise Mastodon::NotPermittedError unless user_signed_in?
+
+    backup = nil
+
+    RedisLock.acquire(lock_options) do |lock|
+      if lock.acquired?
+        authorize :backup, :create?
+        backup = current_user.backups.create!
+      else
+        raise Mastodon::RaceConditionError
+      end
+    end
 
-    backup = current_user.backups.create!
     BackupWorker.perform_async(backup.id)
 
     redirect_to settings_export_path
   end
+
+  def lock_options
+    { redis: Redis.current, key: "backup:#{current_user.id}" }
+  end
 end
diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb
index 186d276c2..5cb048c1a 100644
--- a/app/controllers/tags_controller.rb
+++ b/app/controllers/tags_controller.rb
@@ -9,13 +9,15 @@ class TagsController < ApplicationController
   before_action :set_instance_presenter
 
   def show
-    @tag = Tag.find_by!(name: params[:id].downcase)
+    @tag = Tag.find_normalized!(params[:id])
 
     respond_to do |format|
       format.html do
         use_pack 'about'
-        serializable_resource = ActiveModelSerializers::SerializableResource.new(InitialStatePresenter.new(initial_state_params), serializer: InitialStateSerializer)
-        @initial_state_json   = serializable_resource.to_json
+        @initial_state_json = ActiveModelSerializers::SerializableResource.new(
+          InitialStatePresenter.new(settings: {}, token: current_session&.token),
+          serializer: InitialStateSerializer
+        ).to_json
       end
 
       format.rss do
@@ -26,8 +28,7 @@ class TagsController < ApplicationController
       end
 
       format.json do
-        @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local])
-                                       .paginate_by_max_id(PAGE_SIZE, params[:max_id])
+        @statuses = HashtagQueryService.new.call(@tag, params.slice(:any, :all, :none), current_account, params[:local]).paginate_by_max_id(PAGE_SIZE, params[:max_id])
         @statuses = cache_collection(@statuses, Status)
 
         render json: collection_presenter,
@@ -56,11 +57,4 @@ class TagsController < ApplicationController
       items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) }
     )
   end
-
-  def initial_state_params
-    {
-      settings: {},
-      token: current_session&.token,
-    }
-  end
 end
diff --git a/app/helpers/admin/filter_helper.rb b/app/helpers/admin/filter_helper.rb
index 275b5f2fe..8f78bf5f8 100644
--- a/app/helpers/admin/filter_helper.rb
+++ b/app/helpers/admin/filter_helper.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 module Admin::FilterHelper
-  ACCOUNT_FILTERS      = %i(local remote by_domain active silenced suspended username display_name email ip staff).freeze
+  ACCOUNT_FILTERS      = %i(local remote by_domain active pending silenced suspended username display_name email ip staff).freeze
   REPORT_FILTERS       = %i(resolved account_id target_account_id).freeze
   INVITE_FILTER        = %i(available expired).freeze
   CUSTOM_EMOJI_FILTERS = %i(local remote by_domain shortcode).freeze
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index fc006d777..f70c37522 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -20,7 +20,23 @@ module ApplicationHelper
   end
 
   def open_registrations?
-    Setting.open_registrations
+    Setting.registrations_mode == 'open'
+  end
+
+  def approved_registrations?
+    Setting.registrations_mode == 'approved'
+  end
+
+  def closed_registrations?
+    Setting.registrations_mode == 'none'
+  end
+
+  def available_sign_up_path
+    if closed_registrations?
+      'https://joinmastodon.org/#getting-started'
+    else
+      new_user_registration_path
+    end
   end
 
   def open_deletion?
diff --git a/app/helpers/home_helper.rb b/app/helpers/home_helper.rb
index 9b3f1380b..df60b7dd7 100644
--- a/app/helpers/home_helper.rb
+++ b/app/helpers/home_helper.rb
@@ -56,4 +56,22 @@ module HomeHelper
       'emojify'
     end
   end
+
+  def optional_link_to(condition, path, options = {}, &block)
+    if condition
+      link_to(path, options, &block)
+    else
+      content_tag(:div, &block)
+    end
+  end
+
+  def sign_up_message
+    if closed_registrations?
+      t('auth.registration_closed', instance: site_hostname)
+    elsif open_registrations?
+      t('auth.register')
+    elsif approved_registrations?
+      t('auth.apply_for_account')
+    end
+  end
 end
diff --git a/app/helpers/jsonld_helper.rb b/app/helpers/jsonld_helper.rb
index f0a19e332..5b4011275 100644
--- a/app/helpers/jsonld_helper.rb
+++ b/app/helpers/jsonld_helper.rb
@@ -47,6 +47,15 @@ module JsonLdHelper
     !uri.start_with?('http://', 'https://')
   end
 
+  def invalid_origin?(url)
+    return true if unsupported_uri_scheme?(url)
+
+    needle   = Addressable::URI.parse(url).host
+    haystack = Addressable::URI.parse(@account.uri).host
+
+    !haystack.casecmp(needle).zero?
+  end
+
   def canonicalize(json)
     graph = RDF::Graph.new << JSON::LD::API.toRdf(json, documentLoader: method(:load_jsonld_context))
     graph.dump(:normalize)
diff --git a/app/javascript/flavours/glitch/actions/notifications.js b/app/javascript/flavours/glitch/actions/notifications.js
index f89b4cb36..dd4f5fd44 100644
--- a/app/javascript/flavours/glitch/actions/notifications.js
+++ b/app/javascript/flavours/glitch/actions/notifications.js
@@ -106,7 +106,7 @@ const excludeTypesFromSettings = state => state.getIn(['settings', 'notification
 
 
 const excludeTypesFromFilter = filter => {
-  const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']);
+  const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
   return allTypes.filterNot(item => item === filter).toJS();
 };
 
diff --git a/app/javascript/flavours/glitch/components/media_gallery.js b/app/javascript/flavours/glitch/components/media_gallery.js
index 1fa25ee4e..6be2b4700 100644
--- a/app/javascript/flavours/glitch/components/media_gallery.js
+++ b/app/javascript/flavours/glitch/components/media_gallery.js
@@ -238,7 +238,7 @@ export default class MediaGallery extends React.PureComponent {
   };
 
   componentWillReceiveProps (nextProps) {
-    if (!is(nextProps.media, this.props.media)) {
+    if (!is(nextProps.media, this.props.media) || nextProps.revealed === true) {
       this.setState({ visible: nextProps.revealed === undefined ? (displayMedia !== 'hide_all' && !nextProps.sensitive || displayMedia === 'show_all') : nextProps.revealed });
     }
   }
diff --git a/app/javascript/flavours/glitch/components/status.js b/app/javascript/flavours/glitch/components/status.js
index 31f4f1ddd..c8bf75f79 100644
--- a/app/javascript/flavours/glitch/components/status.js
+++ b/app/javascript/flavours/glitch/components/status.js
@@ -466,6 +466,7 @@ export default class Status extends ImmutablePureComponent {
               onOpenVideo={this.handleOpenVideo}
               width={this.props.cachedMediaWidth}
               cacheWidth={this.props.cacheMediaWidth}
+              revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined}
             />)}
           </Bundle>
         );
@@ -483,6 +484,7 @@ export default class Status extends ImmutablePureComponent {
                 onOpenMedia={this.props.onOpenMedia}
                 cacheWidth={this.props.cacheMediaWidth}
                 defaultWidth={this.props.cachedMediaWidth}
+                revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined}
               />
             )}
           </Bundle>
diff --git a/app/javascript/flavours/glitch/components/status_prepend.js b/app/javascript/flavours/glitch/components/status_prepend.js
index 4e329f546..481e6644e 100644
--- a/app/javascript/flavours/glitch/components/status_prepend.js
+++ b/app/javascript/flavours/glitch/components/status_prepend.js
@@ -62,6 +62,13 @@ export default class StatusPrepend extends React.PureComponent {
           values={{ name : link }}
         />
       );
+    case 'poll':
+      return (
+        <FormattedMessage
+          id='notification.poll'
+          defaultMessage='A poll you have voted in has ended'
+        />
+      );
     }
     return null;
   }
@@ -75,7 +82,7 @@ export default class StatusPrepend extends React.PureComponent {
         <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
           <i
             className={`fa fa-fw fa-${
-              type === 'favourite' ? 'star star-icon' : (type === 'featured' ? 'thumb-tack' : 'retweet')
+              type === 'favourite' ? 'star star-icon' : (type === 'featured' ? 'thumb-tack' : (type === 'poll' ? 'tasks' : 'retweet'))
             } status__prepend-icon`}
           />
         </div>
diff --git a/app/javascript/flavours/glitch/containers/timeline_container.js b/app/javascript/flavours/glitch/containers/timeline_container.js
index 5a1f41f7a..6ce556664 100644
--- a/app/javascript/flavours/glitch/containers/timeline_container.js
+++ b/app/javascript/flavours/glitch/containers/timeline_container.js
@@ -7,7 +7,6 @@ import { hydrateStore } from 'flavours/glitch/actions/store';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from 'mastodon/locales';
 import PublicTimeline from 'flavours/glitch/features/standalone/public_timeline';
-import CommunityTimeline from 'flavours/glitch/features/standalone/community_timeline';
 import HashtagTimeline from 'flavours/glitch/features/standalone/hashtag_timeline';
 import ModalContainer from 'flavours/glitch/features/ui/containers/modal_container';
 import initialState from 'flavours/glitch/util/initial_state';
@@ -26,24 +25,22 @@ export default class TimelineContainer extends React.PureComponent {
   static propTypes = {
     locale: PropTypes.string.isRequired,
     hashtag: PropTypes.string,
-    showPublicTimeline: PropTypes.bool.isRequired,
+    local: PropTypes.bool,
   };
 
   static defaultProps = {
-    showPublicTimeline: initialState.settings.known_fediverse,
+    local: !initialState.settings.known_fediverse,
   };
 
   render () {
-    const { locale, hashtag, showPublicTimeline } = this.props;
+    const { locale, hashtag, local } = this.props;
 
     let timeline;
 
     if (hashtag) {
       timeline = <HashtagTimeline hashtag={hashtag} />;
-    } else if (showPublicTimeline) {
-      timeline = <PublicTimeline />;
     } else {
-      timeline = <CommunityTimeline />;
+      timeline = <PublicTimeline local={local} />;
     }
 
     return (
@@ -51,6 +48,7 @@ export default class TimelineContainer extends React.PureComponent {
         <Provider store={store}>
           <Fragment>
             {timeline}
+
             {ReactDOM.createPortal(
               <ModalContainer />,
               document.getElementById('modal-container'),
diff --git a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js
index 1a6abef37..b6d373a2c 100644
--- a/app/javascript/flavours/glitch/features/account/components/profile_column_header.js
+++ b/app/javascript/flavours/glitch/features/account/components/profile_column_header.js
@@ -11,16 +11,18 @@ export default @injectIntl
 class ProfileColumnHeader extends React.PureComponent {
 
   static propTypes = {
+    onClick: PropTypes.func,
     intl: PropTypes.object.isRequired,
   };
 
   render() {
-    const { intl } = this.props;
+    const { onClick, intl } = this.props;
 
     return (
       <ColumnHeader
         icon='user-circle'
         title={intl.formatMessage(messages.profile)}
+        onClick={onClick}
         showBackButton
       />
     );
diff --git a/app/javascript/flavours/glitch/features/account_gallery/index.js b/app/javascript/flavours/glitch/features/account_gallery/index.js
index a9ea5088e..63c1b2d86 100644
--- a/app/javascript/flavours/glitch/features/account_gallery/index.js
+++ b/app/javascript/flavours/glitch/features/account_gallery/index.js
@@ -65,6 +65,10 @@ export default class AccountGallery extends ImmutablePureComponent {
     }
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   handleScrollToBottom = () => {
     if (this.props.hasMore) {
       this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
@@ -94,6 +98,10 @@ export default class AccountGallery extends ImmutablePureComponent {
     return !(location.state && location.state.mastodonModalOpen);
   }
 
+  setRef = c => {
+    this.column = c;
+  }
+
   render () {
     const { medias, isLoading, hasMore } = this.props;
 
@@ -112,8 +120,8 @@ export default class AccountGallery extends ImmutablePureComponent {
     }
 
     return (
-      <Column>
-        <ProfileColumnHeader />
+      <Column ref={this.setRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} />
 
         <ScrollContainer scrollKey='account_gallery' shouldUpdateScroll={this.shouldUpdateScroll}>
           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}>
diff --git a/app/javascript/flavours/glitch/features/account_timeline/index.js b/app/javascript/flavours/glitch/features/account_timeline/index.js
index 415e3be20..d683b8789 100644
--- a/app/javascript/flavours/glitch/features/account_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/account_timeline/index.js
@@ -57,10 +57,18 @@ export default class AccountTimeline extends ImmutablePureComponent {
     }
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   handleLoadMore = maxId => {
     this.props.dispatch(expandAccountTimeline(this.props.params.accountId, { maxId, withReplies: this.props.withReplies }));
   }
 
+  setRef = c => {
+    this.column = c;
+  }
+
   render () {
     const { statusIds, featuredStatusIds, isLoading, hasMore } = this.props;
 
@@ -73,8 +81,8 @@ export default class AccountTimeline extends ImmutablePureComponent {
     }
 
     return (
-      <Column name='account'>
-        <ProfileColumnHeader />
+      <Column ref={this.setRef} name='account'>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} />
 
         <StatusList
           prepend={<HeaderContainer accountId={this.props.params.accountId} />}
diff --git a/app/javascript/flavours/glitch/features/followers/index.js b/app/javascript/flavours/glitch/features/followers/index.js
index 124004cb6..6bb9f60fd 100644
--- a/app/javascript/flavours/glitch/features/followers/index.js
+++ b/app/javascript/flavours/glitch/features/followers/index.js
@@ -43,6 +43,10 @@ export default class Followers extends ImmutablePureComponent {
     }
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   handleScroll = (e) => {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
 
@@ -61,6 +65,10 @@ export default class Followers extends ImmutablePureComponent {
     return !(location.state && location.state.mastodonModalOpen);
   }
 
+  setRef = c => {
+    this.column = c;
+  }
+
   render () {
     const { accountIds, hasMore } = this.props;
 
@@ -79,8 +87,8 @@ export default class Followers extends ImmutablePureComponent {
     }
 
     return (
-      <Column>
-        <ProfileColumnHeader />
+      <Column ref={this.setRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} />
 
         <ScrollContainer scrollKey='followers' shouldUpdateScroll={this.shouldUpdateScroll}>
           <div className='scrollable' onScroll={this.handleScroll}>
diff --git a/app/javascript/flavours/glitch/features/following/index.js b/app/javascript/flavours/glitch/features/following/index.js
index 656100dad..3f2f091a1 100644
--- a/app/javascript/flavours/glitch/features/following/index.js
+++ b/app/javascript/flavours/glitch/features/following/index.js
@@ -43,6 +43,10 @@ export default class Following extends ImmutablePureComponent {
     }
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   handleScroll = (e) => {
     const { scrollTop, scrollHeight, clientHeight } = e.target;
 
@@ -61,6 +65,10 @@ export default class Following extends ImmutablePureComponent {
     return !(location.state && location.state.mastodonModalOpen);
   }
 
+  setRef = c => {
+    this.column = c;
+  }
+
   render () {
     const { accountIds, hasMore } = this.props;
 
@@ -79,8 +87,8 @@ export default class Following extends ImmutablePureComponent {
     }
 
     return (
-      <Column>
-        <ProfileColumnHeader />
+      <Column ref={this.setRef}>
+        <ProfileColumnHeader onClick={this.handleHeaderClick} />
 
         <ScrollContainer scrollKey='following' shouldUpdateScroll={this.shouldUpdateScroll}>
           <div className='scrollable' onScroll={this.handleScroll}>
diff --git a/app/javascript/flavours/glitch/features/local_settings/page/index.js b/app/javascript/flavours/glitch/features/local_settings/page/index.js
index 4535d9849..bc4ad359c 100644
--- a/app/javascript/flavours/glitch/features/local_settings/page/index.js
+++ b/app/javascript/flavours/glitch/features/local_settings/page/index.js
@@ -328,6 +328,14 @@ export default class LocalSettingsPage extends React.PureComponent {
         >
           <FormattedMessage id='settings.inline_preview_cards' defaultMessage='Inline preview cards for external links' />
         </LocalSettingsPageItem>
+        <LocalSettingsPageItem
+          settings={settings}
+          item={['media', 'reveal_behind_cw']}
+          id='mastodon-settings--reveal-behind-cw'
+          onChange={onChange}
+        >
+          <FormattedMessage id='settings.media_reveal_behind_cw' defaultMessage='Reveal sensitive media behind a CW by default' />
+        </LocalSettingsPageItem>
       </div>
     ),
   ];
diff --git a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
index 4e35d5b4e..e29bd61f5 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/column_settings.js
@@ -90,6 +90,17 @@ export default class ColumnSettings extends React.PureComponent {
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
           </div>
         </div>
+
+        <div role='group' aria-labelledby='notifications-poll'>
+          <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} meta={pushMeta} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
index f95a2c9de..3457b7598 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/filter_bar.js
@@ -6,6 +6,7 @@ const tooltips = defineMessages({
   mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
   favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
   boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
+  polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
   follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
 });
 
@@ -79,6 +80,13 @@ class FilterBar extends React.PureComponent {
           <i className='fa fa-fw fa-retweet' />
         </button>
         <button
+          className={selectedFilter === 'poll' ? 'active' : ''}
+          onClick={this.onClick('poll')}
+          title={intl.formatMessage(tooltips.polls)}
+        >
+          <i className='fa fa-fw fa-tasks' />
+        </button>
+        <button
           className={selectedFilter === 'follow' ? 'active' : ''}
           onClick={this.onClick('follow')}
           title={intl.formatMessage(tooltips.follows)}
diff --git a/app/javascript/flavours/glitch/features/notifications/components/notification.js b/app/javascript/flavours/glitch/features/notifications/components/notification.js
index daafe3507..5c5bbf604 100644
--- a/app/javascript/flavours/glitch/features/notifications/components/notification.js
+++ b/app/javascript/flavours/glitch/features/notifications/components/notification.js
@@ -108,6 +108,27 @@ export default class Notification extends ImmutablePureComponent {
           withDismiss
         />
       );
+    case 'poll':
+      return (
+        <StatusContainer
+          containerId={notification.get('id')}
+          hidden={hidden}
+          id={notification.get('status')}
+          account={notification.get('account')}
+          prepend='poll'
+          muted
+          notification={notification}
+          onMoveDown={onMoveDown}
+          onMoveUp={onMoveUp}
+          onMention={onMention}
+          getScrollPosition={getScrollPosition}
+          updateScrollBottom={updateScrollBottom}
+          cachedMediaWidth={this.props.cachedMediaWidth}
+          cacheMediaWidth={this.props.cacheMediaWidth}
+          onUnmount={this.props.onUnmount}
+          withDismiss
+        />
+      );
     default:
       return null;
     }
diff --git a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
deleted file mode 100644
index 2b67e836a..000000000
--- a/app/javascript/flavours/glitch/features/standalone/community_timeline/index.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import { expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
-import Column from 'flavours/glitch/components/column';
-import ColumnHeader from 'flavours/glitch/components/column_header';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connectCommunityStream } from 'flavours/glitch/actions/streaming';
-
-const messages = defineMessages({
-  title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
-});
-
-@connect()
-@injectIntl
-export default class CommunityTimeline extends React.PureComponent {
-
-  static propTypes = {
-    dispatch: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleHeaderClick = () => {
-    this.column.scrollTop();
-  }
-
-  setRef = c => {
-    this.column = c;
-  }
-
-  componentDidMount () {
-    const { dispatch } = this.props;
-
-    dispatch(expandCommunityTimeline());
-    this.disconnect = dispatch(connectCommunityStream());
-  }
-
-  componentWillUnmount () {
-    if (this.disconnect) {
-      this.disconnect();
-      this.disconnect = null;
-    }
-  }
-
-  handleLoadMore = maxId => {
-    this.props.dispatch(expandCommunityTimeline({ maxId }));
-  }
-
-  render () {
-    const { intl } = this.props;
-
-    return (
-      <Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
-        <ColumnHeader
-          icon='users'
-          title={intl.formatMessage(messages.title)}
-          onClick={this.handleHeaderClick}
-        />
-
-        <StatusListContainer
-          timelineId='community'
-          onLoadMore={this.handleLoadMore}
-          scrollKey='standalone_public_timeline'
-          trackScroll={false}
-        />
-      </Column>
-    );
-  }
-
-}
diff --git a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
index 907da3992..5e2b3fc6d 100644
--- a/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
+++ b/app/javascript/flavours/glitch/features/standalone/public_timeline/index.js
@@ -1,70 +1,112 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import StatusListContainer from 'flavours/glitch/features/ui/containers/status_list_container';
-import { expandPublicTimeline } from 'flavours/glitch/actions/timelines';
-import Column from 'flavours/glitch/components/column';
-import ColumnHeader from 'flavours/glitch/components/column_header';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connectPublicStream } from 'flavours/glitch/actions/streaming';
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { expandPublicTimeline, expandCommunityTimeline } from 'flavours/glitch/actions/timelines';
+import { connectPublicStream, connectCommunityStream } from 'flavours/glitch/actions/streaming';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
+import DetailedStatusContainer from 'flavours/glitch/features/status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'flavours/glitch/components/loading_indicator';
 
-const messages = defineMessages({
-  title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
-});
+const mapStateToProps = (state, { local }) => {
+  const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap());
 
-@connect()
-@injectIntl
-export default class PublicTimeline extends React.PureComponent {
+  return {
+    statusIds: timeline.get('items', ImmutableList()),
+    isLoading: timeline.get('isLoading', false),
+    hasMore: timeline.get('hasMore', false),
+  };
+};
+
+export default @connect(mapStateToProps)
+class PublicTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+    local: PropTypes.bool,
   };
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
+  componentDidMount () {
+    this._connect();
   }
 
-  setRef = c => {
-    this.column = c;
+  componentDidUpdate (prevProps) {
+    if (prevProps.local !== this.props.local) {
+      this._disconnect();
+      this._connect();
+    }
   }
 
-  componentDidMount () {
-    const { dispatch } = this.props;
-
-    dispatch(expandPublicTimeline());
-    this.disconnect = dispatch(connectPublicStream());
+  componentWillUnmount () {
+    this._disconnect();
   }
 
-  componentWillUnmount () {
+  _connect () {
+    const { dispatch, local } = this.props;
+
+    dispatch(local ? expandCommunityTimeline() : expandPublicTimeline());
+    this.disconnect = dispatch(local ? connectCommunityStream() : connectPublicStream());
+  }
+ 
+  _disconnect () {
     if (this.disconnect) {
       this.disconnect();
       this.disconnect = null;
     }
   }
 
-  handleLoadMore = maxId => {
-    this.props.dispatch(expandPublicTimeline({ maxId }));
+  handleLoadMore = () => {
+    const { dispatch, statusIds, local } = this.props;
+    const maxId = statusIds.last();
+
+    if (maxId) {
+      dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId }));
+    }
+  }
+
+  setRef = c => {
+    this.masonry = c;
   }
 
+  handleHeightChange = debounce(() => {
+    if (!this.masonry) {
+      return;
+    }
+
+    this.masonry.forcePack();
+  }, 50)
+
   render () {
-    const { intl } = this.props;
+    const { statusIds, hasMore, isLoading } = this.props;
+
+    const sizes = [
+      { columns: 1, gutter: 0 },
+      { mq: '415px', columns: 1, gutter: 10 },
+      { mq: '640px', columns: 2, gutter: 10 },
+      { mq: '960px', columns: 3, gutter: 10 },
+      { mq: '1255px', columns: 3, gutter: 10 },
+    ];
+
+    const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
 
     return (
-      <Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
-        <ColumnHeader
-          icon='globe'
-          title={intl.formatMessage(messages.title)}
-          onClick={this.handleHeaderClick}
-        />
-
-        <StatusListContainer
-          timelineId='public'
-          onLoadMore={this.handleLoadMore}
-          scrollKey='standalone_public_timeline'
-          trackScroll={false}
-        />
-      </Column>
+      <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
+        {statusIds.map(statusId => (
+          <div className='statuses-grid__item' key={statusId}>
+            <DetailedStatusContainer
+              id={statusId}
+              compact
+              measureHeight
+              onHeightChange={this.handleHeightChange}
+            />
+          </div>
+        )).toArray()}
+      </Masonry>
     );
   }
 
diff --git a/app/javascript/flavours/glitch/features/status/components/detailed_status.js b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
index ad60320ef..e78f16c54 100644
--- a/app/javascript/flavours/glitch/features/status/components/detailed_status.js
+++ b/app/javascript/flavours/glitch/features/status/components/detailed_status.js
@@ -23,7 +23,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
   };
 
   static propTypes = {
-    status: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
     settings: ImmutablePropTypes.map.isRequired,
     onOpenMedia: PropTypes.func.isRequired,
     onOpenVideo: PropTypes.func.isRequired,
@@ -138,6 +138,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
             preventPlayback={!expanded}
             onOpenVideo={this.handleOpenVideo}
             autoplay
+            revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined}
           />
         );
         mediaIcon = 'video-camera';
@@ -151,6 +152,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
             fullwidth={settings.getIn(['media', 'fullwidth'])}
             hidden={!expanded}
             onOpenMedia={this.props.onOpenMedia}
+            revealed={settings.getIn(['media', 'reveal_behind_cw']) && !!status.get('spoiler_text') ? true : undefined}
           />
         );
         mediaIcon = 'picture-o';
diff --git a/app/javascript/flavours/glitch/features/status/index.js b/app/javascript/flavours/glitch/features/status/index.js
index 880372de5..73d3c7e9e 100644
--- a/app/javascript/flavours/glitch/features/status/index.js
+++ b/app/javascript/flavours/glitch/features/status/index.js
@@ -369,6 +369,10 @@ export default class Status extends ImmutablePureComponent {
     }
   }
 
+  handleHeaderClick = () => {
+    this.column.scrollTop();
+  }
+
   renderChildren (list) {
     return list.map(id => (
       <StatusContainer
@@ -390,6 +394,10 @@ export default class Status extends ImmutablePureComponent {
     this.node = c;
   }
 
+  setColumnRef = c => {
+    this.column = c;
+  }
+
   componentDidUpdate (prevProps) {
     if (this.props.params.statusId && (this.props.params.statusId !== prevProps.params.statusId || prevProps.ancestorsIds.size < this.props.ancestorsIds.size)) {
       const { status, ancestorsIds } = this.props;
@@ -452,10 +460,11 @@ export default class Status extends ImmutablePureComponent {
     };
 
     return (
-      <Column label={intl.formatMessage(messages.detailedStatus)}>
+      <Column ref={this.setColumnRef} label={intl.formatMessage(messages.detailedStatus)}>
         <ColumnHeader
           icon='comment'
           title={intl.formatMessage(messages.tootHeading)}
+          onClick={this.handleHeaderClick}
           showBackButton
           extraButton={(
             <button className='column-header__button' title={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} aria-label={intl.formatMessage(!isExpanded ? messages.revealAll : messages.hideAll)} onClick={this.handleToggleAll} aria-pressed={!isExpanded ? 'false' : 'true'}><i className={`fa fa-${!isExpanded ? 'eye-slash' : 'eye'}`} /></button>
diff --git a/app/javascript/flavours/glitch/features/video/index.js b/app/javascript/flavours/glitch/features/video/index.js
index cf66536c4..e3ed799c7 100644
--- a/app/javascript/flavours/glitch/features/video/index.js
+++ b/app/javascript/flavours/glitch/features/video/index.js
@@ -119,6 +119,12 @@ export default class Video extends React.PureComponent {
     revealed: this.props.revealed === undefined ? (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all') : this.props.revealed,
   };
 
+  componentWillReceiveProps (nextProps) {
+    if (nextProps.revealed === true) {
+      this.setState({ revealed: true });
+    }
+  }
+
   // hard coded in components.scss
   // any way to get ::before values programatically?
   volWidth = 50;
diff --git a/app/javascript/flavours/glitch/reducers/local_settings.js b/app/javascript/flavours/glitch/reducers/local_settings.js
index 93a404328..ef694d4ea 100644
--- a/app/javascript/flavours/glitch/reducers/local_settings.js
+++ b/app/javascript/flavours/glitch/reducers/local_settings.js
@@ -39,8 +39,9 @@ const initialState = ImmutableMap({
     show_action_bar : true,
   }),
   media     : ImmutableMap({
-    letterbox   : true,
-    fullwidth   : true,
+    letterbox        : true,
+    fullwidth        : true,
+    reveal_behind_cw : false,
   }),
   notifications : ImmutableMap({
     favicon_badge : false,
diff --git a/app/javascript/flavours/glitch/reducers/push_notifications.js b/app/javascript/flavours/glitch/reducers/push_notifications.js
index 1b47ca962..e87e8fc1a 100644
--- a/app/javascript/flavours/glitch/reducers/push_notifications.js
+++ b/app/javascript/flavours/glitch/reducers/push_notifications.js
@@ -9,6 +9,7 @@ const initialState = Immutable.Map({
     favourite: false,
     reblog: false,
     mention: false,
+    poll: false,
   }),
   isSubscribed: false,
   browserSupport: false,
diff --git a/app/javascript/flavours/glitch/reducers/settings.js b/app/javascript/flavours/glitch/reducers/settings.js
index 384ff2d44..cc86a6b20 100644
--- a/app/javascript/flavours/glitch/reducers/settings.js
+++ b/app/javascript/flavours/glitch/reducers/settings.js
@@ -33,6 +33,7 @@ const initialState = ImmutableMap({
       favourite: true,
       reblog: true,
       mention: true,
+      poll: true,
     }),
 
     quickFilter: ImmutableMap({
@@ -46,6 +47,7 @@ const initialState = ImmutableMap({
       favourite: true,
       reblog: true,
       mention: true,
+      poll: true,
     }),
 
     sounds: ImmutableMap({
@@ -53,6 +55,7 @@ const initialState = ImmutableMap({
       favourite: true,
       reblog: true,
       mention: true,
+      poll: true,
     }),
   }),
 
diff --git a/app/javascript/flavours/glitch/styles/about.scss b/app/javascript/flavours/glitch/styles/about.scss
index 329482458..7a457600e 100644
--- a/app/javascript/flavours/glitch/styles/about.scss
+++ b/app/javascript/flavours/glitch/styles/about.scss
@@ -193,6 +193,7 @@ $small-breakpoint: 960px;
     }
 
     strong {
+      font-family: $font-display, sans-serif;
       font-weight: 500;
       font-size: 32px;
       line-height: 48px;
@@ -282,168 +283,6 @@ $small-breakpoint: 960px;
 }
 
 .landing-page {
-  .grid {
-    display: grid;
-    grid-gap: 10px;
-    grid-template-columns: 1fr 2fr;
-    grid-auto-columns: 25%;
-    grid-auto-rows: max-content;
-
-    .column-0 {
-      display: none;
-    }
-
-    .column-1 {
-      grid-column: 1;
-      grid-row: 1;
-    }
-
-    .column-2 {
-      grid-column: 2;
-      grid-row: 1;
-    }
-
-    .column-3 {
-      grid-column: 3;
-      grid-row: 1 / 3;
-    }
-
-    .column-4 {
-      grid-column: 1 / 3;
-      grid-row: 2;
-    }
-  }
-
-  @media screen and (max-width: $small-breakpoint) {
-    .grid {
-      grid-template-columns: 40% 60%;
-
-      .column-0 {
-        display: none;
-      }
-
-      .column-1 {
-        grid-column: 1;
-        grid-row: 1;
-
-        &.non-preview .landing-page__forms {
-          height: 100%;
-        }
-      }
-
-      .column-2 {
-        grid-column: 2;
-        grid-row: 1 / 3;
-
-        &.non-preview {
-          grid-column: 2;
-          grid-row: 1;
-        }
-      }
-
-      .column-3 {
-        grid-column: 1;
-        grid-row: 2 / 4;
-      }
-
-      .column-4 {
-        grid-column: 2;
-        grid-row: 3;
-
-        &.non-preview {
-          grid-column: 1 / 3;
-          grid-row: 2;
-        }
-      }
-    }
-  }
-
-  @media screen and (max-width: $column-breakpoint) {
-    .grid {
-      grid-template-columns: 100%;
-
-      .column-0 {
-        display: block;
-        grid-column: 1;
-        grid-row: 1;
-      }
-
-      .column-1 {
-        grid-column: 1;
-        grid-row: 3;
-
-        .brand {
-          display: none;
-        }
-      }
-
-      .column-2 {
-        grid-column: 1;
-        grid-row: 2;
-
-        .landing-page__logo,
-        .landing-page__call-to-action {
-          display: none;
-        }
-
-        &.non-preview {
-          grid-column: 1;
-          grid-row: 2;
-        }
-      }
-
-      .column-3 {
-        grid-column: 1;
-        grid-row: 5;
-      }
-
-      .column-4 {
-        grid-column: 1;
-        grid-row: 4;
-
-        &.non-preview {
-          grid-column: 1;
-          grid-row: 4;
-        }
-      }
-    }
-  }
-
-  .column-flex {
-    display: flex;
-    flex-direction: column;
-  }
-
-  .separator-or {
-    position: relative;
-    margin: 40px 0;
-    text-align: center;
-
-    &::before {
-      content: "";
-      display: block;
-      width: 100%;
-      height: 0;
-      border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
-      position: absolute;
-      top: 50%;
-      left: 0;
-    }
-
-    span {
-      display: inline-block;
-      background: $ui-base-color;
-      font-size: 12px;
-      font-weight: 500;
-      color: $darker-text-color;
-      text-transform: uppercase;
-      position: relative;
-      z-index: 1;
-      padding: 0 8px;
-      cursor: default;
-    }
-  }
-
   p,
   li {
     font-family: $font-sans-serif, sans-serif;
@@ -460,28 +299,6 @@ $small-breakpoint: 960px;
     }
   }
 
-  .closed-registrations-message {
-    margin-top: 20px;
-
-    &,
-    p {
-      text-align: center;
-      font-size: 12px;
-      line-height: 18px;
-      color: $darker-text-color;
-      margin-bottom: 0;
-
-      a {
-        color: $highlight-text-color;
-        text-decoration: underline;
-      }
-    }
-
-    p:last-child {
-      margin-bottom: 0;
-    }
-  }
-
   em {
     display: inline;
     margin: 0;
@@ -595,187 +412,6 @@ $small-breakpoint: 960px;
     }
   }
 
-  .container-alt {
-    width: 100%;
-    box-sizing: border-box;
-    max-width: 800px;
-    margin: 0 auto;
-    word-wrap: break-word;
-  }
-
-  .header-wrapper {
-    padding-top: 15px;
-    background: $ui-base-color;
-    background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color);
-    position: relative;
-
-    &.compact {
-      background: $ui-base-color;
-      padding-bottom: 15px;
-
-      .hero .heading {
-        padding-bottom: 20px;
-        font-family: $font-sans-serif, sans-serif;
-        font-size: 16px;
-        font-weight: 400;
-        font-size: 16px;
-        line-height: 30px;
-        color: $darker-text-color;
-
-        a {
-          color: $highlight-text-color;
-          text-decoration: underline;
-        }
-      }
-    }
-  }
-
-  .brand {
-    a {
-      padding-left: 0;
-      padding-right: 0;
-      color: $white;
-    }
-
-    img {
-      height: 32px;
-      position: relative;
-      top: 4px;
-      left: -10px;
-    }
-  }
-
-  .header {
-    line-height: 30px;
-    overflow: hidden;
-
-    .container-alt {
-      display: flex;
-      justify-content: space-between;
-    }
-
-    .links {
-      position: relative;
-      z-index: 4;
-
-      a {
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        color: $darker-text-color;
-        text-decoration: none;
-        padding: 12px 16px;
-        line-height: 32px;
-        font-family: $font-display, sans-serif;
-        font-weight: 500;
-        font-size: 14px;
-
-        &:hover {
-          color: $secondary-text-color;
-        }
-      }
-
-      ul {
-        list-style: none;
-        margin: 0;
-
-        li {
-          display: inline-block;
-          vertical-align: bottom;
-          margin: 0;
-
-          &:first-child a {
-            padding-left: 0;
-          }
-
-          &:last-child a {
-            padding-right: 0;
-          }
-        }
-      }
-    }
-
-    .hero {
-      margin-top: 50px;
-      align-items: center;
-      position: relative;
-
-      .heading {
-        position: relative;
-        z-index: 4;
-        padding-bottom: 150px;
-      }
-
-      .simple_form,
-      .closed-registrations-message {
-        background: darken($ui-base-color, 4%);
-        width: 280px;
-        padding: 15px 20px;
-        border-radius: 4px 4px 0 0;
-        line-height: initial;
-        position: relative;
-        z-index: 4;
-
-        .actions {
-          margin-bottom: 0;
-
-          button,
-          .button,
-          .block-button {
-            margin-bottom: 0;
-          }
-        }
-      }
-
-      .closed-registrations-message {
-        min-height: 330px;
-        display: flex;
-        flex-direction: column;
-        justify-content: space-between;
-      }
-    }
-  }
-
-  .about-short {
-    background: darken($ui-base-color, 4%);
-    padding: 50px 0 30px;
-    font-family: $font-sans-serif, sans-serif;
-    font-size: 16px;
-    font-weight: 400;
-    font-size: 16px;
-    line-height: 30px;
-    color: $darker-text-color;
-
-    a {
-      color: $highlight-text-color;
-      text-decoration: underline;
-    }
-  }
-
-  &.alternative {
-    padding: 10px 0;
-
-    .brand {
-      text-align: center;
-      padding: 30px 0;
-      margin-bottom: 10px;
-
-      img {
-        position: static;
-        padding: 10px 0;
-      }
-
-      @media screen and (max-width: $small-breakpoint) {
-        padding: 15px 0;
-      }
-
-      @media screen and (max-width: $column-breakpoint) {
-        padding: 0;
-        margin-bottom: -10px;
-      }
-    }
-  }
-
   &__information,
   &__forms {
     padding: 20px;
@@ -970,353 +606,253 @@ $small-breakpoint: 960px;
     }
   }
 
-  &__forms {
-    height: 100%;
-
-    @media screen and (max-width: $small-breakpoint) {
-      height: auto;
-    }
-
-    @media screen and (max-width: $column-breakpoint) {
-      background: transparent;
-      box-shadow: none;
-      padding: 0 20px;
-      margin-top: 30px;
-      margin-bottom: 40px;
-
-      .separator-or {
-        span {
-          background: darken($ui-base-color, 8%);
-        }
+  @media screen and (max-width: 840px) {
+    .information-board {
+      .container-alt {
+        padding-right: 20px;
       }
-    }
-
-    hr {
-      margin: 40px 0;
-    }
 
-    .button {
-      display: block;
-    }
-
-    .subtle-hint a {
-      text-decoration: none;
+      .panel {
+        position: static;
+        margin-top: 20px;
+        width: 100%;
+        border-radius: 4px;
 
-      &:hover,
-      &:focus,
-      &:active {
-        text-decoration: underline;
+        .panel-header {
+          text-align: center;
+        }
       }
     }
   }
 
-  #mastodon-timeline {
-    display: flex;
-    -webkit-overflow-scrolling: touch;
-    -ms-overflow-style: -ms-autohiding-scrollbar;
-    font-family: $font-sans-serif, sans-serif;
-    font-size: 13px;
-    line-height: 18px;
-    font-weight: 400;
-    color: $primary-text-color;
-    width: 100%;
-    flex: 1 1 auto;
-    overflow: hidden;
-    height: 100%;
-
-    .column-header {
-      color: inherit;
-      font-family: inherit;
-      font-size: 16px;
-      line-height: inherit;
-      font-weight: inherit;
-      margin: 0;
-      padding: 0;
-    }
-
-    .column {
-      padding: 0;
-      border-radius: 4px;
-      overflow: hidden;
-      width: 100%;
-    }
-
-    .scrollable {
-      height: 400px;
-    }
-
-    p {
-      font-size: inherit;
-      line-height: inherit;
-      font-weight: inherit;
-      color: $primary-text-color;
-      margin-bottom: 20px;
-
-      &:last-child {
-        margin-bottom: 0;
-      }
+  @media screen and (max-width: 675px) {
+    .header-wrapper {
+      padding-top: 0;
 
-      a {
-        color: $secondary-text-color;
-        text-decoration: none;
+      &.compact {
+        padding-bottom: 0;
       }
-    }
-
-    .attachment-list__list {
-      margin-left: 0;
-      list-style: none;
-
-      li {
-        font-size: inherit;
-        line-height: inherit;
-        font-weight: inherit;
-        margin-bottom: 0;
-
-        a {
-          color: $dark-text-color;
-          text-decoration: none;
 
-          &:hover {
-            text-decoration: underline;
-          }
-        }
+      &.compact .hero .heading {
+        text-align: initial;
       }
     }
 
-    @media screen and (max-width: $column-breakpoint) {
-      display: none;
+    .header .container-alt,
+    .features .container-alt {
+      display: block;
     }
   }
 
-  &__features {
-    & > p {
-      padding-right: 60px;
-    }
-
-    .features-list {
-      margin: 40px 0;
-      margin-top: 30px;
-    }
-
-    &__action {
-      text-align: center;
-    }
+  .cta {
+    margin: 20px;
   }
+}
 
-  .features-list {
-    .features-list__row {
-      display: flex;
-      padding: 10px 0;
-      justify-content: space-between;
-
-      .visual {
-        flex: 0 0 auto;
-        display: flex;
-        align-items: center;
-        margin-left: 15px;
+.landing {
+  margin-bottom: 100px;
 
-        .fa {
-          display: block;
-          color: $darker-text-color;
-          font-size: 48px;
-        }
-      }
+  @media screen and (max-width: 738px) {
+    margin-bottom: 0;
+  }
 
-      .text {
-        font-size: 16px;
-        line-height: 30px;
-        color: $darker-text-color;
+  &__brand {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding: 100px;
 
-        h6 {
-          font-size: inherit;
-          line-height: inherit;
-          margin-bottom: 0;
-        }
-      }
+    img {
+      height: 52px;
     }
 
-    @media screen and (min-width: $small-breakpoint) {
-      display: grid;
-      grid-gap: 30px;
-      grid-template-columns: 1fr 1fr;
-      grid-auto-columns: 50%;
-      grid-auto-rows: max-content;
+    @media screen and (max-width: $no-gap-breakpoint) {
+      padding: 0;
+      margin-bottom: 30px;
     }
   }
 
-  .footer-links {
-    padding-bottom: 50px;
-    text-align: right;
-    color: $dark-text-color;
+  .directory {
+    margin-top: 30px;
+    background: transparent;
+    box-shadow: none;
+    border-radius: 0;
+  }
 
-    p {
-      font-size: 14px;
-    }
+  .hero-widget {
+    margin-top: 30px;
+    margin-bottom: 0;
 
-    a {
-      color: inherit;
-      text-decoration: underline;
+    h4 {
+      padding: 10px;
+      text-transform: uppercase;
+      font-weight: 700;
+      font-size: 13px;
+      color: $darker-text-color;
     }
-  }
 
-  &__footer {
-    margin-top: 10px;
-    text-align: center;
-    color: $dark-text-color;
+    &__text {
+      border-radius: 0;
+      padding-bottom: 0;
+    }
 
-    p {
-      font-size: 14px;
+    &__footer {
+      background: $ui-base-color;
+      padding: 10px;
+      border-radius: 0 0 4px 4px;
+      display: flex;
 
-      a {
-        color: inherit;
-        text-decoration: underline;
+      &__column {
+        flex: 1 1 50%;
       }
     }
-  }
 
-  @media screen and (max-width: 840px) {
-    .container-alt {
-      padding: 0 20px;
-    }
+    .account {
+      padding: 10px 0;
+      border-bottom: 0;
 
-    .information-board {
-      .container-alt {
-        padding-right: 20px;
+      .account__display-name {
+        display: flex;
+        align-items: center;
       }
 
-      .panel {
-        position: static;
-        margin-top: 20px;
-        width: 100%;
-        border-radius: 4px;
-
-        .panel-header {
-          text-align: center;
-        }
+      .account__avatar {
+        width: 44px;
+        height: 44px;
+        background-size: 44px 44px;
       }
     }
-  }
 
-  @media screen and (max-width: 675px) {
-    .header-wrapper {
-      padding-top: 0;
+    &__counter {
+      padding: 10px;
 
-      &.compact {
-        padding-bottom: 0;
+      strong {
+        font-family: $font-display, sans-serif;
+        font-size: 15px;
+        font-weight: 700;
+        display: block;
       }
 
-      &.compact .hero .heading {
-        text-align: initial;
+      span {
+        font-size: 14px;
+        color: $darker-text-color;
       }
     }
+  }
 
-    .header .container-alt,
-    .features .container-alt {
-      display: block;
-    }
-
-    .header {
-      .links {
-        padding-top: 15px;
-        background: darken($ui-base-color, 4%);
+  .simple_form .user_agreement .label_input > label {
+    font-weight: 400;
+    color: $darker-text-color;
+  }
 
-        a {
-          padding: 12px 8px;
-        }
+  .simple_form p.lead {
+    color: $darker-text-color;
+    font-size: 15px;
+    line-height: 20px;
+    font-weight: 400;
+    margin-bottom: 25px;
+  }
 
-        .nav {
-          display: flex;
-          flex-flow: row wrap;
-          justify-content: space-around;
-        }
+  &__grid {
+    max-width: 960px;
+    margin: 0 auto;
+    display: grid;
+    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+    grid-gap: 30px;
 
-        .brand img {
-          left: 0;
-          top: 0;
-        }
-      }
+    @media screen and (max-width: 738px) {
+      grid-template-columns: minmax(0, 100%);
+      grid-gap: 10px;
 
-      .hero {
-        margin-top: 30px;
-        padding: 0;
+      &__column-login {
+        grid-row: 1;
+        display: flex;
+        flex-direction: column;
 
-        .heading {
-          padding: 30px 20px;
-          text-align: center;
+        .box-widget {
+          order: 2;
+          flex: 0 0 auto;
         }
 
-        .simple_form,
-        .closed-registrations-message {
-          background: darken($ui-base-color, 8%);
-          width: 100%;
-          border-radius: 0;
-          box-sizing: border-box;
+        .hero-widget {
+          margin-top: 0;
+          margin-bottom: 10px;
+          order: 1;
+          flex: 0 0 auto;
         }
       }
-    }
-  }
-
-  .cta {
-    margin: 20px;
-  }
 
-  &.tag-page {
-    @media screen and (max-width: $column-breakpoint) {
-      padding: 0;
-
-      .container {
-        padding: 0;
+      &__column-registration {
+        grid-row: 2;
       }
 
-      #mastodon-timeline {
-        display: flex;
-        height: 100vh;
-        border-radius: 0;
+      .directory {
+        margin-top: 10px;
       }
     }
 
-    .grid {
-      @media screen and (min-width: $small-breakpoint) {
-        grid-template-columns: 33% 67%;
-      }
-
-      .column-2 {
-        grid-column: 2;
-        grid-row: 1;
-      }
-    }
+    @media screen and (max-width: $no-gap-breakpoint) {
+      grid-gap: 0;
 
-    .brand {
-      text-align: unset;
-      padding: 0;
+      .hero-widget {
+        display: block;
+        margin-bottom: 0;
+        box-shadow: none;
 
-      img {
-        height: 48px;
-        width: auto;
+        &__img,
+        &__img img,
+        &__footer {
+          border-radius: 0;
+        }
       }
-    }
 
-    .cta {
-      margin: 0;
-
-      .button {
-        margin-right: 4px;
+      .hero-widget,
+      .box-widget,
+      .directory__tag {
+        border-bottom: 1px solid lighten($ui-base-color, 8%);
       }
-    }
 
-    @media screen and (max-width: $column-breakpoint) {
-      .grid {
-        grid-gap: 0;
+      .directory {
+        margin-top: 0;
 
-        .column-1 {
-          grid-column: 1;
-          grid-row: 1;
-        }
+        &__tag {
+          margin-bottom: 0;
 
-        .column-2 {
-          display: none;
+          & > a,
+          & > div {
+            border-radius: 0;
+            box-shadow: none;
+          }
+
+          &:last-child {
+            border-bottom: 0;
+          }
         }
       }
     }
   }
 }
+
+.brand {
+  position: relative;
+  text-decoration: none;
+}
+
+.brand__tagline {
+  display: block;
+  position: absolute;
+  bottom: -10px;
+  left: 50px;
+  width: 300px;
+  color: $ui-primary-color;
+  text-decoration: none;
+  font-size: 14px;
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    position: static;
+    width: auto;
+    margin-top: 20px;
+    color: $dark-text-color;
+  }
+}
+
diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss
index 4dbbaa1e8..42f53f525 100644
--- a/app/javascript/flavours/glitch/styles/admin.scss
+++ b/app/javascript/flavours/glitch/styles/admin.scss
@@ -705,3 +705,11 @@ a.name-tag,
   overflow: hidden;
   text-overflow: ellipsis;
 }
+
+.ellipsized-ip {
+  display: inline-block;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  vertical-align: middle;
+}
diff --git a/app/javascript/flavours/glitch/styles/forms.scss b/app/javascript/flavours/glitch/styles/forms.scss
index bab982706..6051c1d00 100644
--- a/app/javascript/flavours/glitch/styles/forms.scss
+++ b/app/javascript/flavours/glitch/styles/forms.scss
@@ -68,6 +68,17 @@ code {
         top: 2px;
         left: 0;
       }
+
+      label a {
+        color: $highlight-text-color;
+        text-decoration: underline;
+
+        &:hover,
+        &:active,
+        &:focus {
+          text-decoration: none;
+        }
+      }
     }
   }
 
@@ -305,7 +316,7 @@ code {
       box-shadow: none;
     }
 
-    &:focus:invalid {
+    &:focus:invalid:not(:placeholder-shown) {
       border-color: lighten($error-red, 12%);
     }
 
@@ -346,6 +357,10 @@ code {
     }
   }
 
+  .input.disabled {
+    opacity: 0.5;
+  }
+
   .actions {
     margin-top: 30px;
     display: flex;
@@ -392,6 +407,10 @@ code {
       background-color: darken($ui-highlight-color, 5%);
     }
 
+    &:disabled:hover {
+      background-color: $ui-primary-color;
+    }
+
     &.negative {
       background: $error-value-color;
 
diff --git a/app/javascript/flavours/glitch/styles/polls.scss b/app/javascript/flavours/glitch/styles/polls.scss
index 4f8c94d83..6030e1e98 100644
--- a/app/javascript/flavours/glitch/styles/polls.scss
+++ b/app/javascript/flavours/glitch/styles/polls.scss
@@ -144,6 +144,7 @@
 
     button,
     select {
+      width: 100%;
       flex: 1 1 50%;
     }
   }
@@ -190,3 +191,15 @@
     color: darken($simple-background-color, 14%);
   }
 }
+
+.muted .poll {
+  color: $dark-text-color;
+
+  &__chart {
+    background: rgba(darken($ui-primary-color, 14%), 0.2);
+
+    &.leading {
+      background: rgba($ui-highlight-color, 0.2);
+    }
+  }
+}
diff --git a/app/javascript/flavours/glitch/styles/tables.scss b/app/javascript/flavours/glitch/styles/tables.scss
index 9fd0b95bb..296182ff5 100644
--- a/app/javascript/flavours/glitch/styles/tables.scss
+++ b/app/javascript/flavours/glitch/styles/tables.scss
@@ -82,6 +82,10 @@
       }
     }
   }
+
+  &--invites tbody td {
+    vertical-align: middle;
+  }
 }
 
 .table-wrapper {
diff --git a/app/javascript/flavours/glitch/styles/widgets.scss b/app/javascript/flavours/glitch/styles/widgets.scss
index 1eaf30c5b..645192ea4 100644
--- a/app/javascript/flavours/glitch/styles/widgets.scss
+++ b/app/javascript/flavours/glitch/styles/widgets.scss
@@ -295,6 +295,11 @@
       cursor: default;
     }
 
+    &.disabled > div {
+      opacity: 0.5;
+      cursor: default;
+    }
+
     h4 {
       flex: 1 1 auto;
       font-size: 18px;
diff --git a/app/javascript/mastodon/actions/notifications.js b/app/javascript/mastodon/actions/notifications.js
index 4c145febc..61fef19e9 100644
--- a/app/javascript/mastodon/actions/notifications.js
+++ b/app/javascript/mastodon/actions/notifications.js
@@ -92,7 +92,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
 const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
 
 const excludeTypesFromFilter = filter => {
-  const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention']);
+  const allTypes = ImmutableList(['follow', 'favourite', 'reblog', 'mention', 'poll']);
   return allTypes.filterNot(item => item === filter).toJS();
 };
 
diff --git a/app/javascript/mastodon/containers/timeline_container.js b/app/javascript/mastodon/containers/timeline_container.js
index a1a4bd024..54f8eb310 100644
--- a/app/javascript/mastodon/containers/timeline_container.js
+++ b/app/javascript/mastodon/containers/timeline_container.js
@@ -7,7 +7,6 @@ import { hydrateStore } from '../actions/store';
 import { IntlProvider, addLocaleData } from 'react-intl';
 import { getLocale } from '../locales';
 import PublicTimeline from '../features/standalone/public_timeline';
-import CommunityTimeline from '../features/standalone/community_timeline';
 import HashtagTimeline from '../features/standalone/hashtag_timeline';
 import ModalContainer from '../features/ui/containers/modal_container';
 import initialState from '../initial_state';
@@ -26,24 +25,22 @@ export default class TimelineContainer extends React.PureComponent {
   static propTypes = {
     locale: PropTypes.string.isRequired,
     hashtag: PropTypes.string,
-    showPublicTimeline: PropTypes.bool.isRequired,
+    local: PropTypes.bool,
   };
 
   static defaultProps = {
-    showPublicTimeline: initialState.settings.known_fediverse,
+    local: !initialState.settings.known_fediverse,
   };
 
   render () {
-    const { locale, hashtag, showPublicTimeline } = this.props;
+    const { locale, hashtag, local } = this.props;
 
     let timeline;
 
     if (hashtag) {
       timeline = <HashtagTimeline hashtag={hashtag} />;
-    } else if (showPublicTimeline) {
-      timeline = <PublicTimeline />;
     } else {
-      timeline = <CommunityTimeline />;
+      timeline = <PublicTimeline local={local} />;
     }
 
     return (
@@ -51,6 +48,7 @@ export default class TimelineContainer extends React.PureComponent {
         <Provider store={store}>
           <Fragment>
             {timeline}
+
             {ReactDOM.createPortal(
               <ModalContainer />,
               document.getElementById('modal-container'),
diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js
index a334fd63c..60a86312a 100644
--- a/app/javascript/mastodon/features/notifications/components/column_settings.js
+++ b/app/javascript/mastodon/features/notifications/components/column_settings.js
@@ -89,6 +89,17 @@ export default class ColumnSettings extends React.PureComponent {
             <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'reblog']} onChange={onChange} label={soundStr} />
           </div>
         </div>
+
+        <div role='group' aria-labelledby='notifications-poll'>
+          <span id='notifications-poll' className='column-settings__section'><FormattedMessage id='notifications.column_settings.poll' defaultMessage='Poll results:' /></span>
+
+          <div className='column-settings__row'>
+            <SettingToggle prefix='notifications_desktop' settings={settings} settingPath={['alerts', 'poll']} onChange={onChange} label={alertStr} />
+            {showPushSettings && <SettingToggle prefix='notifications_push' settings={pushSettings} settingPath={['alerts', 'poll']} onChange={this.onPushChange} label={pushStr} />}
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['shows', 'poll']} onChange={onChange} label={showStr} />
+            <SettingToggle prefix='notifications' settings={settings} settingPath={['sounds', 'poll']} onChange={onChange} label={soundStr} />
+          </div>
+        </div>
       </div>
     );
   }
diff --git a/app/javascript/mastodon/features/notifications/components/filter_bar.js b/app/javascript/mastodon/features/notifications/components/filter_bar.js
index 6ae8b7491..3f3e6ab7d 100644
--- a/app/javascript/mastodon/features/notifications/components/filter_bar.js
+++ b/app/javascript/mastodon/features/notifications/components/filter_bar.js
@@ -7,6 +7,7 @@ const tooltips = defineMessages({
   mentions: { id: 'notifications.filter.mentions', defaultMessage: 'Mentions' },
   favourites: { id: 'notifications.filter.favourites', defaultMessage: 'Favourites' },
   boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
+  polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
   follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
 });
 
@@ -80,6 +81,13 @@ class FilterBar extends React.PureComponent {
           <Icon id='retweet' fixedWidth />
         </button>
         <button
+          className={selectedFilter === 'poll' ? 'active' : ''}
+          onClick={this.onClick('poll')}
+          title={intl.formatMessage(tooltips.polls)}
+        >
+          <Icon id='tasks' fixedWidth />
+        </button>
+        <button
           className={selectedFilter === 'follow' ? 'active' : ''}
           onClick={this.onClick('follow')}
           title={intl.formatMessage(tooltips.follows)}
diff --git a/app/javascript/mastodon/features/notifications/components/notification.js b/app/javascript/mastodon/features/notifications/components/notification.js
index 9669b6e7d..4bdf09166 100644
--- a/app/javascript/mastodon/features/notifications/components/notification.js
+++ b/app/javascript/mastodon/features/notifications/components/notification.js
@@ -205,6 +205,38 @@ class Notification extends ImmutablePureComponent {
     );
   }
 
+  renderPoll (notification) {
+    const { intl } = this.props;
+
+    return (
+      <HotKeys handlers={this.getHandlers()}>
+        <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }), notification.get('created_at'))}>
+          <div className='notification__message'>
+            <div className='notification__favourite-icon-wrapper'>
+              <Icon id='tasks' fixedWidth />
+            </div>
+
+            <span title={notification.get('created_at')}>
+              <FormattedMessage id='notification.poll' defaultMessage='A poll you have voted in has ended' />
+            </span>
+          </div>
+
+          <StatusContainer
+            id={notification.get('status')}
+            account={notification.get('account')}
+            muted
+            withDismiss
+            hidden={this.props.hidden}
+            getScrollPosition={this.props.getScrollPosition}
+            updateScrollBottom={this.props.updateScrollBottom}
+            cachedMediaWidth={this.props.cachedMediaWidth}
+            cacheMediaWidth={this.props.cacheMediaWidth}
+          />
+        </div>
+      </HotKeys>
+    );
+  }
+
   render () {
     const { notification } = this.props;
     const account          = notification.get('account');
@@ -220,6 +252,8 @@ class Notification extends ImmutablePureComponent {
       return this.renderFavourite(notification, link);
     case 'reblog':
       return this.renderReblog(notification, link);
+    case 'poll':
+      return this.renderPoll(notification);
     }
 
     return null;
diff --git a/app/javascript/mastodon/features/standalone/community_timeline/index.js b/app/javascript/mastodon/features/standalone/community_timeline/index.js
deleted file mode 100644
index f917f41c9..000000000
--- a/app/javascript/mastodon/features/standalone/community_timeline/index.js
+++ /dev/null
@@ -1,71 +0,0 @@
-import React from 'react';
-import { connect } from 'react-redux';
-import PropTypes from 'prop-types';
-import StatusListContainer from '../../ui/containers/status_list_container';
-import { expandCommunityTimeline } from '../../../actions/timelines';
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connectCommunityStream } from '../../../actions/streaming';
-
-const messages = defineMessages({
-  title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
-});
-
-export default @connect()
-@injectIntl
-class CommunityTimeline extends React.PureComponent {
-
-  static propTypes = {
-    dispatch: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
-  };
-
-  handleHeaderClick = () => {
-    this.column.scrollTop();
-  }
-
-  setRef = c => {
-    this.column = c;
-  }
-
-  componentDidMount () {
-    const { dispatch } = this.props;
-
-    dispatch(expandCommunityTimeline());
-    this.disconnect = dispatch(connectCommunityStream());
-  }
-
-  componentWillUnmount () {
-    if (this.disconnect) {
-      this.disconnect();
-      this.disconnect = null;
-    }
-  }
-
-  handleLoadMore = maxId => {
-    this.props.dispatch(expandCommunityTimeline({ maxId }));
-  }
-
-  render () {
-    const { intl } = this.props;
-
-    return (
-      <Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
-        <ColumnHeader
-          icon='users'
-          title={intl.formatMessage(messages.title)}
-          onClick={this.handleHeaderClick}
-        />
-
-        <StatusListContainer
-          timelineId='community'
-          onLoadMore={this.handleLoadMore}
-          scrollKey='standalone_public_timeline'
-          trackScroll={false}
-        />
-      </Column>
-    );
-  }
-
-}
diff --git a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
index 333726f94..0880d98c8 100644
--- a/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/hashtag_timeline/index.js
@@ -2,13 +2,13 @@ import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
 import ImmutablePropTypes from 'react-immutable-proptypes';
-import { expandHashtagTimeline } from '../../../actions/timelines';
-import { connectHashtagStream } from '../../../actions/streaming';
+import { expandHashtagTimeline } from 'mastodon/actions/timelines';
+import { connectHashtagStream } from 'mastodon/actions/streaming';
 import Masonry from 'react-masonry-infinite';
 import { List as ImmutableList } from 'immutable';
-import DetailedStatusContainer from '../../status/containers/detailed_status_container';
+import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container';
 import { debounce } from 'lodash';
-import LoadingIndicator from '../../../components/loading_indicator';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
 
 const mapStateToProps = (state, { hashtag }) => ({
   statusIds: state.getIn(['timelines', `hashtag:${hashtag}`, 'items'], ImmutableList()),
diff --git a/app/javascript/mastodon/features/standalone/public_timeline/index.js b/app/javascript/mastodon/features/standalone/public_timeline/index.js
index 618696eb1..10129e606 100644
--- a/app/javascript/mastodon/features/standalone/public_timeline/index.js
+++ b/app/javascript/mastodon/features/standalone/public_timeline/index.js
@@ -1,70 +1,112 @@
 import React from 'react';
 import { connect } from 'react-redux';
 import PropTypes from 'prop-types';
-import StatusListContainer from '../../ui/containers/status_list_container';
-import { expandPublicTimeline } from '../../../actions/timelines';
-import Column from '../../../components/column';
-import ColumnHeader from '../../../components/column_header';
-import { defineMessages, injectIntl } from 'react-intl';
-import { connectPublicStream } from '../../../actions/streaming';
-
-const messages = defineMessages({
-  title: { id: 'standalone.public_title', defaultMessage: 'A look inside...' },
-});
-
-export default @connect()
-@injectIntl
+import ImmutablePropTypes from 'react-immutable-proptypes';
+import { expandPublicTimeline, expandCommunityTimeline } from 'mastodon/actions/timelines';
+import { connectPublicStream, connectCommunityStream } from 'mastodon/actions/streaming';
+import Masonry from 'react-masonry-infinite';
+import { List as ImmutableList, Map as ImmutableMap } from 'immutable';
+import DetailedStatusContainer from 'mastodon/features/status/containers/detailed_status_container';
+import { debounce } from 'lodash';
+import LoadingIndicator from 'mastodon/components/loading_indicator';
+
+const mapStateToProps = (state, { local }) => {
+  const timeline = state.getIn(['timelines', local ? 'community' : 'public'], ImmutableMap());
+
+  return {
+    statusIds: timeline.get('items', ImmutableList()),
+    isLoading: timeline.get('isLoading', false),
+    hasMore: timeline.get('hasMore', false),
+  };
+};
+
+export default @connect(mapStateToProps)
 class PublicTimeline extends React.PureComponent {
 
   static propTypes = {
     dispatch: PropTypes.func.isRequired,
-    intl: PropTypes.object.isRequired,
+    statusIds: ImmutablePropTypes.list.isRequired,
+    isLoading: PropTypes.bool.isRequired,
+    hasMore: PropTypes.bool.isRequired,
+    local: PropTypes.bool,
   };
 
-  handleHeaderClick = () => {
-    this.column.scrollTop();
+  componentDidMount () {
+    this._connect();
   }
 
-  setRef = c => {
-    this.column = c;
+  componentDidUpdate (prevProps) {
+    if (prevProps.local !== this.props.local) {
+      this._disconnect();
+      this._connect();
+    }
   }
 
-  componentDidMount () {
-    const { dispatch } = this.props;
+  componentWillUnmount () {
+    this._disconnect();
+  }
+
+  _connect () {
+    const { dispatch, local } = this.props;
 
-    dispatch(expandPublicTimeline());
-    this.disconnect = dispatch(connectPublicStream());
+    dispatch(local ? expandCommunityTimeline() : expandPublicTimeline());
+    this.disconnect = dispatch(local ? connectCommunityStream() : connectPublicStream());
   }
 
-  componentWillUnmount () {
+  _disconnect () {
     if (this.disconnect) {
       this.disconnect();
       this.disconnect = null;
     }
   }
 
-  handleLoadMore = maxId => {
-    this.props.dispatch(expandPublicTimeline({ maxId }));
+  handleLoadMore = () => {
+    const { dispatch, statusIds, local } = this.props;
+    const maxId = statusIds.last();
+
+    if (maxId) {
+      dispatch(local ? expandCommunityTimeline({ maxId }) : expandPublicTimeline({ maxId }));
+    }
+  }
+
+  setRef = c => {
+    this.masonry = c;
   }
 
+  handleHeightChange = debounce(() => {
+    if (!this.masonry) {
+      return;
+    }
+
+    this.masonry.forcePack();
+  }, 50)
+
   render () {
-    const { intl } = this.props;
+    const { statusIds, hasMore, isLoading } = this.props;
+
+    const sizes = [
+      { columns: 1, gutter: 0 },
+      { mq: '415px', columns: 1, gutter: 10 },
+      { mq: '640px', columns: 2, gutter: 10 },
+      { mq: '960px', columns: 3, gutter: 10 },
+      { mq: '1255px', columns: 3, gutter: 10 },
+    ];
+
+    const loader = (isLoading && statusIds.isEmpty()) ? <LoadingIndicator key={0} /> : undefined;
 
     return (
-      <Column ref={this.setRef} label={intl.formatMessage(messages.title)}>
-        <ColumnHeader
-          icon='globe'
-          title={intl.formatMessage(messages.title)}
-          onClick={this.handleHeaderClick}
-        />
-
-        <StatusListContainer
-          timelineId='public'
-          onLoadMore={this.handleLoadMore}
-          scrollKey='standalone_public_timeline'
-          trackScroll={false}
-        />
-      </Column>
+      <Masonry ref={this.setRef} className='statuses-grid' hasMore={hasMore} loadMore={this.handleLoadMore} sizes={sizes} loader={loader}>
+        {statusIds.map(statusId => (
+          <div className='statuses-grid__item' key={statusId}>
+            <DetailedStatusContainer
+              id={statusId}
+              compact
+              measureHeight
+              onHeightChange={this.handleHeightChange}
+            />
+          </div>
+        )).toArray()}
+      </Masonry>
     );
   }
 
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.js b/app/javascript/mastodon/features/status/components/detailed_status.js
index 5cd50f055..5c79f9f19 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.js
+++ b/app/javascript/mastodon/features/status/components/detailed_status.js
@@ -23,7 +23,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
   };
 
   static propTypes = {
-    status: ImmutablePropTypes.map.isRequired,
+    status: ImmutablePropTypes.map,
     onOpenMedia: PropTypes.func.isRequired,
     onOpenVideo: PropTypes.func.isRequired,
     onToggleHidden: PropTypes.func.isRequired,
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index 868e54751..78a5648af 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -17,6 +17,10 @@
       {
         "defaultMessage": "File upload limit exceeded.",
         "id": "upload_error.limit"
+      },
+      {
+        "defaultMessage": "File upload not allowed with polls.",
+        "id": "upload_error.poll"
       }
     ],
     "path": "app/javascript/mastodon/actions/compose.json"
@@ -910,6 +914,52 @@
   {
     "descriptors": [
       {
+        "defaultMessage": "Add a poll",
+        "id": "poll_button.add_poll"
+      },
+      {
+        "defaultMessage": "Remove poll",
+        "id": "poll_button.remove_poll"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/poll_button.json"
+  },
+  {
+    "descriptors": [
+      {
+        "defaultMessage": "Choice {number}",
+        "id": "compose_form.poll.option_placeholder"
+      },
+      {
+        "defaultMessage": "Add a choice",
+        "id": "compose_form.poll.add_option"
+      },
+      {
+        "defaultMessage": "Remove this choice",
+        "id": "compose_form.poll.remove_option"
+      },
+      {
+        "defaultMessage": "Poll duration",
+        "id": "compose_form.poll.duration"
+      },
+      {
+        "defaultMessage": "{number, plural, one {# minute} other {# minutes}}",
+        "id": "intervals.full.minutes"
+      },
+      {
+        "defaultMessage": "{number, plural, one {# hour} other {# hours}}",
+        "id": "intervals.full.hours"
+      },
+      {
+        "defaultMessage": "{number, plural, one {# day} other {# days}}",
+        "id": "intervals.full.days"
+      }
+    ],
+    "path": "app/javascript/mastodon/features/compose/components/poll_form.json"
+  },
+  {
+    "descriptors": [
+      {
         "defaultMessage": "Public",
         "id": "privacy.public.short"
       },
@@ -1853,6 +1903,10 @@
       {
         "defaultMessage": "{name} boosted your status",
         "id": "notification.reblog"
+      },
+      {
+        "defaultMessage": "Your poll has ended",
+        "id": "notification.poll"
       }
     ],
     "path": "app/javascript/mastodon/features/notifications/components/notification.json"
@@ -1917,24 +1971,6 @@
   {
     "descriptors": [
       {
-        "defaultMessage": "A look inside...",
-        "id": "standalone.public_title"
-      }
-    ],
-    "path": "app/javascript/mastodon/features/standalone/community_timeline/index.json"
-  },
-  {
-    "descriptors": [
-      {
-        "defaultMessage": "A look inside...",
-        "id": "standalone.public_title"
-      }
-    ],
-    "path": "app/javascript/mastodon/features/standalone/public_timeline/index.json"
-  },
-  {
-    "descriptors": [
-      {
         "defaultMessage": "Delete",
         "id": "status.delete"
       },
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index ae37322e8..eb82cd9a9 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -77,6 +77,10 @@
   "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
   "compose_form.lock_disclaimer.lock": "locked",
   "compose_form.placeholder": "What's on your mind?",
+  "compose_form.poll.add_option": "Add a choice",
+  "compose_form.poll.duration": "Poll duration",
+  "compose_form.poll.option_placeholder": "Choice {number}",
+  "compose_form.poll.remove_option": "Remove this choice",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.marked": "Media is marked as sensitive",
@@ -155,6 +159,9 @@
   "home.column_settings.basic": "Basic",
   "home.column_settings.show_reblogs": "Show boosts",
   "home.column_settings.show_replies": "Show replies",
+  "intervals.full.days": "{number, plural, one {# day} other {# days}}",
+  "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
+  "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
   "introduction.federation.action": "Next",
   "introduction.federation.federated.headline": "Federated",
   "introduction.federation.federated.text": "Public posts from other servers of the fediverse will appear in the federated timeline.",
@@ -245,6 +252,7 @@
   "notification.favourite": "{name} favourited your status",
   "notification.follow": "{name} followed you",
   "notification.mention": "{name} mentioned you",
+  "notification.poll": "A poll you have voted in has ended",
   "notification.reblog": "{name} boosted your status",
   "notifications.clear": "Clear notifications",
   "notifications.clear_confirmation": "Are you sure you want to permanently clear all your notifications?",
@@ -269,6 +277,8 @@
   "poll.refresh": "Refresh",
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
+  "poll_button.add_poll": "Add a poll",
+  "poll_button.remove_poll": "Remove poll",
   "privacy.change": "Adjust status privacy",
   "privacy.direct.long": "Post to mentioned users only",
   "privacy.direct.short": "Direct",
@@ -303,7 +313,6 @@
   "search_results.hashtags": "Hashtags",
   "search_results.statuses": "Toots",
   "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
-  "standalone.public_title": "A look inside...",
   "status.admin_account": "Open moderation interface for @{name}",
   "status.admin_status": "Open this status in the moderation interface",
   "status.block": "Block @{name}",
@@ -361,6 +370,7 @@
   "upload_area.title": "Drag & drop to upload",
   "upload_button.label": "Add media (JPEG, PNG, GIF, WebM, MP4, MOV)",
   "upload_error.limit": "File upload limit exceeded.",
+  "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.description": "Describe for the visually impaired",
   "upload_form.focus": "Change preview",
   "upload_form.undo": "Delete",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index e2d3a98f3..6388c7e9c 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -77,6 +77,10 @@
   "compose_form.lock_disclaimer": "あなたのアカウントは{locked}になっていません。誰でもあなたをフォローすることができ、フォロワー限定の投稿を見ることができます。",
   "compose_form.lock_disclaimer.lock": "承認制",
   "compose_form.placeholder": "今なにしてる?",
+  "compose_form.poll.add_option": "Add a choice",
+  "compose_form.poll.duration": "Poll duration",
+  "compose_form.poll.option_placeholder": "Choice {number}",
+  "compose_form.poll.remove_option": "Remove this choice",
   "compose_form.publish": "トゥート",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.marked": "メディアに閲覧注意が設定されています",
@@ -155,6 +159,9 @@
   "home.column_settings.basic": "基本設定",
   "home.column_settings.show_reblogs": "ブースト表示",
   "home.column_settings.show_replies": "返信表示",
+  "intervals.full.days": "{number, plural, one {# day} other {# days}}",
+  "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
+  "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
   "introduction.federation.action": "次へ",
   "introduction.federation.federated.headline": "連合タイムライン",
   "introduction.federation.federated.text": "Fediverseの他のサーバーからの公開投稿は連合タイムラインに表示されます。",
@@ -245,6 +252,7 @@
   "notification.favourite": "{name}さんがあなたのトゥートをお気に入りに登録しました",
   "notification.follow": "{name}さんにフォローされました",
   "notification.mention": "{name}さんがあなたに返信しました",
+  "notification.poll": "Your poll has ended",
   "notification.reblog": "{name}さんがあなたのトゥートをブーストしました",
   "notifications.clear": "通知を消去",
   "notifications.clear_confirmation": "本当に通知を消去しますか?",
@@ -269,6 +277,8 @@
   "poll.refresh": "Refresh",
   "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
   "poll.vote": "Vote",
+  "poll_button.add_poll": "Add a poll",
+  "poll_button.remove_poll": "Remove poll",
   "privacy.change": "公開範囲を変更",
   "privacy.direct.long": "メンションしたユーザーだけに公開",
   "privacy.direct.short": "ダイレクト",
@@ -303,7 +313,6 @@
   "search_results.hashtags": "ハッシュタグ",
   "search_results.statuses": "トゥート",
   "search_results.total": "{count, number}件の結果",
-  "standalone.public_title": "今こんな話をしています...",
   "status.admin_account": "@{name} のモデレーション画面を開く",
   "status.admin_status": "このトゥートをモデレーション画面で開く",
   "status.block": "@{name}さんをブロック",
@@ -361,6 +370,7 @@
   "upload_area.title": "ドラッグ&ドロップでアップロード",
   "upload_button.label": "メディアを追加 (JPEG, PNG, GIF, WebM, MP4, MOV)",
   "upload_error.limit": "アップロードできる上限を超えています。",
+  "upload_error.poll": "File upload not allowed with polls.",
   "upload_form.description": "視覚障害者のための説明",
   "upload_form.focus": "焦点",
   "upload_form.undo": "削除",
diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js
index 85628c6b1..317352b79 100644
--- a/app/javascript/mastodon/reducers/push_notifications.js
+++ b/app/javascript/mastodon/reducers/push_notifications.js
@@ -9,6 +9,7 @@ const initialState = Immutable.Map({
     favourite: false,
     reblog: false,
     mention: false,
+    poll: false,
   }),
   isSubscribed: false,
   browserSupport: false,
diff --git a/app/javascript/mastodon/reducers/settings.js b/app/javascript/mastodon/reducers/settings.js
index 2e1878cf7..a0eea137f 100644
--- a/app/javascript/mastodon/reducers/settings.js
+++ b/app/javascript/mastodon/reducers/settings.js
@@ -31,6 +31,7 @@ const initialState = ImmutableMap({
       favourite: true,
       reblog: true,
       mention: true,
+      poll: true,
     }),
 
     quickFilter: ImmutableMap({
@@ -44,6 +45,7 @@ const initialState = ImmutableMap({
       favourite: true,
       reblog: true,
       mention: true,
+      poll: true,
     }),
 
     sounds: ImmutableMap({
@@ -51,6 +53,7 @@ const initialState = ImmutableMap({
       favourite: true,
       reblog: true,
       mention: true,
+      poll: true,
     }),
   }),
 
diff --git a/app/javascript/mastodon/service_worker/web_push_locales.js b/app/javascript/mastodon/service_worker/web_push_locales.js
index ce96ae297..5ce8c7b50 100644
--- a/app/javascript/mastodon/service_worker/web_push_locales.js
+++ b/app/javascript/mastodon/service_worker/web_push_locales.js
@@ -18,6 +18,7 @@ filenames.forEach(filename => {
     'notification.follow': full['notification.follow'] || '',
     'notification.mention': full['notification.mention'] || '',
     'notification.reblog': full['notification.reblog'] || '',
+    'notification.poll': full['notification.poll'] || '',
 
     'status.show_more': full['status.show_more'] || '',
     'status.reblog': full['status.reblog'] || '',
diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss
index b078d4d24..465ef2c11 100644
--- a/app/javascript/styles/mastodon/about.scss
+++ b/app/javascript/styles/mastodon/about.scss
@@ -193,6 +193,7 @@ $small-breakpoint: 960px;
     }
 
     strong {
+      font-family: $font-display, sans-serif;
       font-weight: 500;
       font-size: 32px;
       line-height: 48px;
@@ -280,168 +281,6 @@ $small-breakpoint: 960px;
 }
 
 .landing-page {
-  .grid {
-    display: grid;
-    grid-gap: 10px;
-    grid-template-columns: 1fr 2fr;
-    grid-auto-columns: 25%;
-    grid-auto-rows: max-content;
-
-    .column-0 {
-      display: none;
-    }
-
-    .column-1 {
-      grid-column: 1;
-      grid-row: 1;
-    }
-
-    .column-2 {
-      grid-column: 2;
-      grid-row: 1;
-    }
-
-    .column-3 {
-      grid-column: 3;
-      grid-row: 1 / 3;
-    }
-
-    .column-4 {
-      grid-column: 1 / 3;
-      grid-row: 2;
-    }
-  }
-
-  @media screen and (max-width: $small-breakpoint) {
-    .grid {
-      grid-template-columns: 40% 60%;
-
-      .column-0 {
-        display: none;
-      }
-
-      .column-1 {
-        grid-column: 1;
-        grid-row: 1;
-
-        &.non-preview .landing-page__forms {
-          height: 100%;
-        }
-      }
-
-      .column-2 {
-        grid-column: 2;
-        grid-row: 1 / 3;
-
-        &.non-preview {
-          grid-column: 2;
-          grid-row: 1;
-        }
-      }
-
-      .column-3 {
-        grid-column: 1;
-        grid-row: 2 / 4;
-      }
-
-      .column-4 {
-        grid-column: 2;
-        grid-row: 3;
-
-        &.non-preview {
-          grid-column: 1 / 3;
-          grid-row: 2;
-        }
-      }
-    }
-  }
-
-  @media screen and (max-width: $column-breakpoint) {
-    .grid {
-      grid-template-columns: 100%;
-
-      .column-0 {
-        display: block;
-        grid-column: 1;
-        grid-row: 1;
-      }
-
-      .column-1 {
-        grid-column: 1;
-        grid-row: 3;
-
-        .brand {
-          display: none;
-        }
-      }
-
-      .column-2 {
-        grid-column: 1;
-        grid-row: 2;
-
-        .landing-page__logo,
-        .landing-page__call-to-action {
-          display: none;
-        }
-
-        &.non-preview {
-          grid-column: 1;
-          grid-row: 2;
-        }
-      }
-
-      .column-3 {
-        grid-column: 1;
-        grid-row: 5;
-      }
-
-      .column-4 {
-        grid-column: 1;
-        grid-row: 4;
-
-        &.non-preview {
-          grid-column: 1;
-          grid-row: 4;
-        }
-      }
-    }
-  }
-
-  .column-flex {
-    display: flex;
-    flex-direction: column;
-  }
-
-  .separator-or {
-    position: relative;
-    margin: 40px 0;
-    text-align: center;
-
-    &::before {
-      content: "";
-      display: block;
-      width: 100%;
-      height: 0;
-      border-bottom: 1px solid rgba($ui-base-lighter-color, .6);
-      position: absolute;
-      top: 50%;
-      left: 0;
-    }
-
-    span {
-      display: inline-block;
-      background: $ui-base-color;
-      font-size: 12px;
-      font-weight: 500;
-      color: $darker-text-color;
-      text-transform: uppercase;
-      position: relative;
-      z-index: 1;
-      padding: 0 8px;
-      cursor: default;
-    }
-  }
-
   p,
   li {
     font-family: $font-sans-serif, sans-serif;
@@ -458,28 +297,6 @@ $small-breakpoint: 960px;
     }
   }
 
-  .closed-registrations-message {
-    margin-top: 20px;
-
-    &,
-    p {
-      text-align: center;
-      font-size: 12px;
-      line-height: 18px;
-      color: $darker-text-color;
-      margin-bottom: 0;
-
-      a {
-        color: $highlight-text-color;
-        text-decoration: underline;
-      }
-    }
-
-    p:last-child {
-      margin-bottom: 0;
-    }
-  }
-
   em {
     display: inline;
     margin: 0;
@@ -593,187 +410,6 @@ $small-breakpoint: 960px;
     }
   }
 
-  .container-alt {
-    width: 100%;
-    box-sizing: border-box;
-    max-width: 800px;
-    margin: 0 auto;
-    word-wrap: break-word;
-  }
-
-  .header-wrapper {
-    padding-top: 15px;
-    background: $ui-base-color;
-    background: linear-gradient(150deg, lighten($ui-base-color, 8%), $ui-base-color);
-    position: relative;
-
-    &.compact {
-      background: $ui-base-color;
-      padding-bottom: 15px;
-
-      .hero .heading {
-        padding-bottom: 20px;
-        font-family: $font-sans-serif, sans-serif;
-        font-size: 16px;
-        font-weight: 400;
-        font-size: 16px;
-        line-height: 30px;
-        color: $darker-text-color;
-
-        a {
-          color: $highlight-text-color;
-          text-decoration: underline;
-        }
-      }
-    }
-  }
-
-  .brand {
-    a {
-      padding-left: 0;
-      padding-right: 0;
-      color: $white;
-    }
-
-    img {
-      height: 32px;
-      position: relative;
-      top: 4px;
-      left: -10px;
-    }
-  }
-
-  .header {
-    line-height: 30px;
-    overflow: hidden;
-
-    .container-alt {
-      display: flex;
-      justify-content: space-between;
-    }
-
-    .links {
-      position: relative;
-      z-index: 4;
-
-      a {
-        display: flex;
-        justify-content: center;
-        align-items: center;
-        color: $darker-text-color;
-        text-decoration: none;
-        padding: 12px 16px;
-        line-height: 32px;
-        font-family: $font-display, sans-serif;
-        font-weight: 500;
-        font-size: 14px;
-
-        &:hover {
-          color: $secondary-text-color;
-        }
-      }
-
-      ul {
-        list-style: none;
-        margin: 0;
-
-        li {
-          display: inline-block;
-          vertical-align: bottom;
-          margin: 0;
-
-          &:first-child a {
-            padding-left: 0;
-          }
-
-          &:last-child a {
-            padding-right: 0;
-          }
-        }
-      }
-    }
-
-    .hero {
-      margin-top: 50px;
-      align-items: center;
-      position: relative;
-
-      .heading {
-        position: relative;
-        z-index: 4;
-        padding-bottom: 150px;
-      }
-
-      .simple_form,
-      .closed-registrations-message {
-        background: darken($ui-base-color, 4%);
-        width: 280px;
-        padding: 15px 20px;
-        border-radius: 4px 4px 0 0;
-        line-height: initial;
-        position: relative;
-        z-index: 4;
-
-        .actions {
-          margin-bottom: 0;
-
-          button,
-          .button,
-          .block-button {
-            margin-bottom: 0;
-          }
-        }
-      }
-
-      .closed-registrations-message {
-        min-height: 330px;
-        display: flex;
-        flex-direction: column;
-        justify-content: space-between;
-      }
-    }
-  }
-
-  .about-short {
-    background: darken($ui-base-color, 4%);
-    padding: 50px 0 30px;
-    font-family: $font-sans-serif, sans-serif;
-    font-size: 16px;
-    font-weight: 400;
-    font-size: 16px;
-    line-height: 30px;
-    color: $darker-text-color;
-
-    a {
-      color: $highlight-text-color;
-      text-decoration: underline;
-    }
-  }
-
-  &.alternative {
-    padding: 10px 0;
-
-    .brand {
-      text-align: center;
-      padding: 30px 0;
-      margin-bottom: 10px;
-
-      img {
-        position: static;
-        padding: 10px 0;
-      }
-
-      @media screen and (max-width: $small-breakpoint) {
-        padding: 15px 0;
-      }
-
-      @media screen and (max-width: $column-breakpoint) {
-        padding: 0;
-        margin-bottom: -10px;
-      }
-    }
-  }
-
   &__information,
   &__forms {
     padding: 20px;
@@ -967,353 +603,253 @@ $small-breakpoint: 960px;
     }
   }
 
-  &__forms {
-    height: 100%;
-
-    @media screen and (max-width: $small-breakpoint) {
-      height: auto;
-    }
-
-    @media screen and (max-width: $column-breakpoint) {
-      background: transparent;
-      box-shadow: none;
-      padding: 0 20px;
-      margin-top: 30px;
-      margin-bottom: 40px;
-
-      .separator-or {
-        span {
-          background: darken($ui-base-color, 8%);
-        }
+  @media screen and (max-width: 840px) {
+    .information-board {
+      .container-alt {
+        padding-right: 20px;
       }
-    }
-
-    hr {
-      margin: 40px 0;
-    }
 
-    .button {
-      display: block;
-    }
-
-    .subtle-hint a {
-      text-decoration: none;
+      .panel {
+        position: static;
+        margin-top: 20px;
+        width: 100%;
+        border-radius: 4px;
 
-      &:hover,
-      &:focus,
-      &:active {
-        text-decoration: underline;
+        .panel-header {
+          text-align: center;
+        }
       }
     }
   }
 
-  #mastodon-timeline {
-    display: flex;
-    -webkit-overflow-scrolling: touch;
-    -ms-overflow-style: -ms-autohiding-scrollbar;
-    font-family: $font-sans-serif, sans-serif;
-    font-size: 13px;
-    line-height: 18px;
-    font-weight: 400;
-    color: $primary-text-color;
-    width: 100%;
-    flex: 1 1 auto;
-    overflow: hidden;
-    height: 100%;
-
-    .column-header {
-      color: inherit;
-      font-family: inherit;
-      font-size: 16px;
-      line-height: inherit;
-      font-weight: inherit;
-      margin: 0;
-      padding: 0;
-    }
-
-    .column {
-      padding: 0;
-      border-radius: 4px;
-      overflow: hidden;
-      width: 100%;
-    }
-
-    .scrollable {
-      height: 400px;
-    }
-
-    p {
-      font-size: inherit;
-      line-height: inherit;
-      font-weight: inherit;
-      color: $primary-text-color;
-      margin-bottom: 20px;
-
-      &:last-child {
-        margin-bottom: 0;
-      }
+  @media screen and (max-width: 675px) {
+    .header-wrapper {
+      padding-top: 0;
 
-      a {
-        color: $secondary-text-color;
-        text-decoration: none;
+      &.compact {
+        padding-bottom: 0;
       }
-    }
-
-    .attachment-list__list {
-      margin-left: 0;
-      list-style: none;
-
-      li {
-        font-size: inherit;
-        line-height: inherit;
-        font-weight: inherit;
-        margin-bottom: 0;
-
-        a {
-          color: $dark-text-color;
-          text-decoration: none;
 
-          &:hover {
-            text-decoration: underline;
-          }
-        }
+      &.compact .hero .heading {
+        text-align: initial;
       }
     }
 
-    @media screen and (max-width: $column-breakpoint) {
-      display: none;
+    .header .container-alt,
+    .features .container-alt {
+      display: block;
     }
   }
 
-  &__features {
-    & > p {
-      padding-right: 60px;
-    }
-
-    .features-list {
-      margin: 40px 0;
-      margin-top: 30px;
-    }
-
-    &__action {
-      text-align: center;
-    }
+  .cta {
+    margin: 20px;
   }
+}
 
-  .features-list {
-    .features-list__row {
-      display: flex;
-      padding: 10px 0;
-      justify-content: space-between;
-
-      .visual {
-        flex: 0 0 auto;
-        display: flex;
-        align-items: center;
-        margin-left: 15px;
+.landing {
+  margin-bottom: 100px;
 
-        .fa {
-          display: block;
-          color: $darker-text-color;
-          font-size: 48px;
-        }
-      }
+  @media screen and (max-width: 738px) {
+    margin-bottom: 0;
+  }
 
-      .text {
-        font-size: 16px;
-        line-height: 30px;
-        color: $darker-text-color;
+  &__brand {
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding: 100px;
 
-        h6 {
-          font-size: inherit;
-          line-height: inherit;
-          margin-bottom: 0;
-        }
-      }
+    img {
+      height: 52px;
     }
 
-    @media screen and (min-width: $small-breakpoint) {
-      display: grid;
-      grid-gap: 30px;
-      grid-template-columns: 1fr 1fr;
-      grid-auto-columns: 50%;
-      grid-auto-rows: max-content;
+    @media screen and (max-width: $no-gap-breakpoint) {
+      padding: 0;
+      margin-bottom: 30px;
     }
   }
 
-  .footer-links {
-    padding-bottom: 50px;
-    text-align: right;
-    color: $dark-text-color;
+  .directory {
+    margin-top: 30px;
+    background: transparent;
+    box-shadow: none;
+    border-radius: 0;
+  }
 
-    p {
-      font-size: 14px;
-    }
+  .hero-widget {
+    margin-top: 30px;
+    margin-bottom: 0;
 
-    a {
-      color: inherit;
-      text-decoration: underline;
+    h4 {
+      padding: 10px;
+      text-transform: uppercase;
+      font-weight: 700;
+      font-size: 13px;
+      color: $darker-text-color;
     }
-  }
 
-  &__footer {
-    margin-top: 10px;
-    text-align: center;
-    color: $dark-text-color;
+    &__text {
+      border-radius: 0;
+      padding-bottom: 0;
+    }
 
-    p {
-      font-size: 14px;
+    &__footer {
+      background: $ui-base-color;
+      padding: 10px;
+      border-radius: 0 0 4px 4px;
+      display: flex;
 
-      a {
-        color: inherit;
-        text-decoration: underline;
+      &__column {
+        flex: 1 1 50%;
       }
     }
-  }
 
-  @media screen and (max-width: 840px) {
-    .container-alt {
-      padding: 0 20px;
-    }
+    .account {
+      padding: 10px 0;
+      border-bottom: 0;
 
-    .information-board {
-      .container-alt {
-        padding-right: 20px;
+      .account__display-name {
+        display: flex;
+        align-items: center;
       }
 
-      .panel {
-        position: static;
-        margin-top: 20px;
-        width: 100%;
-        border-radius: 4px;
-
-        .panel-header {
-          text-align: center;
-        }
+      .account__avatar {
+        width: 44px;
+        height: 44px;
+        background-size: 44px 44px;
       }
     }
-  }
 
-  @media screen and (max-width: 675px) {
-    .header-wrapper {
-      padding-top: 0;
+    &__counter {
+      padding: 10px;
 
-      &.compact {
-        padding-bottom: 0;
+      strong {
+        font-family: $font-display, sans-serif;
+        font-size: 15px;
+        font-weight: 700;
+        display: block;
       }
 
-      &.compact .hero .heading {
-        text-align: initial;
+      span {
+        font-size: 14px;
+        color: $darker-text-color;
       }
     }
+  }
 
-    .header .container-alt,
-    .features .container-alt {
-      display: block;
-    }
-
-    .header {
-      .links {
-        padding-top: 15px;
-        background: darken($ui-base-color, 4%);
+  .simple_form .user_agreement .label_input > label {
+    font-weight: 400;
+    color: $darker-text-color;
+  }
 
-        a {
-          padding: 12px 8px;
-        }
+  .simple_form p.lead {
+    color: $darker-text-color;
+    font-size: 15px;
+    line-height: 20px;
+    font-weight: 400;
+    margin-bottom: 25px;
+  }
 
-        .nav {
-          display: flex;
-          flex-flow: row wrap;
-          justify-content: space-around;
-        }
+  &__grid {
+    max-width: 960px;
+    margin: 0 auto;
+    display: grid;
+    grid-template-columns: minmax(0, 50%) minmax(0, 50%);
+    grid-gap: 30px;
 
-        .brand img {
-          left: 0;
-          top: 0;
-        }
-      }
+    @media screen and (max-width: 738px) {
+      grid-template-columns: minmax(0, 100%);
+      grid-gap: 10px;
 
-      .hero {
-        margin-top: 30px;
-        padding: 0;
+      &__column-login {
+        grid-row: 1;
+        display: flex;
+        flex-direction: column;
 
-        .heading {
-          padding: 30px 20px;
-          text-align: center;
+        .box-widget {
+          order: 2;
+          flex: 0 0 auto;
         }
 
-        .simple_form,
-        .closed-registrations-message {
-          background: darken($ui-base-color, 8%);
-          width: 100%;
-          border-radius: 0;
-          box-sizing: border-box;
+        .hero-widget {
+          margin-top: 0;
+          margin-bottom: 10px;
+          order: 1;
+          flex: 0 0 auto;
         }
       }
-    }
-  }
-
-  .cta {
-    margin: 20px;
-  }
 
-  &.tag-page {
-    @media screen and (max-width: $column-breakpoint) {
-      padding: 0;
-
-      .container {
-        padding: 0;
+      &__column-registration {
+        grid-row: 2;
       }
 
-      #mastodon-timeline {
-        display: flex;
-        height: 100vh;
-        border-radius: 0;
+      .directory {
+        margin-top: 10px;
       }
     }
 
-    .grid {
-      @media screen and (min-width: $small-breakpoint) {
-        grid-template-columns: 33% 67%;
-      }
-
-      .column-2 {
-        grid-column: 2;
-        grid-row: 1;
-      }
-    }
+    @media screen and (max-width: $no-gap-breakpoint) {
+      grid-gap: 0;
 
-    .brand {
-      text-align: unset;
-      padding: 0;
+      .hero-widget {
+        display: block;
+        margin-bottom: 0;
+        box-shadow: none;
 
-      img {
-        height: 48px;
-        width: auto;
+        &__img,
+        &__img img,
+        &__footer {
+          border-radius: 0;
+        }
       }
-    }
 
-    .cta {
-      margin: 0;
-
-      .button {
-        margin-right: 4px;
+      .hero-widget,
+      .box-widget,
+      .directory__tag {
+        border-bottom: 1px solid lighten($ui-base-color, 8%);
       }
-    }
 
-    @media screen and (max-width: $column-breakpoint) {
-      .grid {
-        grid-gap: 0;
+      .directory {
+        margin-top: 0;
 
-        .column-1 {
-          grid-column: 1;
-          grid-row: 1;
-        }
+        &__tag {
+          margin-bottom: 0;
 
-        .column-2 {
-          display: none;
+          & > a,
+          & > div {
+            border-radius: 0;
+            box-shadow: none;
+          }
+
+          &:last-child {
+            border-bottom: 0;
+          }
         }
       }
     }
   }
 }
+
+.brand {
+  position: relative;
+  text-decoration: none;
+}
+
+.brand__tagline {
+  display: block;
+  position: absolute;
+  bottom: -10px;
+  left: 50px;
+  width: 300px;
+  color: $ui-primary-color;
+  text-decoration: none;
+  font-size: 14px;
+
+  @media screen and (max-width: $no-gap-breakpoint) {
+    position: static;
+    width: auto;
+    margin-top: 20px;
+    color: $dark-text-color;
+  }
+}
+
diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss
index 4dbbaa1e8..42f53f525 100644
--- a/app/javascript/styles/mastodon/admin.scss
+++ b/app/javascript/styles/mastodon/admin.scss
@@ -705,3 +705,11 @@ a.name-tag,
   overflow: hidden;
   text-overflow: ellipsis;
 }
+
+.ellipsized-ip {
+  display: inline-block;
+  max-width: 120px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  vertical-align: middle;
+}
diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss
index bab982706..6051c1d00 100644
--- a/app/javascript/styles/mastodon/forms.scss
+++ b/app/javascript/styles/mastodon/forms.scss
@@ -68,6 +68,17 @@ code {
         top: 2px;
         left: 0;
       }
+
+      label a {
+        color: $highlight-text-color;
+        text-decoration: underline;
+
+        &:hover,
+        &:active,
+        &:focus {
+          text-decoration: none;
+        }
+      }
     }
   }
 
@@ -305,7 +316,7 @@ code {
       box-shadow: none;
     }
 
-    &:focus:invalid {
+    &:focus:invalid:not(:placeholder-shown) {
       border-color: lighten($error-red, 12%);
     }
 
@@ -346,6 +357,10 @@ code {
     }
   }
 
+  .input.disabled {
+    opacity: 0.5;
+  }
+
   .actions {
     margin-top: 30px;
     display: flex;
@@ -392,6 +407,10 @@ code {
       background-color: darken($ui-highlight-color, 5%);
     }
 
+    &:disabled:hover {
+      background-color: $ui-primary-color;
+    }
+
     &.negative {
       background: $error-value-color;
 
diff --git a/app/javascript/styles/mastodon/polls.scss b/app/javascript/styles/mastodon/polls.scss
index 4f8c94d83..d8bc5473a 100644
--- a/app/javascript/styles/mastodon/polls.scss
+++ b/app/javascript/styles/mastodon/polls.scss
@@ -190,3 +190,15 @@
     color: darken($simple-background-color, 14%);
   }
 }
+
+.muted .poll {
+  color: $dark-text-color;
+
+  &__chart {
+    background: rgba(darken($ui-primary-color, 14%), 0.2);
+
+    &.leading {
+      background: rgba($ui-highlight-color, 0.2);
+    }
+  }
+}
diff --git a/app/javascript/styles/mastodon/tables.scss b/app/javascript/styles/mastodon/tables.scss
index adb75afe5..9e8785679 100644
--- a/app/javascript/styles/mastodon/tables.scss
+++ b/app/javascript/styles/mastodon/tables.scss
@@ -82,6 +82,10 @@
       }
     }
   }
+
+  &--invites tbody td {
+    vertical-align: middle;
+  }
 }
 
 .table-wrapper {
diff --git a/app/javascript/styles/mastodon/widgets.scss b/app/javascript/styles/mastodon/widgets.scss
index 1eaf30c5b..645192ea4 100644
--- a/app/javascript/styles/mastodon/widgets.scss
+++ b/app/javascript/styles/mastodon/widgets.scss
@@ -295,6 +295,11 @@
       cursor: default;
     }
 
+    &.disabled > div {
+      opacity: 0.5;
+      cursor: default;
+    }
+
     h4 {
       flex: 1 1 auto;
       font-size: 18px;
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
index 7e4e57ead..8fe7b9138 100644
--- a/app/lib/activitypub/activity/create.rb
+++ b/app/lib/activitypub/activity/create.rb
@@ -241,8 +241,13 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 
   def poll_vote?
     return false if replied_to_status.nil? || replied_to_status.poll.nil? || !replied_to_status.local? || !replied_to_status.poll.options.include?(@object['name'])
-    return true if replied_to_status.poll.expired?
-    replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id'])
+
+    unless replied_to_status.poll.expired?
+      replied_to_status.poll.votes.create!(account: @account, choice: replied_to_status.poll.options.index(@object['name']), uri: @object['id'])
+      ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, replied_to_status.id) unless replied_to_status.poll.hide_totals?
+    end
+
+    true
   end
 
   def resolve_thread(status)
@@ -369,15 +374,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
     @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
   end
 
-  def invalid_origin?(url)
-    return true if unsupported_uri_scheme?(url)
-
-    needle   = Addressable::URI.parse(url).host
-    haystack = Addressable::URI.parse(@account.uri).host
-
-    !haystack.casecmp(needle).zero?
-  end
-
   def reply_to_local?
     !replied_to_status.nil? && replied_to_status.account.local?
   end
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
index dc76dd3e2..4236af071 100644
--- a/app/lib/activitypub/activity/delete.rb
+++ b/app/lib/activitypub/activity/delete.rb
@@ -75,13 +75,4 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity
   def lock_options
     { redis: Redis.current, key: "create:#{object_uri}" }
   end
-
-  def invalid_origin?(url)
-    return true if unsupported_uri_scheme?(url)
-
-    needle   = Addressable::URI.parse(url).host
-    haystack = Addressable::URI.parse(@account.uri).host
-
-    !haystack.casecmp(needle).zero?
-  end
 end
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
index 67dae3f81..bc9a63f98 100644
--- a/app/lib/activitypub/activity/update.rb
+++ b/app/lib/activitypub/activity/update.rb
@@ -4,7 +4,11 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
   SUPPORTED_TYPES = %w(Application Group Organization Person Service).freeze
 
   def perform
-    update_account if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
+    if equals_or_includes_any?(@object['type'], SUPPORTED_TYPES)
+      update_account
+    elsif equals_or_includes_any?(@object['type'], %w(Question))
+      update_poll
+    end
   end
 
   private
@@ -14,4 +18,13 @@ class ActivityPub::Activity::Update < ActivityPub::Activity
 
     ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object, signed_with_known_key: true)
   end
+
+  def update_poll
+    return reject_payload! if invalid_origin?(@object['id'])
+
+    status = Status.find_by(uri: object_uri, account_id: @account.id)
+    return if status.nil? || status.poll.nil?
+
+    ActivityPub::ProcessPollService.new.call(status.poll, @object)
+  end
 end
diff --git a/app/lib/request.rb b/app/lib/request.rb
index ef4aeaf29..e555ae6a1 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -171,7 +171,7 @@ class Request
         outer_e = nil
 
         Resolv::DNS.open do |dns|
-          dns.timeouts = 1
+          dns.timeouts = 5
 
           addresses = dns.getaddresses(host).take(2)
           time_slot = 10.0 / addresses.size
diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb
index a30468eb8..db154cad5 100644
--- a/app/mailers/admin_mailer.rb
+++ b/app/mailers/admin_mailer.rb
@@ -14,4 +14,14 @@ class AdminMailer < ApplicationMailer
       mail to: @me.user_email, subject: I18n.t('admin_mailer.new_report.subject', instance: @instance, id: @report.id)
     end
   end
+
+  def new_pending_account(recipient, user)
+    @account  = user.account
+    @me       = recipient
+    @instance = Rails.configuration.x.local_domain
+
+    locale_for_account(@me) do
+      mail to: @me.user_email, subject: I18n.t('admin_mailer.new_pending_account.subject', instance: @instance, username: @account.username)
+    end
+  end
 end
diff --git a/app/models/account.rb b/app/models/account.rb
index 79eecc306..6b539f004 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -108,6 +108,8 @@ class Account < ApplicationRecord
            :current_sign_in_ip,
            :current_sign_in_at,
            :confirmed?,
+           :approved?,
+           :pending?,
            :admin?,
            :moderator?,
            :staff?,
@@ -474,6 +476,7 @@ class Account < ApplicationRecord
 
   before_create :generate_keys
   before_validation :prepare_contents, if: :local?
+  before_validation :prepare_username, on: :create
   before_destroy :clean_feed_manager
 
   private
@@ -483,6 +486,10 @@ class Account < ApplicationRecord
     note&.strip!
   end
 
+  def prepare_username
+    username&.squish!
+  end
+
   def generate_keys
     return unless local? && !Rails.env.test?
 
diff --git a/app/models/account_filter.rb b/app/models/account_filter.rb
index b10f50db7..d2503100c 100644
--- a/app/models/account_filter.rb
+++ b/app/models/account_filter.rb
@@ -22,7 +22,7 @@ class AccountFilter
 
   def set_defaults!
     params['local']  = '1' if params['remote'].blank?
-    params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank?
+    params['active'] = '1' if params['suspended'].blank? && params['silenced'].blank? && params['pending'].blank?
   end
 
   def scope_for(key, value)
@@ -35,6 +35,8 @@ class AccountFilter
       Account.where(domain: value)
     when 'active'
       Account.without_suspended
+    when 'pending'
+      accounts_with_users.merge User.pending
     when 'silenced'
       Account.silenced
     when 'suspended'
diff --git a/app/models/concerns/expireable.rb b/app/models/concerns/expireable.rb
index 2c0631476..f7d2bab49 100644
--- a/app/models/concerns/expireable.rb
+++ b/app/models/concerns/expireable.rb
@@ -18,7 +18,11 @@ module Expireable
     end
 
     def expired?
-      !expires_at.nil? && expires_at < Time.now.utc
+      expires? && expires_at < Time.now.utc
+    end
+
+    def expires?
+      !expires_at.nil?
     end
   end
 end
diff --git a/app/models/concerns/ldap_authenticable.rb b/app/models/concerns/ldap_authenticable.rb
new file mode 100644
index 000000000..e1b5e3832
--- /dev/null
+++ b/app/models/concerns/ldap_authenticable.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module LdapAuthenticable
+  extend ActiveSupport::Concern
+
+  def ldap_setup(_attributes)
+    self.confirmed_at = Time.now.utc
+    self.admin        = false
+
+    save!
+  end
+
+  class_methods do
+    def ldap_get_user(attributes = {})
+      resource = joins(:account).find_by(accounts: { username: attributes[Devise.ldap_uid.to_sym].first })
+
+      if resource.blank?
+        resource = new(email: attributes[:mail].first, agreement: true, account_attributes: { username: attributes[Devise.ldap_uid.to_sym].first })
+        resource.ldap_setup(attributes)
+      end
+
+      resource
+    end
+  end
+end
diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb
index 4dd2e9383..1b28b8162 100644
--- a/app/models/concerns/omniauthable.rb
+++ b/app/models/concerns/omniauthable.rb
@@ -7,6 +7,8 @@ module Omniauthable
   TEMP_EMAIL_REGEX = /\Achange@me/
 
   included do
+    devise :omniauthable
+
     def omniauth_providers
       Devise.omniauth_configs.keys
     end
diff --git a/app/models/concerns/pam_authenticable.rb b/app/models/concerns/pam_authenticable.rb
new file mode 100644
index 000000000..2f651c1a3
--- /dev/null
+++ b/app/models/concerns/pam_authenticable.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+module PamAuthenticable
+  extend ActiveSupport::Concern
+
+  included do
+    devise :pam_authenticatable if ENV['PAM_ENABLED'] == 'true'
+
+    def pam_conflict(_attributes)
+      # Block pam login tries on traditional account
+    end
+
+    def pam_conflict?
+      if Devise.pam_authentication
+        encrypted_password.present? && pam_managed_user?
+      else
+        false
+      end
+    end
+
+    def pam_get_name
+      if account.present?
+        account.username
+      else
+        super
+      end
+    end
+
+    def pam_setup(_attributes)
+      account = Account.new(username: pam_get_name)
+      account.save!(validate: false)
+
+      self.email        = "#{account.username}@#{find_pam_suffix}" if email.nil? && find_pam_suffix
+      self.confirmed_at = Time.now.utc
+      self.admin        = false
+      self.account      = account
+
+      account.destroy! unless save
+    end
+
+    def self.pam_get_user(attributes = {})
+      return nil unless attributes[:email]
+
+      resource = begin
+        if Devise.check_at_sign && !attributes[:email].index('@')
+          joins(:account).find_by(accounts: { username: attributes[:email] })
+        else
+          find_by(email: attributes[:email])
+        end
+      end
+
+      if resource.nil?
+        resource = new(email: attributes[:email], agreement: true)
+
+        if Devise.check_at_sign && !resource[:email].index('@')
+          resource[:email] = Rpam2.getenv(resource.find_pam_service, attributes[:email], attributes[:password], 'email', false)
+          resource[:email] = "#{attributes[:email]}@#{resource.find_pam_suffix}" unless resource[:email]
+        end
+      end
+
+      resource
+    end
+
+    def self.authenticate_with_pam(attributes = {})
+      super if Devise.pam_authentication
+    end
+  end
+end
diff --git a/app/models/concerns/user_roles.rb b/app/models/concerns/user_roles.rb
new file mode 100644
index 000000000..58dffdc46
--- /dev/null
+++ b/app/models/concerns/user_roles.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module UserRoles
+  extend ActiveSupport::Concern
+
+  included do
+    scope :admins, -> { where(admin: true) }
+    scope :moderators, -> { where(moderator: true) }
+    scope :staff, -> { admins.or(moderators) }
+  end
+
+  def staff?
+    admin? || moderator?
+  end
+
+  def role
+    if admin?
+      'admin'
+    elsif moderator?
+      'moderator'
+    else
+      'user'
+    end
+  end
+
+  def role?(role)
+    case role
+    when 'user'
+      true
+    when 'moderator'
+      staff?
+    when 'admin'
+      admin?
+    else
+      false
+    end
+  end
+
+  def promote!
+    if moderator?
+      update!(moderator: false, admin: true)
+    elsif !admin?
+      update!(moderator: true)
+    end
+  end
+
+  def demote!
+    if admin?
+      update!(admin: false, moderator: true)
+    elsif moderator?
+      update!(moderator: false)
+    end
+  end
+end
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index d568200ed..929c65793 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -18,8 +18,8 @@ class Form::AdminSettings
     :site_extended_description=,
     :site_terms,
     :site_terms=,
-    :open_registrations,
-    :open_registrations=,
+    :registrations_mode,
+    :registrations_mode=,
     :closed_registrations_message,
     :closed_registrations_message=,
     :open_deletion,
diff --git a/app/models/notification.rb b/app/models/notification.rb
index 2f0a9b78c..982136c05 100644
--- a/app/models/notification.rb
+++ b/app/models/notification.rb
@@ -22,6 +22,7 @@ class Notification < ApplicationRecord
     follow:         'Follow',
     follow_request: 'FollowRequest',
     favourite:      'Favourite',
+    poll:           'Poll',
   }.freeze
 
   STATUS_INCLUDES = [:account, :application, :media_attachments, :tags, active_mentions: :account, reblog: [:account, :application, :media_attachments, :tags, active_mentions: :account]].freeze
@@ -35,6 +36,7 @@ class Notification < ApplicationRecord
   belongs_to :follow,         foreign_type: 'Follow',        foreign_key: 'activity_id', optional: true
   belongs_to :follow_request, foreign_type: 'FollowRequest', foreign_key: 'activity_id', optional: true
   belongs_to :favourite,      foreign_type: 'Favourite',     foreign_key: 'activity_id', optional: true
+  belongs_to :poll,           foreign_type: 'Poll',          foreign_key: 'activity_id', optional: true
 
   validates :account_id, uniqueness: { scope: [:activity_type, :activity_id] }
   validates :activity_type, inclusion: { in: TYPE_CLASS_MAP.values }
@@ -44,7 +46,7 @@ class Notification < ApplicationRecord
     where(activity_type: types)
   }
 
-  cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account
+  cache_associated :from_account, status: STATUS_INCLUDES, mention: [status: STATUS_INCLUDES], favourite: [:account, status: STATUS_INCLUDES], follow: :account, poll: [status: STATUS_INCLUDES]
 
   def type
     @type ||= TYPE_CLASS_MAP.invert[activity_type].to_sym
@@ -58,6 +60,8 @@ class Notification < ApplicationRecord
       favourite&.status
     when :mention
       mention&.status
+    when :poll
+      poll&.status
     end
   end
 
@@ -97,7 +101,7 @@ class Notification < ApplicationRecord
     return unless new_record?
 
     case activity_type
-    when 'Status', 'Follow', 'Favourite', 'FollowRequest'
+    when 'Status', 'Follow', 'Favourite', 'FollowRequest', 'Poll'
       self.from_account_id = activity&.account_id
     when 'Mention'
       self.from_account_id = activity&.status&.account_id
diff --git a/app/models/poll.rb b/app/models/poll.rb
index 09f0b65ec..6df230337 100644
--- a/app/models/poll.rb
+++ b/app/models/poll.rb
@@ -26,6 +26,8 @@ class Poll < ApplicationRecord
 
   has_many :votes, class_name: 'PollVote', inverse_of: :poll, dependent: :destroy
 
+  has_many :notifications, as: :activity, dependent: :destroy
+
   validates :options, presence: true
   validates :expires_at, presence: true, if: :local?
   validates_with PollValidator, on: :create, if: :local?
diff --git a/app/models/tag.rb b/app/models/tag.rb
index 788a678bd..7db76d157 100644
--- a/app/models/tag.rb
+++ b/app/models/tag.rb
@@ -72,6 +72,14 @@ class Tag < ApplicationRecord
          .limit(limit)
          .offset(offset)
     end
+
+    def find_normalized(name)
+      find_by(name: name.mb_chars.downcase.to_s)
+    end
+
+    def find_normalized!(name)
+      find_normalized(name) || raise(ActiveRecord::RecordNotFound)
+    end
   end
 
   private
diff --git a/app/models/user.rb b/app/models/user.rb
index f91ed7015..47657a670 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -37,11 +37,12 @@
 #  remember_token            :string
 #  chosen_languages          :string           is an Array
 #  created_by_application_id :bigint(8)
+#  approved                  :boolean          default(TRUE), not null
 #
 
 class User < ApplicationRecord
   include Settings::Extend
-  include Omniauthable
+  include UserRoles
 
   # The home and list feeds will be stored in Redis for this amount
   # of time, and status fan-out to followers will include only people
@@ -61,9 +62,9 @@ class User < ApplicationRecord
   devise :registerable, :recoverable, :rememberable, :trackable, :validatable,
          :confirmable
 
-  devise :pam_authenticatable if ENV['PAM_ENABLED'] == 'true'
-
-  devise :omniauthable
+  include Omniauthable
+  include PamAuthenticable
+  include LdapAuthenticable
 
   belongs_to :account, inverse_of: :user
   belongs_to :invite, counter_cache: :uses, optional: true
@@ -79,9 +80,8 @@ class User < ApplicationRecord
   validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
 
   scope :recent, -> { order(id: :desc) }
-  scope :admins, -> { where(admin: true) }
-  scope :moderators, -> { where(moderator: true) }
-  scope :staff, -> { admins.or(moderators) }
+  scope :pending, -> { where(approved: false) }
+  scope :approved, -> { where(approved: true) }
   scope :confirmed, -> { where.not(confirmed_at: nil) }
   scope :enabled, -> { where(disabled: false) }
   scope :inactive, -> { where(arel_table[:current_sign_in_at].lt(ACTIVE_DURATION.ago)) }
@@ -90,6 +90,7 @@ class User < ApplicationRecord
   scope :emailable, -> { confirmed.enabled.joins(:account).merge(Account.searchable) }
 
   before_validation :sanitize_languages
+  before_create :set_approved
 
   # This avoids a deprecation warning from Rails 5.1
   # It seems possible that a future release of devise-two-factor will
@@ -104,39 +105,6 @@ class User < ApplicationRecord
 
   attr_reader :invite_code
 
-  def pam_conflict(_)
-    # block pam login tries on traditional account
-    nil
-  end
-
-  def pam_conflict?
-    return false unless Devise.pam_authentication
-    encrypted_password.present? && pam_managed_user?
-  end
-
-  def pam_get_name
-    return account.username if account.present?
-    super
-  end
-
-  def pam_setup(_attributes)
-    acc = Account.new(username: pam_get_name)
-    acc.save!(validate: false)
-
-    self.email = "#{acc.username}@#{find_pam_suffix}" if email.nil? && find_pam_suffix
-    self.confirmed_at = Time.now.utc
-    self.admin = false
-    self.account = acc
-
-    acc.destroy! unless save
-  end
-
-  def ldap_setup(_attributes)
-    self.confirmed_at = Time.now.utc
-    self.admin = false
-    save!
-  end
-
   def confirmed?
     confirmed_at.present?
   end
@@ -145,33 +113,6 @@ class User < ApplicationRecord
     invite_id.present?
   end
 
-  def staff?
-    admin? || moderator?
-  end
-
-  def role
-    if admin?
-      'admin'
-    elsif moderator?
-      'moderator'
-    else
-      'user'
-    end
-  end
-
-  def role?(role)
-    case role
-    when 'user'
-      true
-    when 'moderator'
-      staff?
-    when 'admin'
-      admin?
-    else
-      false
-    end
-  end
-
   def disable!
     update!(disabled: true,
             last_sign_in_at: current_sign_in_at,
@@ -186,7 +127,12 @@ class User < ApplicationRecord
     new_user = !confirmed?
 
     super
-    prepare_new_user! if new_user
+
+    if new_user && approved?
+      prepare_new_user!
+    elsif new_user
+      notify_staff_about_pending_account!
+    end
   end
 
   def confirm!
@@ -194,28 +140,32 @@ class User < ApplicationRecord
 
     skip_confirmation!
     save!
-    prepare_new_user! if new_user
+
+    prepare_new_user! if new_user && approved?
   end
 
-  def update_tracked_fields!(request)
-    super
-    prepare_returning_user!
+  def pending?
+    !approved?
   end
 
-  def promote!
-    if moderator?
-      update!(moderator: false, admin: true)
-    elsif !admin?
-      update!(moderator: true)
-    end
+  def active_for_authentication?
+    super && approved?
   end
 
-  def demote!
-    if admin?
-      update!(admin: false, moderator: true)
-    elsif moderator?
-      update!(moderator: false)
-    end
+  def inactive_message
+    !approved? ? :pending : super
+  end
+
+  def approve!
+    return if approved?
+
+    update!(approved: true)
+    prepare_new_user!
+  end
+
+  def update_tracked_fields!(request)
+    super
+    prepare_returning_user!
   end
 
   def disable_two_factor!
@@ -297,43 +247,6 @@ class User < ApplicationRecord
     super
   end
 
-  def self.pam_get_user(attributes = {})
-    return nil unless attributes[:email]
-
-    resource =
-      if Devise.check_at_sign && !attributes[:email].index('@')
-        joins(:account).find_by(accounts: { username: attributes[:email] })
-      else
-        find_by(email: attributes[:email])
-      end
-
-    if resource.blank?
-      resource = new(email: attributes[:email], agreement: true)
-
-      if Devise.check_at_sign && !resource[:email].index('@')
-        resource[:email] = Rpam2.getenv(resource.find_pam_service, attributes[:email], attributes[:password], 'email', false)
-        resource[:email] = "#{attributes[:email]}@#{resource.find_pam_suffix}" unless resource[:email]
-      end
-    end
-    resource
-  end
-
-  def self.ldap_get_user(attributes = {})
-    resource = joins(:account).find_by(accounts: { username: attributes[Devise.ldap_uid.to_sym].first })
-
-    if resource.blank?
-      resource = new(email: attributes[:mail].first, agreement: true, account_attributes: { username: attributes[Devise.ldap_uid.to_sym].first })
-      resource.ldap_setup(attributes)
-    end
-
-    resource
-  end
-
-  def self.authenticate_with_pam(attributes = {})
-    return nil unless Devise.pam_authentication
-    super
-  end
-
   def show_all_media?
     setting_display_media == 'show_all'
   end
@@ -350,6 +263,10 @@ class User < ApplicationRecord
 
   private
 
+  def set_approved
+    self.approved = Setting.registrations_mode == 'open' || invited?
+  end
+
   def sanitize_languages
     return if chosen_languages.nil?
     chosen_languages.reject!(&:blank?)
@@ -367,6 +284,13 @@ class User < ApplicationRecord
     regenerate_feed! if needs_feed_update?
   end
 
+  def notify_staff_about_pending_account!
+    User.staff.includes(:account).each do |u|
+      next unless u.allows_report_emails?
+      AdminMailer.new_pending_account(u.account, self).deliver_later
+    end
+  end
+
   def regenerate_feed!
     return unless Redis.current.setnx("account:#{account_id}:regeneration", true)
     Redis.current.expire("account:#{account_id}:regeneration", 1.day.seconds)
diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb
index 57af5c61c..d832bff75 100644
--- a/app/policies/user_policy.rb
+++ b/app/policies/user_policy.rb
@@ -21,6 +21,14 @@ class UserPolicy < ApplicationPolicy
     staff?
   end
 
+  def approve?
+    staff? && !record.approved?
+  end
+
+  def reject?
+    staff? && !record.approved?
+  end
+
   def disable?
     staff? && !record.admin?
   end
@@ -36,7 +44,7 @@ class UserPolicy < ApplicationPolicy
   private
 
   def promoteable?
-    !record.staff? || !record.admin?
+    record.approved? && (!record.staff? || !record.admin?)
   end
 
   def demoteable?
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index d8670f124..94a2c1692 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -2,9 +2,7 @@
 
 class InstancePresenter
   delegate(
-    :closed_registrations_message,
     :site_contact_email,
-    :open_registrations,
     :site_title,
     :site_short_description,
     :site_description,
@@ -21,6 +19,10 @@ class InstancePresenter
     Rails.cache.fetch('user_count') { User.confirmed.joins(:account).merge(Account.without_suspended).count }
   end
 
+  def active_user_count
+    Rails.cache.fetch('active_user_count') { Redis.current.pfcount(*(0..3).map { |i| "activity:logins:#{i.weeks.ago.utc.to_date.cweek}" }) }
+  end
+
   def status_count
     Rails.cache.fetch('local_status_count') { Account.local.joins(:account_stat).sum('account_stats.statuses_count') }.to_i
   end
@@ -29,6 +31,10 @@ class InstancePresenter
     Rails.cache.fetch('distinct_domain_count') { Account.distinct.count(:domain) }
   end
 
+  def sample_accounts
+    Rails.cache.fetch('sample_accounts', expires_in: 12.hours) { Account.discoverable.popular.limit(3) }
+  end
+
   def version_number
     Mastodon::Version
   end
diff --git a/app/serializers/activitypub/update_poll_serializer.rb b/app/serializers/activitypub/update_poll_serializer.rb
new file mode 100644
index 000000000..f7933346f
--- /dev/null
+++ b/app/serializers/activitypub/update_poll_serializer.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+class ActivityPub::UpdatePollSerializer < ActiveModel::Serializer
+  attributes :id, :type, :actor, :to
+
+  has_one :object, serializer: ActivityPub::NoteSerializer
+
+  def id
+    [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.poll.updated_at.to_i].join
+  end
+
+  def type
+    'Update'
+  end
+
+  def actor
+    ActivityPub::TagManager.instance.uri_for(object)
+  end
+
+  def to
+    ActivityPub::TagManager.instance.to(object)
+  end
+
+  def cc
+    ActivityPub::TagManager.instance.cc(object)
+  end
+end
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index 30e8dcbc1..97fed63d1 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -65,7 +65,7 @@ class REST::InstanceSerializer < ActiveModel::Serializer
   end
 
   def registrations
-    Setting.open_registrations && !Rails.configuration.x.single_user_mode
+    Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode
   end
 
   private
diff --git a/app/serializers/rest/notification_serializer.rb b/app/serializers/rest/notification_serializer.rb
index 541a6b8b5..80812ad0d 100644
--- a/app/serializers/rest/notification_serializer.rb
+++ b/app/serializers/rest/notification_serializer.rb
@@ -11,6 +11,6 @@ class REST::NotificationSerializer < ActiveModel::Serializer
   end
 
   def status_type?
-    [:favourite, :reblog, :mention].include?(object.type)
+    [:favourite, :reblog, :mention, :poll].include?(object.type)
   end
 end
diff --git a/app/services/activitypub/fetch_remote_poll_service.rb b/app/services/activitypub/fetch_remote_poll_service.rb
index 4f9814fcd..44a23712c 100644
--- a/app/services/activitypub/fetch_remote_poll_service.rb
+++ b/app/services/activitypub/fetch_remote_poll_service.rb
@@ -4,54 +4,7 @@ class ActivityPub::FetchRemotePollService < BaseService
   include JsonLdHelper
 
   def call(poll, on_behalf_of = nil)
-    @json = fetch_resource(poll.status.uri, true, on_behalf_of)
-
-    return unless supported_context? && expected_type?
-
-    expires_at = begin
-      if @json['closed'].is_a?(String)
-        @json['closed']
-      elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
-        Time.now.utc
-      else
-        @json['endTime']
-      end
-    end
-
-    items = begin
-      if @json['anyOf'].is_a?(Array)
-        @json['anyOf']
-      else
-        @json['oneOf']
-      end
-    end
-
-    latest_options = items.map { |item| item['name'].presence || item['content'] }
-
-    # If for some reasons the options were changed, it invalidates all previous
-    # votes, so we need to remove them
-    poll.votes.delete_all if latest_options != poll.options
-
-    begin
-      poll.update!(
-        last_fetched_at: Time.now.utc,
-        expires_at: expires_at,
-        options: latest_options,
-        cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
-      )
-    rescue ActiveRecord::StaleObjectError
-      poll.reload
-      retry
-    end
-  end
-
-  private
-
-  def supported_context?
-    super(@json)
-  end
-
-  def expected_type?
-    equals_or_includes_any?(@json['type'], %w(Question))
+    json = fetch_resource(poll.status.uri, true, on_behalf_of)
+    ActivityPub::ProcessPollService.new.call(poll, json)
   end
 end
diff --git a/app/services/activitypub/fetch_replies_service.rb b/app/services/activitypub/fetch_replies_service.rb
index 569d0d7c1..8cb309e52 100644
--- a/app/services/activitypub/fetch_replies_service.rb
+++ b/app/services/activitypub/fetch_replies_service.rb
@@ -46,13 +46,4 @@ class ActivityPub::FetchRepliesService < BaseService
     # Also limit to 5 fetched replies to limit potential for DoS.
     @items.map { |item| value_or_id(item) }.reject { |uri| invalid_origin?(uri) }.take(5)
   end
-
-  def invalid_origin?(url)
-    return true if unsupported_uri_scheme?(url)
-
-    needle   = Addressable::URI.parse(url).host
-    haystack = Addressable::URI.parse(@account.uri).host
-
-    !haystack.casecmp(needle).zero?
-  end
 end
diff --git a/app/services/activitypub/process_poll_service.rb b/app/services/activitypub/process_poll_service.rb
new file mode 100644
index 000000000..ee248169d
--- /dev/null
+++ b/app/services/activitypub/process_poll_service.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessPollService < BaseService
+  include JsonLdHelper
+
+  def call(poll, json)
+    @json = json
+    return unless supported_context? && expected_type?
+
+    previous_expires_at = poll.expires_at
+
+    expires_at = begin
+      if @json['closed'].is_a?(String)
+        @json['closed']
+      elsif !@json['closed'].nil? && !@json['closed'].is_a?(FalseClass)
+        Time.now.utc
+      else
+        @json['endTime']
+      end
+    end
+
+    items = begin
+      if @json['anyOf'].is_a?(Array)
+        @json['anyOf']
+      else
+        @json['oneOf']
+      end
+    end
+
+    latest_options = items.map { |item| item['name'].presence || item['content'] }
+
+    # If for some reasons the options were changed, it invalidates all previous
+    # votes, so we need to remove them
+    poll.votes.delete_all if latest_options != poll.options
+
+    begin
+      poll.update!(
+        last_fetched_at: Time.now.utc,
+        expires_at: expires_at,
+        options: latest_options,
+        cached_tallies: items.map { |item| item.dig('replies', 'totalItems') || 0 }
+      )
+    rescue ActiveRecord::StaleObjectError
+      poll.reload
+      retry
+    end
+
+    # If the poll had no expiration date set but now has, and people have voted,
+    # schedule a notification.
+    if previous_expires_at.nil? && poll.expires_at.present? && poll.votes.exists?
+      PollExpirationNotifyWorker.perform_at(poll.expires_at + 5.minutes, poll.id)
+    end
+  end
+
+  private
+
+  def supported_context?
+    super(@json)
+  end
+
+  def expected_type?
+    equals_or_includes_any?(@json['type'], %w(Question))
+  end
+end
diff --git a/app/services/app_sign_up_service.rb b/app/services/app_sign_up_service.rb
index d621cc462..6dee9cd81 100644
--- a/app/services/app_sign_up_service.rb
+++ b/app/services/app_sign_up_service.rb
@@ -18,6 +18,6 @@ class AppSignUpService < BaseService
   private
 
   def allowed_registrations?
-    Setting.open_registrations && !Rails.configuration.x.single_user_mode
+    Setting.registrations_mode != 'none' && !Rails.configuration.x.single_user_mode
   end
 end
diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb
index b80ceef03..b5c721589 100644
--- a/app/services/notify_service.rb
+++ b/app/services/notify_service.rb
@@ -38,6 +38,10 @@ class NotifyService < BaseService
     false
   end
 
+  def blocked_poll?
+    false
+  end
+
   def following_sender?
     return @following_sender if defined?(@following_sender)
     @following_sender = @recipient.following?(@notification.from_account) || @recipient.requested?(@notification.from_account)
@@ -88,7 +92,7 @@ class NotifyService < BaseService
 
   def blocked?
     blocked   = @recipient.suspended?                            # Skip if the recipient account is suspended anyway
-    blocked ||= from_self?                                       # Skip for interactions with self
+    blocked ||= from_self? && @notification.type != :poll        # Skip for interactions with self
 
     return blocked if message? && from_staff?
 
diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb
index 8a9d26c56..b9952369d 100644
--- a/app/services/post_status_service.rb
+++ b/app/services/post_status_service.rb
@@ -91,10 +91,13 @@ class PostStatusService < BaseService
   def postprocess_status!
     LinkCrawlWorker.perform_async(@status.id) unless @status.spoiler_text?
     DistributionWorker.perform_async(@status.id)
+
     unless @status.local_only?
       Pubsubhubbub::DistributionWorker.perform_async(@status.stream_entry.id)
       ActivityPub::DistributionWorker.perform_async(@status.id)
     end
+
+    PollExpirationNotifyWorker.perform_at(@status.poll.expires_at, @status.poll.id) if @status.poll
   end
 
   def validate_media!
diff --git a/app/services/remove_status_service.rb b/app/services/remove_status_service.rb
index 99c8e6cbb..7eec11ddf 100644
--- a/app/services/remove_status_service.rb
+++ b/app/services/remove_status_service.rb
@@ -10,7 +10,7 @@ class RemoveStatusService < BaseService
     @account      = status.account
     @tags         = status.tags.pluck(:name).to_a
     @mentions     = status.active_mentions.includes(:account).to_a
-    @reblogs      = status.reblogs.to_a
+    @reblogs      = status.reblogs.includes(:account).to_a
     @stream_entry = status.stream_entry
     @options      = options
 
@@ -78,8 +78,8 @@ class RemoveStatusService < BaseService
     end
 
     # ActivityPub
-    ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:inbox_url)) do |target_account|
-      [signed_activity_json, @account.id, target_account.inbox_url]
+    ActivityPub::DeliveryWorker.push_bulk(target_accounts.select(&:activitypub?).uniq(&:preferred_inbox_url)) do |target_account|
+      [signed_activity_json, @account.id, target_account.preferred_inbox_url]
     end
   end
 
diff --git a/app/services/vote_service.rb b/app/services/vote_service.rb
index 5b80da03a..0cace6c00 100644
--- a/app/services/vote_service.rb
+++ b/app/services/vote_service.rb
@@ -19,8 +19,27 @@ class VoteService < BaseService
       end
     end
 
-    return if @poll.account.local?
+    if @poll.account.local?
+      distribute_poll!
+    else
+      deliver_votes!
+      queue_final_poll_check!
+    end
+  end
+
+  private
+
+  def distribute_poll!
+    return if @poll.hide_totals?
+    ActivityPub::DistributePollUpdateWorker.perform_in(3.minutes, @poll.status.id)
+  end
+
+  def queue_final_poll_check!
+    return unless @poll.expires?
+    PollExpirationNotifyWorker.perform_at(@poll.expires_at + 5.minutes, @poll.id)
+  end
 
+  def deliver_votes!
     @votes.each do |vote|
       ActivityPub::DeliveryWorker.perform_async(
         build_json(vote),
@@ -30,8 +49,6 @@ class VoteService < BaseService
     end
   end
 
-  private
-
   def build_json(vote)
     ActiveModelSerializers::SerializableResource.new(
       vote,
diff --git a/app/views/about/_features.html.haml b/app/views/about/_features.html.haml
deleted file mode 100644
index 8fbc6b760..000000000
--- a/app/views/about/_features.html.haml
+++ /dev/null
@@ -1,25 +0,0 @@
-.features-list
-  .features-list__row
-    .text
-      %h6= t 'about.features.real_conversation_title'
-      = t 'about.features.real_conversation_body'
-    .visual
-      = fa_icon 'fw comments'
-  .features-list__row
-    .text
-      %h6= t 'about.features.not_a_product_title'
-      = t 'about.features.not_a_product_body'
-    .visual
-      = fa_icon 'fw users'
-  .features-list__row
-    .text
-      %h6= t 'about.features.within_reach_title'
-      = t 'about.features.within_reach_body'
-    .visual
-      = fa_icon 'fw mobile'
-  .features-list__row
-    .text
-      %h6= t 'about.features.humane_approach_title'
-      = t 'about.features.humane_approach_body'
-    .visual
-      = fa_icon 'fw leaf'
diff --git a/app/views/about/_forms.html.haml b/app/views/about/_forms.html.haml
deleted file mode 100644
index 78a422690..000000000
--- a/app/views/about/_forms.html.haml
+++ /dev/null
@@ -1,15 +0,0 @@
-- if @instance_presenter.open_registrations
-  = render 'registration'
-- else
-  = link_to t('auth.register_elsewhere'), 'https://joinmastodon.org/#getting-started', class: 'button button-primary'
-
-  .closed-registrations-message
-    - if @instance_presenter.closed_registrations_message.blank?
-      %p= t('about.closed_registrations')
-    - else
-      = @instance_presenter.closed_registrations_message.html_safe
-
-.separator-or
-  %span= t('auth.or')
-
-= link_to t('auth.login'), new_user_session_path, class: 'button button-alternative-2 webapp-btn'
diff --git a/app/views/about/_links.html.haml b/app/views/about/_links.html.haml
deleted file mode 100644
index 381f301f9..000000000
--- a/app/views/about/_links.html.haml
+++ /dev/null
@@ -1,16 +0,0 @@
-.container-alt.links
-  .brand
-    = link_to root_url do
-      = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
-
-  %ul.nav
-    %li
-      - if user_signed_in?
-        = link_to t('settings.back'), root_url, class: 'webapp-btn'
-      - else
-        = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn'
-    %li= link_to t('about.about_this'), about_more_path
-    %li
-      = link_to 'https://joinmastodon.org/#getting-started' do
-        = "#{t('about.other_instances')}"
-        %i.fa.fa-external-link{ style: 'padding-left: 5px;' }
diff --git a/app/views/about/_login.html.haml b/app/views/about/_login.html.haml
new file mode 100644
index 000000000..d286f0d3c
--- /dev/null
+++ b/app/views/about/_login.html.haml
@@ -0,0 +1,13 @@
+= simple_form_for(new_user, url: user_session_path) do |f|
+  .fields-group
+    - if use_seamless_external_login?
+      = f.input :email, placeholder: t('simple_form.labels.defaults.username_or_email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.username_or_email') }, hint: false
+    - else
+      = f.input :email, placeholder: t('simple_form.labels.defaults.email'), input_html: { 'aria-label' => t('simple_form.labels.defaults.email') }, hint: false
+
+    = f.input :password, placeholder: t('simple_form.labels.defaults.password'), input_html: { 'aria-label' => t('simple_form.labels.defaults.password') }, hint: false
+
+  .actions
+    = f.button :button, t('auth.login'), type: :submit, class: 'button button-primary'
+
+  %p.hint.subtle-hint= link_to t('auth.trouble_logging_in'), new_user_password_path
diff --git a/app/views/about/_registration.html.haml b/app/views/about/_registration.html.haml
index ee4f8fe2e..9cb4eb2bc 100644
--- a/app/views/about/_registration.html.haml
+++ b/app/views/about/_registration.html.haml
@@ -1,12 +1,16 @@
 = simple_form_for(new_user, url: user_registration_path) do |f|
-  = f.simple_fields_for :account do |account_fields|
-    = account_fields.input :username, wrapper: :with_label, autofocus: true, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username') }, append: "@#{site_hostname}", hint: false
+  %p.lead= t('about.federation_hint_html', instance: content_tag(:strong, 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 => 'off' }, 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 => 'off' }, 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 => 'off' }, hint: false
+  .fields-group
+    = f.simple_fields_for :account do |account_fields|
+      = account_fields.input :username, wrapper: :with_label, autofocus: true, label: false, required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.username'), :autocomplete => 'off', placeholder: t('simple_form.labels.defaults.username') }, append: "@#{site_hostname}", hint: false, disabled: closed_registrations?
 
-  .actions
-    = f.button :button, t('auth.register'), type: :submit, class: 'button button-primary'
+    = f.input :email, placeholder: t('simple_form.labels.defaults.email'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.email'), :autocomplete => 'off' }, hint: false, disabled: closed_registrations?
+    = f.input :password, placeholder: t('simple_form.labels.defaults.password'), required: true, input_html: { 'aria-label' => t('simple_form.labels.defaults.password'), :autocomplete => 'off' }, 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?
+
+  .fields-group
+    = f.input :agreement, as: :boolean, wrapper: :with_label, label: t('auth.checkbox_agreement_html', rules_path: about_more_path, terms_path: terms_path), disabled: closed_registrations?
 
-  %p.hint.subtle-hint=t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path)
+  .actions
+    = f.button :button, sign_up_message, type: :submit, class: 'button button-primary', disabled: closed_registrations?
diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml
index 6c28f83ce..15d0af64e 100644
--- a/app/views/about/show.html.haml
+++ b/app/views/about/show.html.haml
@@ -3,143 +3,76 @@
 
 - content_for :header_tags do
   %link{ rel: 'canonical', href: about_url }/
-  %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
   = render partial: 'shared/og'
 
-.landing-page.alternative
-  .container
-    .grid
-      .column-0
-        .brand
-          = link_to root_url do
-            = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
-
-      - if Setting.timeline_preview
-        .column-1
-          .landing-page__forms
-            .brand
-              = link_to root_url do
-                = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
-
-            = render 'forms'
-
-      - else
-        .column-1.non-preview
-          .landing-page__forms
-            .brand
-              = link_to root_url do
-                = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
-
-            = render 'forms'
-
-      - if Setting.timeline_preview
-        .column-2
-          .landing-page__hero
-            = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title
-
-          .landing-page__information
-            .landing-page__short-description
-              .row
-                .landing-page__logo
-                  = image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon'
-
-                %h1
-                  = @instance_presenter.site_title
-                  %small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname)
-
-              %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
-
-          .landing-page__call-to-action{ dir: 'ltr' }
-            .row
-              .row__information-board
-                .information-board__section
-                  %span= t 'about.user_count_before'
-                  %strong= number_with_delimiter @instance_presenter.user_count
-                  %span= t 'about.user_count_after', count: @instance_presenter.user_count
-                .information-board__section
-                  %span= t 'about.status_count_before'
-                  %strong= number_with_delimiter @instance_presenter.status_count
-                  %span= t 'about.status_count_after', count: @instance_presenter.status_count
-              .row__mascot
-                .landing-page__mascot
-                  = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''
-
-      - else
-        .column-2.non-preview
-          .landing-page__hero
-            = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title
-
-          .landing-page__information
-            .landing-page__short-description
-              .row
-                .landing-page__logo
-                  = image_tag asset_pack_path('logo_transparent.svg'), alt: 'Mastodon'
-
-                %h1
-                  = @instance_presenter.site_title
-                  %small!= t 'about.hosted_on', domain: content_tag(:span, site_hostname)
-
-              %p= @instance_presenter.site_description.html_safe.presence || t('about.generic_description', domain: site_hostname)
-
-          .landing-page__call-to-action
-            .row
-              .row__information-board
-                .information-board__section
-                  %span= t 'about.user_count_before'
-                  %strong= number_with_delimiter @instance_presenter.user_count
-                  %span= t 'about.user_count_after', count: @instance_presenter.user_count
-                .information-board__section
-                  %span= t 'about.status_count_before'
-                  %strong= number_with_delimiter @instance_presenter.status_count
-                  %span= t 'about.status_count_after', count: @instance_presenter.status_count
-              .row__mascot
-                .landing-page__mascot
-                  = image_tag @instance_presenter.mascot&.file&.url || asset_pack_path('elephant_ui_plane.svg'), alt: ''
-
-      - if Setting.timeline_preview
-        .column-3
-          #mastodon-timeline{ data: { props: Oj.dump(default_props) } }
-
-      - if Setting.timeline_preview
-        .column-4.landing-page__information
-          .landing-page__features
-            .features-list
-              %div
-                %h3= t 'about.what_is_mastodon'
-                %p= t 'about.about_mastodon_html'
-              %div.contact
-                %h3= t 'about.administered_by'
-                = account_link_to(@instance_presenter.contact_account, link_to(t('about.learn_more'), about_more_path, class: 'button button-alternative'))
-
-            = render 'features'
-
-            .landing-page__features__action
-              = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative'
-
-          .landing-page__footer
+.landing
+  .landing__brand
+    = link_to root_url, class: 'brand' do
+      = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
+      %span.brand__tagline=t 'about.tagline'
+
+  .landing__grid
+    .landing__grid__column.landing__grid__column-registration
+      .box-widget
+        = render 'registration'
+
+      .directory
+        .directory__tag{ class: Setting.profile_directory ? nil : 'disabled' }
+          = optional_link_to Setting.profile_directory, explore_path do
+            %h4
+              = fa_icon 'address-book fw'
+              = t('about.discover_users')
+              %small= t('about.browse_directory')
+
+            .avatar-stack
+              - @instance_presenter.sample_accounts.each do |account|
+                = image_tag current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url, width: 48, height: 48, alt: '', class: 'account__avatar'
+
+        .directory__tag{ class: Setting.timeline_preview ? nil : 'disabled' }
+          = optional_link_to Setting.timeline_preview, public_timeline_path do
+            %h4
+              = fa_icon 'globe fw'
+              = t('about.see_whats_happening')
+              %small= t('about.browse_public_posts')
+
+        .directory__tag
+          = link_to 'https://joinmastodon.org/apps', target: '_blank', rel: 'noopener' do
+            %h4
+              = fa_icon 'tablet fw'
+              = t('about.get_apps')
+              %small= t('about.apps_platforms')
+
+    .landing__grid__column.landing__grid__column-login
+      .box-widget
+        = render 'login'
+
+      .hero-widget
+        .hero-widget__img
+          = image_tag @instance_presenter.hero&.file&.url || @instance_presenter.thumbnail&.file&.url || asset_pack_path('preview.jpg'), alt: @instance_presenter.site_title
+
+        - if @instance_presenter.site_short_description.present?
+          .hero-widget__text
             %p
-              = link_to t('about.source_code'), @instance_presenter.source_url
-              = " (#{@instance_presenter.version_number})"
-
-      - else
-        .column-4.non-preview.landing-page__information
-          .landing-page__features
-            .features-list
-              %div
-                %h3= t 'about.what_is_mastodon'
-                %p= t 'about.about_mastodon_html'
-              %div.contact
-                %h3= t 'about.administered_by'
-                = account_link_to(@instance_presenter.contact_account, link_to(t('about.learn_more'), about_more_path, class: 'button button-alternative'))
-
-            = render 'features'
-
-            .landing-page__features__action
-              = link_to t('about.learn_more'), 'https://joinmastodon.org/', class: 'button button-alternative'
-
-          .landing-page__footer
-            %p
-              = link_to t('about.source_code'), @instance_presenter.source_url
-              = " (#{@instance_presenter.version_number})"
-
-#modal-container
+              = @instance_presenter.site_short_description.html_safe.presence
+              = link_to about_more_path do
+                = t('about.learn_more')
+                = fa_icon 'angle-double-right'
+
+        .hero-widget__footer
+          .hero-widget__footer__column
+            %h4= t 'about.administered_by'
+
+            = account_link_to @instance_presenter.contact_account
+
+          .hero-widget__footer__column
+            %h4= t 'about.server_stats'
+
+            %div{ style: 'display: flex' }
+              .hero-widget__counter{ style: 'width: 50%' }
+                %strong= number_to_human @instance_presenter.user_count, strip_insignificant_zeros: true
+                %span= t 'about.user_count_after', count: @instance_presenter.user_count
+              .hero-widget__counter{ style: 'width: 50%' }
+                %strong= number_to_human @instance_presenter.active_user_count, strip_insignificant_zeros: true
+                %span
+                  = t 'about.active_count_after'
+                  %abbr{ title: t('about.active_footnote') } *
diff --git a/app/views/admin/accounts/_account.html.haml b/app/views/admin/accounts/_account.html.haml
index 1e1bb1812..eba3ad804 100644
--- a/app/views/admin/accounts/_account.html.haml
+++ b/app/views/admin/accounts/_account.html.haml
@@ -5,7 +5,7 @@
     %div{ style: 'margin: -2px 0' }= account_badge(account, all: true)
   %td
     - if account.user_current_sign_in_ip
-      %samp= account.user_current_sign_in_ip
+      %samp.ellipsized-ip{ title: account.user_current_sign_in_ip }= account.user_current_sign_in_ip
     - else
       \-
   %td
@@ -14,5 +14,9 @@
     - else
       \-
   %td
-    = table_link_to 'circle', t('admin.accounts.web'), web_path("accounts/#{account.id}")
-    = table_link_to 'globe', t('admin.accounts.public'), TagManager.instance.url_for(account)
+    - if account.local? && account.user_pending?
+      = table_link_to 'check', t('admin.accounts.approve'), approve_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:approve, account.user)
+      = table_link_to 'times', t('admin.accounts.reject'), reject_admin_account_path(account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') } if can?(:reject, account.user)
+    - else
+      = table_link_to 'circle', t('admin.accounts.web'), web_path("accounts/#{account.id}")
+      = table_link_to 'globe', t('admin.accounts.public'), TagManager.instance.url_for(account)
diff --git a/app/views/admin/accounts/index.html.haml b/app/views/admin/accounts/index.html.haml
index 345f74f90..66808add7 100644
--- a/app/views/admin/accounts/index.html.haml
+++ b/app/views/admin/accounts/index.html.haml
@@ -10,9 +10,10 @@
   .filter-subset
     %strong= t('admin.accounts.moderation.title')
     %ul
-      %li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil
-      %li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil
-      %li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil
+      %li= filter_link_to t('admin.accounts.moderation.pending'), pending: '1', silenced: nil, suspended: nil
+      %li= filter_link_to t('admin.accounts.moderation.active'), silenced: nil, suspended: nil, pending: nil
+      %li= filter_link_to t('admin.accounts.moderation.silenced'), silenced: '1', suspended: nil, pending: nil
+      %li= filter_link_to t('admin.accounts.moderation.suspended'), suspended: '1', silenced: nil, pending: nil
   .filter-subset
     %strong= t('admin.accounts.role')
     %ul
diff --git a/app/views/admin/accounts/show.html.haml b/app/views/admin/accounts/show.html.haml
index 7ac73bd07..7494c9fa2 100644
--- a/app/views/admin/accounts/show.html.haml
+++ b/app/views/admin/accounts/show.html.haml
@@ -37,6 +37,8 @@
           %span.red= t('admin.accounts.disabled')
         - elsif @account.local? && !@account.user&.confirmed?
           %span.neutral= t('admin.accounts.confirming')
+        - elsif @account.local? && !@account.user_approved?
+          %span.neutral= t('admin.accounts.pending')
         - else
           %span.neutral= t('admin.accounts.no_limits_imposed')
       .dashboard__counters__label= t 'admin.accounts.login_status'
@@ -95,7 +97,7 @@
             %td
               - if @account.user&.disabled?
                 = table_link_to 'unlock', t('admin.accounts.enable'), enable_admin_account_path(@account.id), method: :post if can?(:enable, @account.user)
-              - else
+              - elsif @account.user_approved?
                 = table_link_to 'lock', t('admin.accounts.disable'), new_admin_account_action_path(@account.id, type: 'disable') if can?(:disable, @account.user)
 
           %tr
@@ -144,26 +146,30 @@
         = link_to t('admin.accounts.reset_password'), admin_account_reset_path(@account.id), method: :create, class: 'button' if can?(:reset_password, @account.user)
         - if @account.user&.otp_required_for_login?
           = link_to t('admin.accounts.disable_two_factor_authentication'), admin_user_two_factor_authentication_path(@account.user.id), method: :delete, class: 'button' if can?(:disable_2fa, @account.user)
-        - unless @account.memorial?
+        - if !@account.memorial? && @account.user_approved?
           = link_to t('admin.accounts.memorialize'), memorialize_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:memorialize, @account)
       - else
         = link_to t('admin.accounts.redownload'), redownload_admin_account_path(@account.id), method: :post, class: 'button' if can?(:redownload, @account)
 
     %div{ style: 'float: left' }
-      - if @account.local?
+      - if @account.local? && @account.user_approved?
         = link_to t('admin.accounts.warn'), new_admin_account_action_path(@account.id, type: 'none'), class: 'button' if can?(:warn, @account)
       - if @account.silenced?
         = link_to t('admin.accounts.undo_silenced'), unsilence_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsilence, @account)
-      - else
+      - elsif !@account.local? || @account.user_approved?
         = link_to t('admin.accounts.silence'), new_admin_account_action_path(@account.id, type: 'silence'), class: 'button button--destructive' if can?(:silence, @account)
 
       - if @account.local?
+        - if @account.user_pending?
+          = link_to t('admin.accounts.approve'), approve_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button' if can?(:approve, @account.user)
+          = link_to t('admin.accounts.reject'), reject_admin_account_path(@account.id), method: :post, data: { confirm: t('admin.accounts.are_you_sure') }, class: 'button button--destructive' if can?(:reject, @account.user)
+
         - unless @account.user_confirmed?
           = link_to t('admin.accounts.confirm'), admin_account_confirmation_path(@account.id), method: :post, class: 'button' if can?(:confirm, @account.user)
 
       - if @account.suspended?
         = link_to t('admin.accounts.undo_suspension'), unsuspend_admin_account_path(@account.id), method: :post, class: 'button' if can?(:unsuspend, @account)
-      - else
+      - elsif !@account.local? || @account.user_approved?
         = link_to t('admin.accounts.perform_full_suspension'), new_admin_account_action_path(@account.id, type: 'suspend'), class: 'button button--destructive' if can?(:suspend, @account)
 
       - unless @account.local?
diff --git a/app/views/admin/invites/_invite.html.haml b/app/views/admin/invites/_invite.html.haml
index d7b697286..ee0eacaf5 100644
--- a/app/views/admin/invites/_invite.html.haml
+++ b/app/views/admin/invites/_invite.html.haml
@@ -1,21 +1,29 @@
 %tr
   %td
+    .input-copy
+      .input-copy__wrapper
+        %input{ type: :text, maxlength: '999', spellcheck: 'false', readonly: 'true', value: public_invite_url(invite_code: invite.code) }
+      %button{ type: :button }= t('generic.copy')
+
+  %td
     .name-tag
       = image_tag invite.user.account.avatar.url(:original), alt: '', width: 16, height: 16, class: 'avatar'
       %span.username= invite.user.account.username
-  %td
-    = invite.uses
-    = " / #{invite.max_uses}" unless invite.max_uses.nil?
-  %td
-    - if invite.expired?
+
+  - if invite.expired?
+    %td{ colspan: 2 }
       = t('invites.expired')
-    - else
+  - else
+    %td
+      = fa_icon 'user fw'
+      = invite.uses
+      = " / #{invite.max_uses}" unless invite.max_uses.nil?
+    %td
       - if invite.expires_at.nil?

       - else
         %time.formatted{ datetime: invite.expires_at.iso8601, title: l(invite.expires_at) }
           = l invite.expires_at
-  %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code)
   %td
     - if !invite.expired? && policy(invite).destroy?
       = table_link_to 'times', t('invites.delete'), admin_invite_path(invite), method: :delete
diff --git a/app/views/admin/invites/index.html.haml b/app/views/admin/invites/index.html.haml
index 42159e9f3..ee6ba0f57 100644
--- a/app/views/admin/invites/index.html.haml
+++ b/app/views/admin/invites/index.html.haml
@@ -18,15 +18,15 @@
 
   %hr.spacer/
 
-.table-wrapper
-  %table.table
+.table-wrapper.simple_form
+  %table.table.table--invites
     %thead
       %tr
         %th
+        %th
         %th= t('invites.table.uses')
         %th= t('invites.table.expires_at')
         %th
-        %th
     %tbody
       = render @invites
 
diff --git a/app/views/admin/settings/edit.html.haml b/app/views/admin/settings/edit.html.haml
index e3ceb4344..9995e0b2a 100644
--- a/app/views/admin/settings/edit.html.haml
+++ b/app/views/admin/settings/edit.html.haml
@@ -6,8 +6,11 @@
   .fields-group
     = f.input :site_title, wrapper: :with_label, label: t('admin.settings.site_title')
 
-  .fields-group
-    = f.input :flavour_and_skin, collection: Themes.instance.flavours_and_skins, group_label_method: lambda { |(flavour, _)| I18n.t("flavours.#{flavour}.name", default: flavour) }, wrapper: :with_label, include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last
+  .fields-row
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :flavour_and_skin, collection: Themes.instance.flavours_and_skins, group_label_method: lambda { |(flavour, _)| I18n.t("flavours.#{flavour}.name", default: flavour) }, wrapper: :with_label, include_blank: false, as: :grouped_select, label_method: :last, value_method: lambda { |value| value.join('/') }, group_method: :last
+    .fields-row__column.fields-row__column-6.fields-group
+      = f.input :registrations_mode, collection: %w(open approved none), wrapper: :with_label, label: t('admin.settings.registrations_mode.title'), include_blank: false, label_method: lambda { |mode| I18n.t("admin.settings.registrations_mode.modes.#{mode}") }
 
   .fields-row
     .fields-row__column.fields-row__column-6.fields-group
@@ -48,9 +51,6 @@
     = f.input :show_staff_badge, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_staff_badge.title'), hint: t('admin.settings.show_staff_badge.desc_html')
 
   .fields-group
-    = f.input :open_registrations, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.open.title'), hint: t('admin.settings.registrations.open.desc_html')
-
-  .fields-group
     = f.input :open_deletion, as: :boolean, wrapper: :with_label, label: t('admin.settings.registrations.deletion.title'), hint: t('admin.settings.registrations.deletion.desc_html')
 
   .fields-group
diff --git a/app/views/admin_mailer/new_pending_account.text.erb b/app/views/admin_mailer/new_pending_account.text.erb
new file mode 100644
index 000000000..ed31ae2eb
--- /dev/null
+++ b/app/views/admin_mailer/new_pending_account.text.erb
@@ -0,0 +1,8 @@
+<%= raw t('application_mailer.salutation', name: display_name(@me)) %>
+
+<%= raw t('admin_mailer.new_pending_account.body') %>
+
+<%= raw t('admin.accounts.email') %>: <%= @account.user_email %>
+<%= raw t('admin.accounts.most_recent_ip') %>: <%= @account.user_current_sign_in_ip %>
+
+<%= raw t('application_mailer.view')%> <%= admin_account_url(@account.id) %>
diff --git a/app/views/auth/registrations/new.html.haml b/app/views/auth/registrations/new.html.haml
index 72ce8e531..1caf2b401 100644
--- a/app/views/auth/registrations/new.html.haml
+++ b/app/views/auth/registrations/new.html.haml
@@ -29,6 +29,6 @@
   %p.hint= t('auth.agreement_html', rules_path: about_more_path, terms_path: terms_path)
 
   .actions
-    = f.button :button, t('auth.register'), type: :submit
+    = f.button :button, sign_up_message, type: :submit
 
 .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 516c625a6..3c68ccd22 100644
--- a/app/views/auth/shared/_links.html.haml
+++ b/app/views/auth/shared/_links.html.haml
@@ -3,7 +3,7 @@
     %li= link_to t('auth.login'), new_session_path(resource_name)
 
   - if devise_mapping.registerable? && controller_name != 'registrations'
-    %li= link_to t('auth.register'), open_registrations? ? new_registration_path(resource_name) : 'https://joinmastodon.org/#getting-started'
+    %li= link_to t('auth.register'), available_sign_up_path
 
   - if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations'
     %li= link_to t('auth.forgot_password'), new_password_path(resource_name)
diff --git a/app/views/invites/_invite.html.haml b/app/views/invites/_invite.html.haml
index 1c7ec311d..4240aa3e7 100644
--- a/app/views/invites/_invite.html.haml
+++ b/app/views/invites/_invite.html.haml
@@ -1,17 +1,25 @@
 %tr
   %td
-    = invite.uses
-    = " / #{invite.max_uses}" unless invite.max_uses.nil?
-  %td
-    - if invite.expired?
+    .input-copy
+      .input-copy__wrapper
+        %input{ type: :text, maxlength: '999', spellcheck: 'false', readonly: 'true', value: public_invite_url(invite_code: invite.code) }
+      %button{ type: :button }= t('generic.copy')
+
+  - if invite.expired?
+    %td{ colspan: 2 }
       = t('invites.expired')
-    - else
+  - else
+    %td
+      = fa_icon 'user fw'
+      = invite.uses
+      = " / #{invite.max_uses}" unless invite.max_uses.nil?
+    %td
       - if invite.expires_at.nil?

       - else
         %time.formatted{ datetime: invite.expires_at.iso8601, title: l(invite.expires_at) }
           = l invite.expires_at
-  %td= table_link_to 'link', public_invite_url(invite_code: invite.code), public_invite_url(invite_code: invite.code)
+
   %td
     - if !invite.expired? && policy(invite).destroy?
       = table_link_to 'times', t('invites.delete'), invite_path(invite), method: :delete
diff --git a/app/views/invites/index.html.haml b/app/views/invites/index.html.haml
index fb827f6e6..61420ab1e 100644
--- a/app/views/invites/index.html.haml
+++ b/app/views/invites/index.html.haml
@@ -8,12 +8,13 @@
 
   %hr.spacer/
 
-%table.table
-  %thead
-    %tr
-      %th= t('invites.table.uses')
-      %th= t('invites.table.expires_at')
-      %th
-      %th
-  %tbody
-    = render @invites
+.simple_form
+  %table.table.table--invites
+    %thead
+      %tr
+        %th
+        %th= t('invites.table.uses')
+        %th= t('invites.table.expires_at')
+        %th
+    %tbody
+      = render @invites
diff --git a/app/views/layouts/public.html.haml b/app/views/layouts/public.html.haml
index c1c0f4b87..1d3519b8a 100644
--- a/app/views/layouts/public.html.haml
+++ b/app/views/layouts/public.html.haml
@@ -1,22 +1,22 @@
 - content_for :content do
   .public-layout
-    .container
-      %nav.header
-        .nav-left
-          = link_to root_url, class: 'brand' do
-            = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
+    - unless @hide_navbar
+      .container
+        %nav.header
+          .nav-left
+            = link_to root_url, class: 'brand' do
+              = image_tag asset_pack_path('logo_full.svg'), alt: 'Mastodon'
 
-          - if Setting.profile_directory
-            = link_to t('directories.directory'), explore_path, class: 'nav-link optional'
-          = link_to t('about.about_this'), about_more_path, class: 'nav-link optional'
-          = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional'
-        .nav-center
-        .nav-right
-          - if user_signed_in?
-            = link_to t('settings.back'), root_url, class: 'nav-link nav-button webapp-btn'
-          - else
-            = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn nav-link nav-button'
-            = link_to t('auth.register'), open_registrations? ? new_user_registration_path : 'https://joinmastodon.org/#getting-started', class: 'webapp-btn nav-link nav-button'
+            = link_to t('directories.directory'), explore_path, class: 'nav-link optional' if Setting.profile_directory
+            = link_to t('about.about_this'), about_more_path, class: 'nav-link optional'
+            = link_to t('about.apps'), 'https://joinmastodon.org/apps', class: 'nav-link optional'
+          .nav-center
+          .nav-right
+            - if user_signed_in?
+              = link_to t('settings.back'), root_url, class: 'nav-link nav-button webapp-btn'
+            - else
+              = link_to t('auth.login'), new_user_session_path, class: 'webapp-btn nav-link nav-button'
+              = link_to t('auth.register'), available_sign_up_path, class: 'webapp-btn nav-link nav-button'
 
     .container= yield
 
diff --git a/app/views/public_timelines/show.html.haml b/app/views/public_timelines/show.html.haml
new file mode 100644
index 000000000..f80157c67
--- /dev/null
+++ b/app/views/public_timelines/show.html.haml
@@ -0,0 +1,13 @@
+- content_for :page_title do
+  = t('about.see_whats_happening')
+
+- content_for :header_tags do
+  %meta{ name: 'robots', content: 'noindex' }/
+  %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
+
+.page-header
+  %h1= t('about.see_whats_happening')
+  %p= t('about.browse_public_posts')
+
+#mastodon-timeline{ data: { props: Oj.dump(default_props) }}
+#modal-container
diff --git a/app/views/remote_follow/new.html.haml b/app/views/remote_follow/new.html.haml
index 5cf6977ba..4e9601f6a 100644
--- a/app/views/remote_follow/new.html.haml
+++ b/app/views/remote_follow/new.html.haml
@@ -1,6 +1,5 @@
 - content_for :header_tags do
-  - if @account.user&.setting_noindex
-    %meta{ name: 'robots', content: 'noindex' }/
+  %meta{ name: 'robots', content: 'noindex' }/
 
 .form-container
   .follow-prompt
@@ -18,4 +17,4 @@
 
     %p.hint.subtle-hint
       = t('remote_follow.reason_html', instance: site_hostname)
-      = t('remote_follow.no_account_html', sign_up_path: open_registrations? ? new_user_registration_path : 'https://joinmastodon.org/#getting-started')
+      = t('remote_follow.no_account_html', sign_up_path: available_sign_up_path)
diff --git a/app/views/remote_interaction/new.html.haml b/app/views/remote_interaction/new.html.haml
index a0b106814..c8c08991f 100644
--- a/app/views/remote_interaction/new.html.haml
+++ b/app/views/remote_interaction/new.html.haml
@@ -1,3 +1,6 @@
+- content_for :header_tags do
+  %meta{ name: 'robots', content: 'noindex' }/
+
 .form-container
   .follow-prompt
     %h2= t("remote_interaction.#{@interaction_type}.prompt")
@@ -18,4 +21,4 @@
 
     %p.hint.subtle-hint
       = t('remote_follow.reason_html', instance: site_hostname)
-      = t('remote_follow.no_account_html', sign_up_path: open_registrations? ? new_user_registration_path : 'https://joinmastodon.org/#getting-started')
+      = t('remote_follow.no_account_html', sign_up_path: available_sign_up_path)
diff --git a/app/views/tags/show.html.haml b/app/views/tags/show.html.haml
index 850160ac1..1a9c58983 100644
--- a/app/views/tags/show.html.haml
+++ b/app/views/tags/show.html.haml
@@ -2,6 +2,7 @@
   = "##{@tag.name}"
 
 - content_for :header_tags do
+  %meta{ name: 'robots', content: 'noindex' }/
   %link{ rel: 'alternate', type: 'application/rss+xml', href: tag_url(@tag, format: 'rss') }/
 
   %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json)
diff --git a/app/views/user_mailer/confirmation_instructions.html.haml b/app/views/user_mailer/confirmation_instructions.html.haml
index f75f7529a..70d0f5a24 100644
--- a/app/views/user_mailer/confirmation_instructions.html.haml
+++ b/app/views/user_mailer/confirmation_instructions.html.haml
@@ -36,7 +36,7 @@
                         %tbody
                           %tr
                             %td.column-cell.text-center
-                              %p= t 'devise.mailer.confirmation_instructions.explanation', host: site_hostname
+                              %p= t @resource.approved? ? 'devise.mailer.confirmation_instructions.explanation' : 'devise.mailer.confirmation_instructions.explanation_when_pending', host: site_hostname
 
 %table.email-table{ cellspacing: 0, cellpadding: 0 }
   %tbody
diff --git a/app/views/user_mailer/confirmation_instructions.text.erb b/app/views/user_mailer/confirmation_instructions.text.erb
index 65b4626c6..aad91cd9d 100644
--- a/app/views/user_mailer/confirmation_instructions.text.erb
+++ b/app/views/user_mailer/confirmation_instructions.text.erb
@@ -2,7 +2,7 @@
 
 ===
 
-<%= t 'devise.mailer.confirmation_instructions.explanation', host: site_hostname %>
+<%= t @resource.approved? ? 'devise.mailer.confirmation_instructions.explanation' : 'devise.mailer.confirmation_instructions.explanation_when_pending', host: site_hostname %>
 
 => <%= confirmation_url(@resource, confirmation_token: @token, redirect_to_app: @resource.created_by_application ? 'true' : nil) %>
 
diff --git a/app/workers/activitypub/distribute_poll_update_worker.rb b/app/workers/activitypub/distribute_poll_update_worker.rb
new file mode 100644
index 000000000..5536bd744
--- /dev/null
+++ b/app/workers/activitypub/distribute_poll_update_worker.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+class ActivityPub::DistributePollUpdateWorker
+  include Sidekiq::Worker
+
+  sidekiq_options queue: 'push', unique: :until_executed, retry: 0
+
+  def perform(status_id)
+    @status  = Status.find(status_id)
+    @account = @status.account
+
+    return if @status.poll.nil? || @status.local_only?
+
+    ActivityPub::DeliveryWorker.push_bulk(inboxes) do |inbox_url|
+      [payload, @account.id, inbox_url]
+    end
+
+    relay! if relayable?
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+
+  private
+
+  def relayable?
+    @status.public_visibility?
+  end
+
+  def inboxes
+    return @inboxes if defined?(@inboxes)
+
+    @inboxes = [@status.mentions, @status.reblogs, @status.poll.votes].flat_map do |relation|
+      relation.includes(:account).map do |record|
+        record.account.preferred_inbox_url if !record.account.local? && record.account.activitypub?
+      end
+    end
+
+    @inboxes.concat(@account.followers.inboxes) unless @status.direct_visibility?
+    @inboxes.uniq!
+    @inboxes.compact!
+    @inboxes
+  end
+
+  def signed_payload
+    Oj.dump(ActivityPub::LinkedDataSignature.new(unsigned_payload).sign!(@account))
+  end
+
+  def unsigned_payload
+    ActiveModelSerializers::SerializableResource.new(
+      @status,
+      serializer: ActivityPub::UpdatePollSerializer,
+      adapter: ActivityPub::Adapter
+    ).as_json
+  end
+
+  def payload
+    @payload ||= @status.distributable? ? signed_payload : Oj.dump(unsigned_payload)
+  end
+
+  def relay!
+    ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
+      [payload, @account.id, inbox_url]
+    end
+  end
+end
diff --git a/app/workers/poll_expiration_notify_worker.rb b/app/workers/poll_expiration_notify_worker.rb
new file mode 100644
index 000000000..e08f0c249
--- /dev/null
+++ b/app/workers/poll_expiration_notify_worker.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class PollExpirationNotifyWorker
+  include Sidekiq::Worker
+
+  sidekiq_options unique: :until_executed
+
+  def perform(poll_id)
+    poll = Poll.find(poll_id)
+
+    # Notify poll owner and remote voters
+    if poll.local?
+      ActivityPub::DistributePollUpdateWorker.perform_async(poll.status.id)
+      NotifyService.new.call(poll.account, poll)
+    end
+
+    # Notify local voters
+    poll.votes.includes(:account).map(&:account).select(&:local?).each do |account|
+      NotifyService.new.call(account, poll)
+    end
+  rescue ActiveRecord::RecordNotFound
+    true
+  end
+end