Merge branch 'main' into glitch-soc/merge-upstream
Conflicts: - `README.md`: Upstream updated copyright year, we don't mention it so kept our version. - `app/controllers/admin/dashboard_controller.rb`: Not really a conflict, upstream change (removing the spam checker) too close to glitch-soc changes. Ported upstream changes. - `app/models/form/admin_settings.rb`: Same. - `app/services/remove_status_service.rb`: Same. - `app/views/admin/settings/edit.html.haml`: Same. - `config/settings.yml`: Same. - `config/environments/production.rb`: Not a real conflict, upstream added a default HTTP header, but we have extra headers in glitch-soc. Added the header.
This commit is contained in:
		
						commit
						e2a2bc9021
					
				
					 100 changed files with 1904 additions and 1077 deletions
				
			
		| 
						 | 
				
			
			@ -1 +1 @@
 | 
			
		|||
2.7.2
 | 
			
		||||
2.7.3
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -26,7 +26,7 @@ RUN ARCH= && \
 | 
			
		|||
	mv node-v$NODE_VER-linux-$ARCH /opt/node
 | 
			
		||||
 | 
			
		||||
# Install Ruby
 | 
			
		||||
ENV RUBY_VER="2.7.2"
 | 
			
		||||
ENV RUBY_VER="2.7.3"
 | 
			
		||||
RUN apt-get update && \
 | 
			
		||||
  apt-get install -y --no-install-recommends build-essential \
 | 
			
		||||
    bison libyaml-dev libgdbm-dev libreadline-dev libjemalloc-dev \
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										12
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										12
									
								
								Gemfile
									
									
									
									
									
								
							| 
						 | 
				
			
			@ -32,9 +32,9 @@ gem 'browser'
 | 
			
		|||
gem 'charlock_holmes', '~> 0.7.7'
 | 
			
		||||
gem 'iso-639'
 | 
			
		||||
gem 'chewy', '~> 5.2'
 | 
			
		||||
gem 'cld3', '~> 3.4.1'
 | 
			
		||||
gem 'cld3', '~> 3.4.2'
 | 
			
		||||
gem 'devise', '~> 4.7'
 | 
			
		||||
gem 'devise-two-factor', git: 'https://github.com/ClearlyClaire/devise-two-factor', ref: '594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d'
 | 
			
		||||
gem 'devise-two-factor', '~> 4.0'
 | 
			
		||||
 | 
			
		||||
group :pam_authentication, optional: true do
 | 
			
		||||
  gem 'devise_pam_authenticatable2', '~> 9.2'
 | 
			
		||||
| 
						 | 
				
			
			@ -62,9 +62,8 @@ gem 'idn-ruby', require: 'idn'
 | 
			
		|||
gem 'kaminari', '~> 1.2'
 | 
			
		||||
gem 'link_header', '~> 0.0'
 | 
			
		||||
gem 'mime-types', '~> 3.3.1', require: 'mime/types/columnar'
 | 
			
		||||
gem 'nilsimsa', git: 'https://github.com/witgo/nilsimsa', ref: 'fd184883048b922b176939f851338d0a4971a532'
 | 
			
		||||
gem 'nokogiri', '~> 1.11'
 | 
			
		||||
gem 'nsa', git: 'https://github.com/Gargron/nsa', ref: 'd1079e0cdafdfed7f9f35478d13b9bdaa65965c0'
 | 
			
		||||
gem 'nsa', '~> 0.2'
 | 
			
		||||
gem 'oj', '~> 3.11'
 | 
			
		||||
gem 'ox', '~> 2.14'
 | 
			
		||||
gem 'parslet'
 | 
			
		||||
| 
						 | 
				
			
			@ -95,7 +94,7 @@ gem 'tty-prompt', '~> 0.23', require: false
 | 
			
		|||
gem 'twitter-text', '~> 3.1.0'
 | 
			
		||||
gem 'tzinfo-data', '~> 1.2021'
 | 
			
		||||
gem 'webpacker', '~> 5.2'
 | 
			
		||||
gem 'webpush'
 | 
			
		||||
gem 'webpush', '~> 0.3'
 | 
			
		||||
gem 'webauthn', '~> 3.0.0.alpha1'
 | 
			
		||||
 | 
			
		||||
gem 'json-ld'
 | 
			
		||||
| 
						 | 
				
			
			@ -126,7 +125,7 @@ group :test do
 | 
			
		|||
  gem 'rspec-sidekiq', '~> 3.1'
 | 
			
		||||
  gem 'simplecov', '~> 0.21', require: false
 | 
			
		||||
  gem 'webmock', '~> 3.12'
 | 
			
		||||
  gem 'parallel_tests', '~> 3.6'
 | 
			
		||||
  gem 'parallel_tests', '~> 3.7'
 | 
			
		||||
  gem 'rspec_junit_formatter', '~> 0.4'
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -160,4 +159,3 @@ gem 'concurrent-ruby', require: false
 | 
			
		|||
gem 'connection_pool', require: false
 | 
			
		||||
 | 
			
		||||
gem 'xorcist', '~> 1.1'
 | 
			
		||||
gem 'pluck_each', git: 'https://github.com/nsommer/pluck_each', ref: '73be0947c52fc54bf6d7085378db008358aac5eb'
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										100
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										100
									
								
								Gemfile.lock
									
									
									
									
									
								
							| 
						 | 
				
			
			@ -1,42 +1,3 @@
 | 
			
		|||
GIT
 | 
			
		||||
  remote: https://github.com/ClearlyClaire/devise-two-factor
 | 
			
		||||
  revision: 594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d
 | 
			
		||||
  ref: 594bb8a32e6f94df7e5ba7c9399eaf9ff25bac0d
 | 
			
		||||
  specs:
 | 
			
		||||
    devise-two-factor (3.1.0)
 | 
			
		||||
      activesupport (< 7.0)
 | 
			
		||||
      attr_encrypted (>= 1.3, < 4, != 2)
 | 
			
		||||
      devise
 | 
			
		||||
      railties (< 7.0)
 | 
			
		||||
      rotp (~> 6)
 | 
			
		||||
 | 
			
		||||
GIT
 | 
			
		||||
  remote: https://github.com/Gargron/nsa
 | 
			
		||||
  revision: d1079e0cdafdfed7f9f35478d13b9bdaa65965c0
 | 
			
		||||
  ref: d1079e0cdafdfed7f9f35478d13b9bdaa65965c0
 | 
			
		||||
  specs:
 | 
			
		||||
    nsa (0.2.8)
 | 
			
		||||
      activesupport (>= 4.2, < 7)
 | 
			
		||||
      concurrent-ruby (~> 1.0, >= 1.0.2)
 | 
			
		||||
      sidekiq (>= 3.5)
 | 
			
		||||
      statsd-ruby (~> 1.4, >= 1.4.0)
 | 
			
		||||
 | 
			
		||||
GIT
 | 
			
		||||
  remote: https://github.com/nsommer/pluck_each
 | 
			
		||||
  revision: 73be0947c52fc54bf6d7085378db008358aac5eb
 | 
			
		||||
  ref: 73be0947c52fc54bf6d7085378db008358aac5eb
 | 
			
		||||
  specs:
 | 
			
		||||
    pluck_each (0.1.3)
 | 
			
		||||
      activerecord (>= 6.1.0)
 | 
			
		||||
      activesupport (>= 6.1.0)
 | 
			
		||||
 | 
			
		||||
GIT
 | 
			
		||||
  remote: https://github.com/witgo/nilsimsa
 | 
			
		||||
  revision: fd184883048b922b176939f851338d0a4971a532
 | 
			
		||||
  ref: fd184883048b922b176939f851338d0a4971a532
 | 
			
		||||
  specs:
 | 
			
		||||
    nilsimsa (1.1.2)
 | 
			
		||||
 | 
			
		||||
GEM
 | 
			
		||||
  remote: https://rubygems.org/
 | 
			
		||||
  specs:
 | 
			
		||||
| 
						 | 
				
			
			@ -120,8 +81,8 @@ GEM
 | 
			
		|||
      cocaine (~> 0.5.3)
 | 
			
		||||
    awrence (1.1.1)
 | 
			
		||||
    aws-eventstream (1.1.1)
 | 
			
		||||
    aws-partitions (1.436.0)
 | 
			
		||||
    aws-sdk-core (3.113.0)
 | 
			
		||||
    aws-partitions (1.445.0)
 | 
			
		||||
    aws-sdk-core (3.114.0)
 | 
			
		||||
      aws-eventstream (~> 1, >= 1.0.2)
 | 
			
		||||
      aws-partitions (~> 1, >= 1.239.0)
 | 
			
		||||
      aws-sigv4 (~> 1.1)
 | 
			
		||||
| 
						 | 
				
			
			@ -129,7 +90,7 @@ GEM
 | 
			
		|||
    aws-sdk-kms (1.43.0)
 | 
			
		||||
      aws-sdk-core (~> 3, >= 3.112.0)
 | 
			
		||||
      aws-sigv4 (~> 1.1)
 | 
			
		||||
    aws-sdk-s3 (1.93.0)
 | 
			
		||||
    aws-sdk-s3 (1.93.1)
 | 
			
		||||
      aws-sdk-core (~> 3, >= 3.112.0)
 | 
			
		||||
      aws-sdk-kms (~> 1)
 | 
			
		||||
      aws-sigv4 (~> 1.1)
 | 
			
		||||
| 
						 | 
				
			
			@ -192,15 +153,15 @@ GEM
 | 
			
		|||
      elasticsearch (>= 2.0.0)
 | 
			
		||||
      elasticsearch-dsl
 | 
			
		||||
    chunky_png (1.3.15)
 | 
			
		||||
    cld3 (3.4.1)
 | 
			
		||||
      ffi (>= 1.1.0, < 1.15.0)
 | 
			
		||||
    cld3 (3.4.2)
 | 
			
		||||
      ffi (>= 1.1.0, < 1.16.0)
 | 
			
		||||
    climate_control (0.2.0)
 | 
			
		||||
    cocaine (0.5.8)
 | 
			
		||||
      climate_control (>= 0.0.3, < 1.0)
 | 
			
		||||
    coderay (1.1.3)
 | 
			
		||||
    color_diff (0.1)
 | 
			
		||||
    concurrent-ruby (1.1.8)
 | 
			
		||||
    connection_pool (2.2.3)
 | 
			
		||||
    connection_pool (2.2.5)
 | 
			
		||||
    cose (1.0.0)
 | 
			
		||||
      cbor (~> 0.5.9)
 | 
			
		||||
      openssl-signature_algorithm (~> 0.4.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -216,6 +177,12 @@ GEM
 | 
			
		|||
      railties (>= 4.1.0)
 | 
			
		||||
      responders
 | 
			
		||||
      warden (~> 1.2.3)
 | 
			
		||||
    devise-two-factor (4.0.0)
 | 
			
		||||
      activesupport (< 6.2)
 | 
			
		||||
      attr_encrypted (>= 1.3, < 4, != 2)
 | 
			
		||||
      devise (~> 4.0)
 | 
			
		||||
      railties (< 6.2)
 | 
			
		||||
      rotp (~> 6.0)
 | 
			
		||||
    devise_pam_authenticatable2 (9.2.0)
 | 
			
		||||
      devise (>= 4.0.0)
 | 
			
		||||
      rpam2 (~> 4.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -225,7 +192,7 @@ GEM
 | 
			
		|||
    docile (1.3.4)
 | 
			
		||||
    domain_name (0.5.20190701)
 | 
			
		||||
      unf (>= 0.0.5, < 1.0.0)
 | 
			
		||||
    doorkeeper (5.5.0)
 | 
			
		||||
    doorkeeper (5.5.1)
 | 
			
		||||
      railties (>= 5)
 | 
			
		||||
    dotenv (2.7.6)
 | 
			
		||||
    dotenv-rails (2.7.6)
 | 
			
		||||
| 
						 | 
				
			
			@ -257,7 +224,7 @@ GEM
 | 
			
		|||
    faraday-net_http (1.0.1)
 | 
			
		||||
    fast_blank (1.0.0)
 | 
			
		||||
    fastimage (2.2.3)
 | 
			
		||||
    ffi (1.14.2)
 | 
			
		||||
    ffi (1.15.0)
 | 
			
		||||
    ffi-compiler (1.0.1)
 | 
			
		||||
      ffi (>= 1.0.0)
 | 
			
		||||
      rake
 | 
			
		||||
| 
						 | 
				
			
			@ -313,7 +280,7 @@ GEM
 | 
			
		|||
    httplog (1.4.3)
 | 
			
		||||
      rack (>= 1.0)
 | 
			
		||||
      rainbow (>= 2.0.0)
 | 
			
		||||
    i18n (1.8.9)
 | 
			
		||||
    i18n (1.8.10)
 | 
			
		||||
      concurrent-ruby (~> 1.0)
 | 
			
		||||
    i18n-tasks (0.9.34)
 | 
			
		||||
      activesupport (>= 4.0.2)
 | 
			
		||||
| 
						 | 
				
			
			@ -369,7 +336,7 @@ GEM
 | 
			
		|||
      activesupport (>= 4)
 | 
			
		||||
      railties (>= 4)
 | 
			
		||||
      request_store (~> 1.0)
 | 
			
		||||
    loofah (2.9.0)
 | 
			
		||||
    loofah (2.9.1)
 | 
			
		||||
      crass (~> 1.0.2)
 | 
			
		||||
      nokogiri (>= 1.5.9)
 | 
			
		||||
    mail (2.7.1)
 | 
			
		||||
| 
						 | 
				
			
			@ -401,12 +368,17 @@ GEM
 | 
			
		|||
      net-ssh (>= 2.6.5, < 7.0.0)
 | 
			
		||||
    net-ssh (6.1.0)
 | 
			
		||||
    nio4r (2.5.7)
 | 
			
		||||
    nokogiri (1.11.2)
 | 
			
		||||
    nokogiri (1.11.3)
 | 
			
		||||
      mini_portile2 (~> 2.5.0)
 | 
			
		||||
      racc (~> 1.4)
 | 
			
		||||
    nokogumbo (2.0.4)
 | 
			
		||||
      nokogiri (~> 1.8, >= 1.8.4)
 | 
			
		||||
    oj (3.11.3)
 | 
			
		||||
    nsa (0.2.8)
 | 
			
		||||
      activesupport (>= 4.2, < 7)
 | 
			
		||||
      concurrent-ruby (~> 1.0, >= 1.0.2)
 | 
			
		||||
      sidekiq (>= 3.5)
 | 
			
		||||
      statsd-ruby (~> 1.4, >= 1.4.0)
 | 
			
		||||
    oj (3.11.5)
 | 
			
		||||
    omniauth (1.9.1)
 | 
			
		||||
      hashie (>= 3.4.6)
 | 
			
		||||
      rack (>= 1.6.2, < 3)
 | 
			
		||||
| 
						 | 
				
			
			@ -434,9 +406,9 @@ GEM
 | 
			
		|||
      av (~> 0.9.0)
 | 
			
		||||
      paperclip (>= 2.5.2)
 | 
			
		||||
    parallel (1.20.1)
 | 
			
		||||
    parallel_tests (3.6.0)
 | 
			
		||||
    parallel_tests (3.7.0)
 | 
			
		||||
      parallel
 | 
			
		||||
    parser (3.0.0.0)
 | 
			
		||||
    parser (3.0.1.0)
 | 
			
		||||
      ast (~> 2.4.1)
 | 
			
		||||
    parslet (2.0.0)
 | 
			
		||||
    pastel (0.8.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -444,7 +416,7 @@ GEM
 | 
			
		|||
    pg (1.2.3)
 | 
			
		||||
    pghero (2.8.1)
 | 
			
		||||
      activerecord (>= 5)
 | 
			
		||||
    pkg-config (1.4.5)
 | 
			
		||||
    pkg-config (1.4.6)
 | 
			
		||||
    posix-spawn (0.3.15)
 | 
			
		||||
    premailer (1.14.2)
 | 
			
		||||
      addressable
 | 
			
		||||
| 
						 | 
				
			
			@ -530,7 +502,7 @@ GEM
 | 
			
		|||
    responders (3.0.1)
 | 
			
		||||
      actionpack (>= 5.0)
 | 
			
		||||
      railties (>= 5.0)
 | 
			
		||||
    rexml (3.2.4)
 | 
			
		||||
    rexml (3.2.5)
 | 
			
		||||
    rotp (6.2.0)
 | 
			
		||||
    rpam2 (4.0.2)
 | 
			
		||||
    rqrcode (1.2.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -591,7 +563,7 @@ GEM
 | 
			
		|||
      railties (>= 4.0.0)
 | 
			
		||||
    securecompare (1.0.0)
 | 
			
		||||
    semantic_range (2.3.0)
 | 
			
		||||
    sidekiq (6.2.0)
 | 
			
		||||
    sidekiq (6.2.1)
 | 
			
		||||
      connection_pool (>= 2.2.2)
 | 
			
		||||
      rack (~> 2.0)
 | 
			
		||||
      redis (>= 4.2.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -604,7 +576,7 @@ GEM
 | 
			
		|||
      sidekiq (>= 3)
 | 
			
		||||
      thwait
 | 
			
		||||
      tilt (>= 1.4.0)
 | 
			
		||||
    sidekiq-unique-jobs (7.0.7)
 | 
			
		||||
    sidekiq-unique-jobs (7.0.8)
 | 
			
		||||
      brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
 | 
			
		||||
      concurrent-ruby (~> 1.0, >= 1.0.5)
 | 
			
		||||
      sidekiq (>= 5.0, < 7.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -651,7 +623,7 @@ GEM
 | 
			
		|||
      openssl-signature_algorithm (~> 0.4.0)
 | 
			
		||||
    tty-color (0.6.0)
 | 
			
		||||
    tty-cursor (0.7.1)
 | 
			
		||||
    tty-prompt (0.23.0)
 | 
			
		||||
    tty-prompt (0.23.1)
 | 
			
		||||
      pastel (~> 0.8)
 | 
			
		||||
      tty-reader (~> 0.8)
 | 
			
		||||
    tty-reader (0.9.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -728,13 +700,13 @@ DEPENDENCIES
 | 
			
		|||
  capybara (~> 3.35)
 | 
			
		||||
  charlock_holmes (~> 0.7.7)
 | 
			
		||||
  chewy (~> 5.2)
 | 
			
		||||
  cld3 (~> 3.4.1)
 | 
			
		||||
  cld3 (~> 3.4.2)
 | 
			
		||||
  climate_control (~> 0.2)
 | 
			
		||||
  color_diff (~> 0.1)
 | 
			
		||||
  concurrent-ruby
 | 
			
		||||
  connection_pool
 | 
			
		||||
  devise (~> 4.7)
 | 
			
		||||
  devise-two-factor!
 | 
			
		||||
  devise-two-factor (~> 4.0)
 | 
			
		||||
  devise_pam_authenticatable2 (~> 9.2)
 | 
			
		||||
  discard (~> 1.2)
 | 
			
		||||
  doorkeeper (~> 5.5)
 | 
			
		||||
| 
						 | 
				
			
			@ -769,9 +741,8 @@ DEPENDENCIES
 | 
			
		|||
  microformats (~> 4.2)
 | 
			
		||||
  mime-types (~> 3.3.1)
 | 
			
		||||
  net-ldap (~> 0.17)
 | 
			
		||||
  nilsimsa!
 | 
			
		||||
  nokogiri (~> 1.11)
 | 
			
		||||
  nsa!
 | 
			
		||||
  nsa (~> 0.2)
 | 
			
		||||
  oj (~> 3.11)
 | 
			
		||||
  omniauth (~> 1.9)
 | 
			
		||||
  omniauth-cas (~> 2.0)
 | 
			
		||||
| 
						 | 
				
			
			@ -781,12 +752,11 @@ DEPENDENCIES
 | 
			
		|||
  paperclip (~> 6.0)
 | 
			
		||||
  paperclip-av-transcoder (~> 0.6)
 | 
			
		||||
  parallel (~> 1.20)
 | 
			
		||||
  parallel_tests (~> 3.6)
 | 
			
		||||
  parallel_tests (~> 3.7)
 | 
			
		||||
  parslet
 | 
			
		||||
  pg (~> 1.2)
 | 
			
		||||
  pghero (~> 2.8)
 | 
			
		||||
  pkg-config (~> 1.4)
 | 
			
		||||
  pluck_each!
 | 
			
		||||
  posix-spawn
 | 
			
		||||
  premailer-rails
 | 
			
		||||
  private_address_check (~> 0.5)
 | 
			
		||||
| 
						 | 
				
			
			@ -834,5 +804,5 @@ DEPENDENCIES
 | 
			
		|||
  webauthn (~> 3.0.0.alpha1)
 | 
			
		||||
  webmock (~> 3.12)
 | 
			
		||||
  webpacker (~> 5.2)
 | 
			
		||||
  webpush
 | 
			
		||||
  webpush (~> 0.3)
 | 
			
		||||
  xorcist (~> 1.1)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -36,7 +36,6 @@ module Admin
 | 
			
		|||
      @profile_directory     = Setting.profile_directory
 | 
			
		||||
      @timeline_preview      = Setting.timeline_preview
 | 
			
		||||
      @keybase_integration   = Setting.enable_keybase
 | 
			
		||||
      @spam_check_enabled    = Setting.spam_check_enabled
 | 
			
		||||
      @trends_enabled        = Setting.trends
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										53
									
								
								app/controllers/admin/follow_recommendations_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								app/controllers/admin/follow_recommendations_controller.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,53 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module Admin
 | 
			
		||||
  class FollowRecommendationsController < BaseController
 | 
			
		||||
    before_action :set_language
 | 
			
		||||
 | 
			
		||||
    def show
 | 
			
		||||
      authorize :follow_recommendation, :show?
 | 
			
		||||
 | 
			
		||||
      @form     = Form::AccountBatch.new
 | 
			
		||||
      @accounts = filtered_follow_recommendations
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def update
 | 
			
		||||
      @form = Form::AccountBatch.new(form_account_batch_params.merge(current_account: current_account, action: action_from_button))
 | 
			
		||||
      @form.save
 | 
			
		||||
    rescue ActionController::ParameterMissing
 | 
			
		||||
      # Do nothing
 | 
			
		||||
    ensure
 | 
			
		||||
      redirect_to admin_follow_recommendations_path(filter_params)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    private
 | 
			
		||||
 | 
			
		||||
    def set_language
 | 
			
		||||
      @language = follow_recommendation_filter.language
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def filtered_follow_recommendations
 | 
			
		||||
      follow_recommendation_filter.results
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def follow_recommendation_filter
 | 
			
		||||
      @follow_recommendation_filter ||= FollowRecommendationFilter.new(filter_params)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def form_account_batch_params
 | 
			
		||||
      params.require(:form_account_batch).permit(:action, account_ids: [])
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def filter_params
 | 
			
		||||
      params.slice(*FollowRecommendationFilter::KEYS).permit(*FollowRecommendationFilter::KEYS)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def action_from_button
 | 
			
		||||
      if params[:suppress]
 | 
			
		||||
        'suppress_follow_recommendation'
 | 
			
		||||
      elsif params[:unsuppress]
 | 
			
		||||
        'unsuppress_follow_recommendation'
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -3,13 +3,13 @@
 | 
			
		|||
class Api::V1::Push::SubscriptionsController < Api::BaseController
 | 
			
		||||
  before_action -> { doorkeeper_authorize! :push }
 | 
			
		||||
  before_action :require_user!
 | 
			
		||||
  before_action :set_web_push_subscription
 | 
			
		||||
  before_action :check_web_push_subscription, only: [:show, :update]
 | 
			
		||||
  before_action :set_push_subscription
 | 
			
		||||
  before_action :check_push_subscription, only: [:show, :update]
 | 
			
		||||
 | 
			
		||||
  def create
 | 
			
		||||
    @web_subscription&.destroy!
 | 
			
		||||
    @push_subscription&.destroy!
 | 
			
		||||
 | 
			
		||||
    @web_subscription = ::Web::PushSubscription.create!(
 | 
			
		||||
    @push_subscription = Web::PushSubscription.create!(
 | 
			
		||||
      endpoint: subscription_params[:endpoint],
 | 
			
		||||
      key_p256dh: subscription_params[:keys][:p256dh],
 | 
			
		||||
      key_auth: subscription_params[:keys][:auth],
 | 
			
		||||
| 
						 | 
				
			
			@ -18,31 +18,31 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
 | 
			
		|||
      access_token_id: doorkeeper_token.id
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
    render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def show
 | 
			
		||||
    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
    render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update
 | 
			
		||||
    @web_subscription.update!(data: data_params)
 | 
			
		||||
    render json: @web_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
    @push_subscription.update!(data: data_params)
 | 
			
		||||
    render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def destroy
 | 
			
		||||
    @web_subscription&.destroy!
 | 
			
		||||
    @push_subscription&.destroy!
 | 
			
		||||
    render_empty
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def set_web_push_subscription
 | 
			
		||||
    @web_subscription = ::Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
 | 
			
		||||
  def set_push_subscription
 | 
			
		||||
    @push_subscription = Web::PushSubscription.find_by(access_token_id: doorkeeper_token.id)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def check_web_push_subscription
 | 
			
		||||
    not_found if @web_subscription.nil?
 | 
			
		||||
  def check_push_subscription
 | 
			
		||||
    not_found if @push_subscription.nil?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def subscription_params
 | 
			
		||||
| 
						 | 
				
			
			@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
 | 
			
		|||
  def data_params
 | 
			
		||||
    return {} if params[:data].blank?
 | 
			
		||||
 | 
			
		||||
    params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
 | 
			
		||||
    params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,6 +19,6 @@ class Api::V1::SuggestionsController < Api::BaseController
 | 
			
		|||
  private
 | 
			
		||||
 | 
			
		||||
  def set_accounts
 | 
			
		||||
    @accounts = PotentialFriendshipTracker.get(current_account.id, limit: limit_param(DEFAULT_ACCOUNTS_LIMIT))
 | 
			
		||||
    @accounts = PotentialFriendshipTracker.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										19
									
								
								app/controllers/api/v2/suggestions_controller.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								app/controllers/api/v2/suggestions_controller.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Api::V2::SuggestionsController < Api::BaseController
 | 
			
		||||
  include Authorization
 | 
			
		||||
 | 
			
		||||
  before_action -> { doorkeeper_authorize! :read }
 | 
			
		||||
  before_action :require_user!
 | 
			
		||||
  before_action :set_suggestions
 | 
			
		||||
 | 
			
		||||
  def index
 | 
			
		||||
    render json: @suggestions, each_serializer: REST::SuggestionSerializer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def set_suggestions
 | 
			
		||||
    @suggestions = AccountSuggestions.get(current_account, limit_param(DEFAULT_ACCOUNTS_LIMIT))
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -2,6 +2,7 @@
 | 
			
		|||
 | 
			
		||||
class Api::Web::PushSubscriptionsController < Api::Web::BaseController
 | 
			
		||||
  before_action :require_user!
 | 
			
		||||
  before_action :set_push_subscription, only: :update
 | 
			
		||||
 | 
			
		||||
  def create
 | 
			
		||||
    active_session = current_session
 | 
			
		||||
| 
						 | 
				
			
			@ -15,9 +16,11 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
 | 
			
		|||
    alerts_enabled = active_session.detection.device.mobile? || active_session.detection.device.tablet?
 | 
			
		||||
 | 
			
		||||
    data = {
 | 
			
		||||
      policy: 'all',
 | 
			
		||||
 | 
			
		||||
      alerts: {
 | 
			
		||||
        follow: alerts_enabled,
 | 
			
		||||
        follow_request: false,
 | 
			
		||||
        follow_request: alerts_enabled,
 | 
			
		||||
        favourite: alerts_enabled,
 | 
			
		||||
        reblog: alerts_enabled,
 | 
			
		||||
        mention: alerts_enabled,
 | 
			
		||||
| 
						 | 
				
			
			@ -28,7 +31,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
 | 
			
		|||
 | 
			
		||||
    data.deep_merge!(data_params) if params[:data]
 | 
			
		||||
 | 
			
		||||
    web_subscription = ::Web::PushSubscription.create!(
 | 
			
		||||
    push_subscription = ::Web::PushSubscription.create!(
 | 
			
		||||
      endpoint: subscription_params[:endpoint],
 | 
			
		||||
      key_p256dh: subscription_params[:keys][:p256dh],
 | 
			
		||||
      key_auth: subscription_params[:keys][:auth],
 | 
			
		||||
| 
						 | 
				
			
			@ -37,27 +40,27 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
 | 
			
		|||
      access_token_id: active_session.access_token_id
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    active_session.update!(web_push_subscription: web_subscription)
 | 
			
		||||
    active_session.update!(web_push_subscription: push_subscription)
 | 
			
		||||
 | 
			
		||||
    render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
    render json: push_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def update
 | 
			
		||||
    params.require([:id])
 | 
			
		||||
 | 
			
		||||
    web_subscription = ::Web::PushSubscription.find(params[:id])
 | 
			
		||||
    web_subscription.update!(data: data_params)
 | 
			
		||||
 | 
			
		||||
    render json: web_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
    @push_subscription.update!(data: data_params)
 | 
			
		||||
    render json: @push_subscription, serializer: REST::WebPushSubscriptionSerializer
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def set_push_subscription
 | 
			
		||||
    @push_subscription = ::Web::PushSubscription.find(params[:id])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def subscription_params
 | 
			
		||||
    @subscription_params ||= params.require(:subscription).permit(:endpoint, keys: [:auth, :p256dh])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def data_params
 | 
			
		||||
    @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
 | 
			
		||||
    @data_params ||= params.require(:data).permit(:policy, alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -91,8 +91,6 @@ module ApplicationHelper
 | 
			
		|||
      fa_icon('unlock', title: I18n.t('statuses.visibilities.unlisted'))
 | 
			
		||||
    elsif status.private_visibility? || status.limited_visibility?
 | 
			
		||||
      fa_icon('lock', title: I18n.t('statuses.visibilities.private'))
 | 
			
		||||
    elsif status.direct_visibility?
 | 
			
		||||
      fa_icon('envelope', title: I18n.t('statuses.visibilities.direct'))
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										18
									
								
								app/helpers/email_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								app/helpers/email_helper.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module EmailHelper
 | 
			
		||||
  def self.included(base)
 | 
			
		||||
    base.extend(self)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def email_to_canonical_email(str)
 | 
			
		||||
    username, domain = str.downcase.split('@', 2)
 | 
			
		||||
    username, = username.gsub('.', '').split('+', 2)
 | 
			
		||||
 | 
			
		||||
    "#{username}@#{domain}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def email_to_canonical_email_hash(str)
 | 
			
		||||
    Digest::SHA2.new(256).hexdigest(email_to_canonical_email(str))
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -24,6 +24,7 @@ export function normalizeAccount(account) {
 | 
			
		|||
 | 
			
		||||
  account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
 | 
			
		||||
  account.note_emojified = emojify(account.note, emojiMap);
 | 
			
		||||
  account.note_plain = unescapeHTML(account.note);
 | 
			
		||||
 | 
			
		||||
  if (account.fields) {
 | 
			
		||||
    account.fields = account.fields.map(pair => ({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,21 +1,8 @@
 | 
			
		|||
import { changeSetting, saveSettings } from './settings';
 | 
			
		||||
import { requestBrowserPermission } from './notifications';
 | 
			
		||||
 | 
			
		||||
export const INTRODUCTION_VERSION = 20181216044202;
 | 
			
		||||
 | 
			
		||||
export const closeOnboarding = () => dispatch => {
 | 
			
		||||
  dispatch(changeSetting(['introductionVersion'], INTRODUCTION_VERSION));
 | 
			
		||||
  dispatch(saveSettings());
 | 
			
		||||
 | 
			
		||||
  dispatch(requestBrowserPermission((permission) => {
 | 
			
		||||
    if (permission === 'granted') {
 | 
			
		||||
      dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
 | 
			
		||||
      dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
 | 
			
		||||
      dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
 | 
			
		||||
      dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
 | 
			
		||||
      dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
 | 
			
		||||
      dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
 | 
			
		||||
      dispatch(saveSettings());
 | 
			
		||||
    }
 | 
			
		||||
  }));
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,6 @@
 | 
			
		|||
import api from '../api';
 | 
			
		||||
import { importFetchedAccounts } from './importer';
 | 
			
		||||
import { fetchRelationships } from './accounts';
 | 
			
		||||
 | 
			
		||||
export const SUGGESTIONS_FETCH_REQUEST = 'SUGGESTIONS_FETCH_REQUEST';
 | 
			
		||||
export const SUGGESTIONS_FETCH_SUCCESS = 'SUGGESTIONS_FETCH_SUCCESS';
 | 
			
		||||
| 
						 | 
				
			
			@ -7,13 +8,17 @@ export const SUGGESTIONS_FETCH_FAIL    = 'SUGGESTIONS_FETCH_FAIL';
 | 
			
		|||
 | 
			
		||||
export const SUGGESTIONS_DISMISS = 'SUGGESTIONS_DISMISS';
 | 
			
		||||
 | 
			
		||||
export function fetchSuggestions() {
 | 
			
		||||
export function fetchSuggestions(withRelationships = false) {
 | 
			
		||||
  return (dispatch, getState) => {
 | 
			
		||||
    dispatch(fetchSuggestionsRequest());
 | 
			
		||||
 | 
			
		||||
    api(getState).get('/api/v1/suggestions').then(response => {
 | 
			
		||||
      dispatch(importFetchedAccounts(response.data));
 | 
			
		||||
    api(getState).get('/api/v2/suggestions').then(response => {
 | 
			
		||||
      dispatch(importFetchedAccounts(response.data.map(x => x.account)));
 | 
			
		||||
      dispatch(fetchSuggestionsSuccess(response.data));
 | 
			
		||||
 | 
			
		||||
      if (withRelationships) {
 | 
			
		||||
        dispatch(fetchRelationships(response.data.map(item => item.account.id)));
 | 
			
		||||
      }
 | 
			
		||||
    }).catch(error => dispatch(fetchSuggestionsFail(error)));
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -25,10 +30,10 @@ export function fetchSuggestionsRequest() {
 | 
			
		|||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export function fetchSuggestionsSuccess(accounts) {
 | 
			
		||||
export function fetchSuggestionsSuccess(suggestions) {
 | 
			
		||||
  return {
 | 
			
		||||
    type: SUGGESTIONS_FETCH_SUCCESS,
 | 
			
		||||
    accounts,
 | 
			
		||||
    suggestions,
 | 
			
		||||
    skipLoading: true,
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -48,5 +53,12 @@ export const dismissSuggestion = accountId => (dispatch, getState) => {
 | 
			
		|||
    id: accountId,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  api(getState).delete(`/api/v1/suggestions/${accountId}`);
 | 
			
		||||
  api(getState).delete(`/api/v1/suggestions/${accountId}`).then(() => {
 | 
			
		||||
    dispatch(fetchSuggestionsRequest());
 | 
			
		||||
 | 
			
		||||
    api(getState).get('/api/v2/suggestions').then(response => {
 | 
			
		||||
      dispatch(importFetchedAccounts(response.data.map(x => x.account)));
 | 
			
		||||
      dispatch(fetchSuggestionsSuccess(response.data));
 | 
			
		||||
    }).catch(error => dispatch(fetchSuggestionsFail(error)));
 | 
			
		||||
  }).catch(() => {});
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -78,8 +78,10 @@ class Account extends ImmutablePureComponent {
 | 
			
		|||
 | 
			
		||||
    let buttons;
 | 
			
		||||
 | 
			
		||||
    if (onActionClick && actionIcon) {
 | 
			
		||||
      buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
 | 
			
		||||
    if (actionIcon) {
 | 
			
		||||
      if (onActionClick) {
 | 
			
		||||
        buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
 | 
			
		||||
      }
 | 
			
		||||
    } else if (account.get('id') !== me && account.get('relationship', null) !== null) {
 | 
			
		||||
      const following = account.getIn(['relationship', 'following']);
 | 
			
		||||
      const requested = account.getIn(['relationship', 'requested']);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										9
									
								
								app/javascript/mastodon/components/logo.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/javascript/mastodon/components/logo.js
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
 | 
			
		||||
const Logo = () => (
 | 
			
		||||
  <svg viewBox='0 0 216.4144 232.00976' className='logo'>
 | 
			
		||||
    <use xlinkHref='#mastodon-svg-logo' />
 | 
			
		||||
  </svg>
 | 
			
		||||
);
 | 
			
		||||
 | 
			
		||||
export default Logo;
 | 
			
		||||
| 
						 | 
				
			
			@ -1,12 +1,10 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import { Provider, connect } from 'react-redux';
 | 
			
		||||
import { Provider } from 'react-redux';
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import configureStore from '../store/configureStore';
 | 
			
		||||
import { INTRODUCTION_VERSION } from '../actions/onboarding';
 | 
			
		||||
import { BrowserRouter, Route } from 'react-router-dom';
 | 
			
		||||
import { ScrollContext } from 'react-router-scroll-4';
 | 
			
		||||
import UI from '../features/ui';
 | 
			
		||||
import Introduction from '../features/introduction';
 | 
			
		||||
import { fetchCustomEmojis } from '../actions/custom_emojis';
 | 
			
		||||
import { hydrateStore } from '../actions/store';
 | 
			
		||||
import { connectUserStream } from '../actions/streaming';
 | 
			
		||||
| 
						 | 
				
			
			@ -26,39 +24,6 @@ const hydrateAction = hydrateStore(initialState);
 | 
			
		|||
store.dispatch(hydrateAction);
 | 
			
		||||
store.dispatch(fetchCustomEmojis());
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = state => ({
 | 
			
		||||
  showIntroduction: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@connect(mapStateToProps)
 | 
			
		||||
class MastodonMount extends React.PureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    showIntroduction: PropTypes.bool,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  shouldUpdateScroll (_, { location }) {
 | 
			
		||||
    return location.state !== previewMediaState && location.state !== previewVideoState;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { showIntroduction } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (showIntroduction) {
 | 
			
		||||
      return <Introduction />;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <BrowserRouter basename='/web'>
 | 
			
		||||
        <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
 | 
			
		||||
          <Route path='/' component={UI} />
 | 
			
		||||
        </ScrollContext>
 | 
			
		||||
      </BrowserRouter>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default class Mastodon extends React.PureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
| 
						 | 
				
			
			@ -76,6 +41,10 @@ export default class Mastodon extends React.PureComponent {
 | 
			
		|||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  shouldUpdateScroll (_, { location }) {
 | 
			
		||||
    return location.state !== previewMediaState && location.state !== previewVideoState;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { locale } = this.props;
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -83,7 +52,11 @@ export default class Mastodon extends React.PureComponent {
 | 
			
		|||
      <IntlProvider locale={locale} messages={messages}>
 | 
			
		||||
        <Provider store={store}>
 | 
			
		||||
          <ErrorBoundary>
 | 
			
		||||
            <MastodonMount />
 | 
			
		||||
            <BrowserRouter basename='/web'>
 | 
			
		||||
              <ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
 | 
			
		||||
                <Route path='/' component={UI} />
 | 
			
		||||
              </ScrollContext>
 | 
			
		||||
            </BrowserRouter>
 | 
			
		||||
          </ErrorBoundary>
 | 
			
		||||
        </Provider>
 | 
			
		||||
      </IntlProvider>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -51,12 +51,12 @@ class SearchResults extends ImmutablePureComponent {
 | 
			
		|||
              <FormattedMessage id='suggestions.header' defaultMessage='You might be interested in…' />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            {suggestions && suggestions.map(accountId => (
 | 
			
		||||
            {suggestions && suggestions.map(suggestion => (
 | 
			
		||||
              <AccountContainer
 | 
			
		||||
                key={accountId}
 | 
			
		||||
                id={accountId}
 | 
			
		||||
                actionIcon='times'
 | 
			
		||||
                actionTitle={intl.formatMessage(messages.dismissSuggestion)}
 | 
			
		||||
                key={suggestion.get('account')}
 | 
			
		||||
                id={suggestion.get('account')}
 | 
			
		||||
                actionIcon={suggestion.get('source') === 'past_interaction' ? 'times' : null}
 | 
			
		||||
                actionTitle={suggestion.get('source') === 'past_interaction' ? intl.formatMessage(messages.dismissSuggestion) : null}
 | 
			
		||||
                onActionClick={dismissSuggestion}
 | 
			
		||||
              />
 | 
			
		||||
            ))}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,7 +11,7 @@ const emojiFilenames = (emojis) => {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
// Emoji requiring extra borders depending on theme
 | 
			
		||||
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲']);
 | 
			
		||||
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺', '📱', '📲', '🚲']);
 | 
			
		||||
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
 | 
			
		||||
 | 
			
		||||
const emojiFilename = (filename) => {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,85 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
import { connect } from 'react-redux';
 | 
			
		||||
import { makeGetAccount } from 'mastodon/selectors';
 | 
			
		||||
import Avatar from 'mastodon/components/avatar';
 | 
			
		||||
import DisplayName from 'mastodon/components/display_name';
 | 
			
		||||
import Permalink from 'mastodon/components/permalink';
 | 
			
		||||
import IconButton from 'mastodon/components/icon_button';
 | 
			
		||||
import { injectIntl, defineMessages } from 'react-intl';
 | 
			
		||||
import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  follow: { id: 'account.follow', defaultMessage: 'Follow' },
 | 
			
		||||
  unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const makeMapStateToProps = () => {
 | 
			
		||||
  const getAccount = makeGetAccount();
 | 
			
		||||
 | 
			
		||||
  const mapStateToProps = (state, props) => ({
 | 
			
		||||
    account: getAccount(state, props.id),
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return mapStateToProps;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getFirstSentence = str => {
 | 
			
		||||
  const arr = str.split(/(([\.\?!]+\s)|[.。?!\n•])/);
 | 
			
		||||
 | 
			
		||||
  return arr[0];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default @connect(makeMapStateToProps)
 | 
			
		||||
@injectIntl
 | 
			
		||||
class Account extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    account: ImmutablePropTypes.map.isRequired,
 | 
			
		||||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
    dispatch: PropTypes.func.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleFollow = () => {
 | 
			
		||||
    const { account, dispatch } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
 | 
			
		||||
      dispatch(unfollowAccount(account.get('id')));
 | 
			
		||||
    } else {
 | 
			
		||||
      dispatch(followAccount(account.get('id')));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { account, intl } = this.props;
 | 
			
		||||
 | 
			
		||||
    let button;
 | 
			
		||||
 | 
			
		||||
    if (account.getIn(['relationship', 'following'])) {
 | 
			
		||||
      button = <IconButton icon='check' title={intl.formatMessage(messages.unfollow)} active onClick={this.handleFollow} />;
 | 
			
		||||
    } else {
 | 
			
		||||
      button = <IconButton icon='plus' title={intl.formatMessage(messages.follow)} onClick={this.handleFollow} />;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <div className='account follow-recommendations-account'>
 | 
			
		||||
        <div className='account__wrapper'>
 | 
			
		||||
          <Permalink className='account__display-name account__display-name--with-note' title={account.get('acct')} href={account.get('url')} to={`/accounts/${account.get('id')}`}>
 | 
			
		||||
            <div className='account__avatar-wrapper'><Avatar account={account} size={36} /></div>
 | 
			
		||||
 | 
			
		||||
            <DisplayName account={account} />
 | 
			
		||||
 | 
			
		||||
            <div className='account__note'>{getFirstSentence(account.get('note_plain'))}</div>
 | 
			
		||||
          </Permalink>
 | 
			
		||||
 | 
			
		||||
          <div className='account__relationship'>
 | 
			
		||||
            {button}
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,95 @@
 | 
			
		|||
import React from 'react';
 | 
			
		||||
import PropTypes from 'prop-types';
 | 
			
		||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
			
		||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
			
		||||
import { connect } from 'react-redux';
 | 
			
		||||
import { FormattedMessage } from 'react-intl';
 | 
			
		||||
import { fetchSuggestions } from 'mastodon/actions/suggestions';
 | 
			
		||||
import { changeSetting, saveSettings } from 'mastodon/actions/settings';
 | 
			
		||||
import { requestBrowserPermission } from 'mastodon/actions/notifications';
 | 
			
		||||
import Column from 'mastodon/features/ui/components/column';
 | 
			
		||||
import Account from './components/account';
 | 
			
		||||
import Logo from 'mastodon/components/logo';
 | 
			
		||||
import imageGreeting from 'mastodon/../images/elephant_ui_greeting.svg';
 | 
			
		||||
import Button from 'mastodon/components/button';
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = state => ({
 | 
			
		||||
  suggestions: state.getIn(['suggestions', 'items']),
 | 
			
		||||
  isLoading: state.getIn(['suggestions', 'isLoading']),
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export default @connect(mapStateToProps)
 | 
			
		||||
class FollowRecommendations extends ImmutablePureComponent {
 | 
			
		||||
 | 
			
		||||
  static contextTypes = {
 | 
			
		||||
    router: PropTypes.object.isRequired,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    dispatch: PropTypes.func.isRequired,
 | 
			
		||||
    suggestions: ImmutablePropTypes.list,
 | 
			
		||||
    isLoading: PropTypes.bool,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  componentDidMount () {
 | 
			
		||||
    const { dispatch, suggestions } = this.props;
 | 
			
		||||
 | 
			
		||||
    // Don't re-fetch if we're e.g. navigating backwards to this page,
 | 
			
		||||
    // since we don't want followed accounts to disappear from the list
 | 
			
		||||
 | 
			
		||||
    if (suggestions.size === 0) {
 | 
			
		||||
      dispatch(fetchSuggestions(true));
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleDone = () => {
 | 
			
		||||
    const { dispatch } = this.props;
 | 
			
		||||
    const { router } = this.context;
 | 
			
		||||
 | 
			
		||||
    dispatch(requestBrowserPermission((permission) => {
 | 
			
		||||
      if (permission === 'granted') {
 | 
			
		||||
        dispatch(changeSetting(['notifications', 'alerts', 'follow'], true));
 | 
			
		||||
        dispatch(changeSetting(['notifications', 'alerts', 'favourite'], true));
 | 
			
		||||
        dispatch(changeSetting(['notifications', 'alerts', 'reblog'], true));
 | 
			
		||||
        dispatch(changeSetting(['notifications', 'alerts', 'mention'], true));
 | 
			
		||||
        dispatch(changeSetting(['notifications', 'alerts', 'poll'], true));
 | 
			
		||||
        dispatch(changeSetting(['notifications', 'alerts', 'status'], true));
 | 
			
		||||
        dispatch(saveSettings());
 | 
			
		||||
      }
 | 
			
		||||
    }));
 | 
			
		||||
 | 
			
		||||
    router.history.push('/timelines/home');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { suggestions, isLoading } = this.props;
 | 
			
		||||
 | 
			
		||||
    return (
 | 
			
		||||
      <Column>
 | 
			
		||||
        <div className='scrollable'>
 | 
			
		||||
          <div className='column-title'>
 | 
			
		||||
            <Logo />
 | 
			
		||||
            <h3><FormattedMessage id='follow_recommendations.heading' defaultMessage="Follow people you'd like to see posts from! Here are some suggestions." /></h3>
 | 
			
		||||
            <p><FormattedMessage id='follow_recommendations.lead' defaultMessage="Posts from people you follow will show up in chronological order on your home feed. Don't be afraid to make mistakes, you can unfollow people just as easily any time!" /></p>
 | 
			
		||||
          </div>
 | 
			
		||||
 | 
			
		||||
          {!isLoading && (
 | 
			
		||||
            <React.Fragment>
 | 
			
		||||
              <div>
 | 
			
		||||
                {suggestions.map(suggestion => (
 | 
			
		||||
                  <Account key={suggestion.get('account')} id={suggestion.get('account')} />
 | 
			
		||||
                ))}
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div className='column-actions'>
 | 
			
		||||
                <img src={imageGreeting} alt='' className='column-actions__background' />
 | 
			
		||||
                <Button onClick={this.handleDone}><FormattedMessage id='follow_recommendations.done' defaultMessage='Done' /></Button>
 | 
			
		||||
              </div>
 | 
			
		||||
            </React.Fragment>
 | 
			
		||||
          )}
 | 
			
		||||
        </div>
 | 
			
		||||
      </Column>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -51,10 +51,12 @@ import {
 | 
			
		|||
  Lists,
 | 
			
		||||
  Search,
 | 
			
		||||
  Directory,
 | 
			
		||||
  FollowRecommendations,
 | 
			
		||||
} from './util/async-components';
 | 
			
		||||
import { me } from '../../initial_state';
 | 
			
		||||
import { previewState as previewMediaState } from './components/media_modal';
 | 
			
		||||
import { previewState as previewVideoState } from './components/video_modal';
 | 
			
		||||
import { closeOnboarding, INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
 | 
			
		||||
 | 
			
		||||
// Dummy import, to make sure that <Status /> ends up in the application bundle.
 | 
			
		||||
// Without this it ends up in ~8 very commonly used bundles.
 | 
			
		||||
| 
						 | 
				
			
			@ -71,6 +73,7 @@ const mapStateToProps = state => ({
 | 
			
		|||
  hasMediaAttachments: state.getIn(['compose', 'media_attachments']).size > 0,
 | 
			
		||||
  canUploadMore: !state.getIn(['compose', 'media_attachments']).some(x => ['audio', 'video'].includes(x.get('type'))) && state.getIn(['compose', 'media_attachments']).size < 4,
 | 
			
		||||
  dropdownMenuIsOpen: state.getIn(['dropdown_menu', 'openId']) !== null,
 | 
			
		||||
  firstLaunch: state.getIn(['settings', 'introductionVersion'], 0) < INTRODUCTION_VERSION,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const keyMap = {
 | 
			
		||||
| 
						 | 
				
			
			@ -167,6 +170,7 @@ class SwitchingColumnsArea extends React.PureComponent {
 | 
			
		|||
          <WrappedRoute path='/bookmarks' component={BookmarkedStatuses} content={children} />
 | 
			
		||||
          <WrappedRoute path='/pinned' component={PinnedStatuses} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
 | 
			
		||||
 | 
			
		||||
          <WrappedRoute path='/start' component={FollowRecommendations} content={children} />
 | 
			
		||||
          <WrappedRoute path='/search' component={Search} content={children} />
 | 
			
		||||
          <WrappedRoute path='/directory' component={Directory} content={children} componentParams={{ shouldUpdateScroll: this.shouldUpdateScroll }} />
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -215,6 +219,7 @@ class UI extends React.PureComponent {
 | 
			
		|||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
    dropdownMenuIsOpen: PropTypes.bool,
 | 
			
		||||
    layout: PropTypes.string.isRequired,
 | 
			
		||||
    firstLaunch: PropTypes.bool,
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
| 
						 | 
				
			
			@ -350,6 +355,12 @@ class UI extends React.PureComponent {
 | 
			
		|||
      navigator.serviceWorker.addEventListener('message', this.handleServiceWorkerPostMessage);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // On first launch, redirect to the follow recommendations page
 | 
			
		||||
    if (this.props.firstLaunch) {
 | 
			
		||||
      this.context.router.history.replace('/start');
 | 
			
		||||
      this.props.dispatch(closeOnboarding());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.props.dispatch(fetchMarkers());
 | 
			
		||||
    this.props.dispatch(expandHomeTimeline());
 | 
			
		||||
    this.props.dispatch(expandNotifications());
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -153,3 +153,7 @@ export function Audio () {
 | 
			
		|||
export function Directory () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/directory" */'../../directory');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function FollowRecommendations () {
 | 
			
		||||
  return import(/* webpackChunkName: "features/follow_recommendations" */'../../follow_recommendations');
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -19,18 +19,18 @@ export default function suggestionsReducer(state = initialState, action) {
 | 
			
		|||
    return state.set('isLoading', true);
 | 
			
		||||
  case SUGGESTIONS_FETCH_SUCCESS:
 | 
			
		||||
    return state.withMutations(map => {
 | 
			
		||||
      map.set('items', fromJS(action.accounts.map(x => x.id)));
 | 
			
		||||
      map.set('items', fromJS(action.suggestions.map(x => ({ ...x, account: x.account.id }))));
 | 
			
		||||
      map.set('isLoading', false);
 | 
			
		||||
    });
 | 
			
		||||
  case SUGGESTIONS_FETCH_FAIL:
 | 
			
		||||
    return state.set('isLoading', false);
 | 
			
		||||
  case SUGGESTIONS_DISMISS:
 | 
			
		||||
    return state.update('items', list => list.filterNot(id => id === action.id));
 | 
			
		||||
    return state.update('items', list => list.filterNot(x => x.account === action.id));
 | 
			
		||||
  case ACCOUNT_BLOCK_SUCCESS:
 | 
			
		||||
  case ACCOUNT_MUTE_SUCCESS:
 | 
			
		||||
    return state.update('items', list => list.filterNot(id => id === action.relationship.id));
 | 
			
		||||
    return state.update('items', list => list.filterNot(x => x.account === action.relationship.id));
 | 
			
		||||
  case DOMAIN_BLOCK_SUCCESS:
 | 
			
		||||
    return state.update('items', list => list.filterNot(id => action.accounts.includes(id)));
 | 
			
		||||
    return state.update('items', list => list.filterNot(x => action.accounts.includes(x.account)));
 | 
			
		||||
  default:
 | 
			
		||||
    return state;
 | 
			
		||||
  }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1307,6 +1307,29 @@
 | 
			
		|||
    overflow: hidden;
 | 
			
		||||
    text-decoration: none;
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
 | 
			
		||||
    &--with-note {
 | 
			
		||||
      strong {
 | 
			
		||||
        display: inline;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__note {
 | 
			
		||||
    white-space: nowrap;
 | 
			
		||||
    overflow: hidden;
 | 
			
		||||
    text-overflow: ellipsis;
 | 
			
		||||
    color: $ui-secondary-color;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.follow-recommendations-account {
 | 
			
		||||
  .icon-button {
 | 
			
		||||
    color: $ui-primary-color;
 | 
			
		||||
 | 
			
		||||
    &.active {
 | 
			
		||||
      color: $valid-value-color;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -2459,6 +2482,49 @@ a.account__display-name {
 | 
			
		|||
  border-color: darken($ui-base-color, 8%);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.column-title {
 | 
			
		||||
  text-align: center;
 | 
			
		||||
  padding: 40px;
 | 
			
		||||
 | 
			
		||||
  .logo {
 | 
			
		||||
    fill: $primary-text-color;
 | 
			
		||||
    width: 50px;
 | 
			
		||||
    margin: 0 auto;
 | 
			
		||||
    margin-bottom: 40px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  h3 {
 | 
			
		||||
    font-size: 24px;
 | 
			
		||||
    line-height: 1.5;
 | 
			
		||||
    font-weight: 700;
 | 
			
		||||
    margin-bottom: 10px;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  p {
 | 
			
		||||
    font-size: 16px;
 | 
			
		||||
    line-height: 24px;
 | 
			
		||||
    font-weight: 400;
 | 
			
		||||
    color: $darker-text-color;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.column-actions {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: center;
 | 
			
		||||
  padding: 40px;
 | 
			
		||||
  padding-top: 40px;
 | 
			
		||||
  padding-bottom: 200px;
 | 
			
		||||
 | 
			
		||||
  &__background {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    left: 0;
 | 
			
		||||
    bottom: 0;
 | 
			
		||||
    height: 220px;
 | 
			
		||||
    width: auto;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.compose-panel {
 | 
			
		||||
  width: 285px;
 | 
			
		||||
  margin-top: 10px;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										25
									
								
								app/lib/account_reach_finder.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/lib/account_reach_finder.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class AccountReachFinder
 | 
			
		||||
  def initialize(account)
 | 
			
		||||
    @account = account
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def inboxes
 | 
			
		||||
    (followers_inboxes + reporters_inboxes + relay_inboxes).uniq
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def followers_inboxes
 | 
			
		||||
    @account.followers.inboxes
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def reporters_inboxes
 | 
			
		||||
    Account.where(id: @account.targeted_reports.select(:account_id)).inboxes
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def relay_inboxes
 | 
			
		||||
    Relay.enabled.pluck(:inbox_url)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -88,7 +88,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		|||
 | 
			
		||||
    resolve_thread(@status)
 | 
			
		||||
    fetch_replies(@status)
 | 
			
		||||
    check_for_spam
 | 
			
		||||
    distribute(@status)
 | 
			
		||||
    forward_for_reply
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -498,10 +497,6 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
			
		|||
    Tombstone.exists?(uri: object_uri)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def check_for_spam
 | 
			
		||||
    SpamCheck.perform(@status)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def forward_for_reply
 | 
			
		||||
    return unless @status.distributable? && @json['signature'].present? && reply_to_local?
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,8 @@ class ActivityPub::Activity::Flag < ActivityPub::Activity
 | 
			
		|||
    target_accounts.each do |target_account|
 | 
			
		||||
      target_statuses = target_statuses_by_account[target_account.id]
 | 
			
		||||
 | 
			
		||||
      next if target_account.suspended?
 | 
			
		||||
 | 
			
		||||
      ReportService.new.call(
 | 
			
		||||
        @account,
 | 
			
		||||
        target_account,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -7,7 +7,6 @@ class Admin::SystemCheck::SidekiqProcessCheck < Admin::SystemCheck::BaseCheck
 | 
			
		|||
    mailers
 | 
			
		||||
    pull
 | 
			
		||||
    scheduler
 | 
			
		||||
    ingress
 | 
			
		||||
  ).freeze
 | 
			
		||||
 | 
			
		||||
  def pass?
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -4,6 +4,8 @@ module ApplicationExtension
 | 
			
		|||
  extend ActiveSupport::Concern
 | 
			
		||||
 | 
			
		||||
  included do
 | 
			
		||||
    validates :website, url: true, if: :website?
 | 
			
		||||
    validates :name, length: { maximum: 60 }
 | 
			
		||||
    validates :website, url: true, length: { maximum: 2_000 }, if: :website?
 | 
			
		||||
    validates :redirect_uri, length: { maximum: 2_000 }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -118,7 +118,7 @@ class Formatter
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def format_field(account, str, **options)
 | 
			
		||||
    html = account.local? ? encode_and_link_urls(str, me: true) : reformat(str)
 | 
			
		||||
    html = account.local? ? encode_and_link_urls(str, me: true, with_domain: true) : reformat(str)
 | 
			
		||||
    html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
 | 
			
		||||
    html.html_safe # rubocop:disable Rails/OutputSafety
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -187,7 +187,7 @@ class Formatter
 | 
			
		|||
      elsif entity[:hashtag]
 | 
			
		||||
        link_to_hashtag(entity)
 | 
			
		||||
      elsif entity[:screen_name]
 | 
			
		||||
        link_to_mention(entity, accounts)
 | 
			
		||||
        link_to_mention(entity, accounts, options)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -352,22 +352,37 @@ class Formatter
 | 
			
		|||
    encode(entity[:url])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def link_to_mention(entity, linkable_accounts)
 | 
			
		||||
  def link_to_mention(entity, linkable_accounts, options = {})
 | 
			
		||||
    acct = entity[:screen_name]
 | 
			
		||||
 | 
			
		||||
    return link_to_account(acct) unless linkable_accounts
 | 
			
		||||
    return link_to_account(acct, options) unless linkable_accounts
 | 
			
		||||
 | 
			
		||||
    account = linkable_accounts.find { |item| TagManager.instance.same_acct?(item.acct, acct) }
 | 
			
		||||
    account ? mention_html(account) : "@#{encode(acct)}"
 | 
			
		||||
    same_username_hits = 0
 | 
			
		||||
    account = nil
 | 
			
		||||
    username, domain = acct.split('@')
 | 
			
		||||
    domain = nil if TagManager.instance.local_domain?(domain)
 | 
			
		||||
 | 
			
		||||
    linkable_accounts.each do |item|
 | 
			
		||||
      same_username = item.username.casecmp(username).zero?
 | 
			
		||||
      same_domain   = item.domain.nil? ? domain.nil? : item.domain.casecmp(domain)&.zero?
 | 
			
		||||
 | 
			
		||||
      if same_username && !same_domain
 | 
			
		||||
        same_username_hits += 1
 | 
			
		||||
      elsif same_username && same_domain
 | 
			
		||||
        account = item
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    account ? mention_html(account, with_domain: same_username_hits.positive? || options[:with_domain]) : "@#{encode(acct)}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def link_to_account(acct)
 | 
			
		||||
  def link_to_account(acct, options = {})
 | 
			
		||||
    username, domain = acct.split('@')
 | 
			
		||||
 | 
			
		||||
    domain  = nil if TagManager.instance.local_domain?(domain)
 | 
			
		||||
    account = EntityCache.instance.mention(username, domain)
 | 
			
		||||
 | 
			
		||||
    account ? mention_html(account) : "@#{encode(acct)}"
 | 
			
		||||
    account ? mention_html(account, with_domain: options[:with_domain]) : "@#{encode(acct)}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def link_to_hashtag(entity)
 | 
			
		||||
| 
						 | 
				
			
			@ -388,7 +403,7 @@ class Formatter
 | 
			
		|||
    "<a href=\"#{encode(tag_url(tag))}\" class=\"mention hashtag\" rel=\"tag\">#<span>#{encode(tag)}</span></a>"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def mention_html(account)
 | 
			
		||||
    "<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(account.username)}</span></a></span>"
 | 
			
		||||
  def mention_html(account, with_domain: false)
 | 
			
		||||
    "<span class=\"h-card\"><a href=\"#{encode(ActivityPub::TagManager.instance.url_for(account))}\" class=\"u-url mention\">@<span>#{encode(with_domain ? account.pretty_acct : account.username)}</span></a></span>"
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -28,10 +28,14 @@ class PotentialFriendshipTracker
 | 
			
		|||
      redis.zrem("interactions:#{account_id}", target_account_id)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    def get(account_id, limit: 20, offset: 0)
 | 
			
		||||
      account_ids = redis.zrevrange("interactions:#{account_id}", offset, limit)
 | 
			
		||||
      return [] if account_ids.empty?
 | 
			
		||||
      Account.searchable.where(id: account_ids)
 | 
			
		||||
    def get(account, limit)
 | 
			
		||||
      account_ids = redis.zrevrange("interactions:#{account.id}", 0, limit)
 | 
			
		||||
 | 
			
		||||
      return [] if account_ids.empty? || limit < 1
 | 
			
		||||
 | 
			
		||||
      accounts = Account.searchable.where(id: account_ids).index_by(&:id)
 | 
			
		||||
 | 
			
		||||
      account_ids.map { |id| accounts[id.to_i] }.compact
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,198 +0,0 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class SpamCheck
 | 
			
		||||
  include Redisable
 | 
			
		||||
  include ActionView::Helpers::TextHelper
 | 
			
		||||
 | 
			
		||||
  # Threshold over which two Nilsimsa values are considered
 | 
			
		||||
  # to refer to the same text
 | 
			
		||||
  NILSIMSA_COMPARE_THRESHOLD = 95
 | 
			
		||||
 | 
			
		||||
  # Nilsimsa doesn't work well on small inputs, so below
 | 
			
		||||
  # this size, we check only for exact matches with MD5
 | 
			
		||||
  NILSIMSA_MIN_SIZE = 10
 | 
			
		||||
 | 
			
		||||
  # How long to keep the trail of digests between updates,
 | 
			
		||||
  # there is no reason to store it forever
 | 
			
		||||
  EXPIRE_SET_AFTER = 1.week.seconds
 | 
			
		||||
 | 
			
		||||
  # How many digests to keep in an account's trail. If it's
 | 
			
		||||
  # too small, spam could rotate around different message templates
 | 
			
		||||
  MAX_TRAIL_SIZE = 10
 | 
			
		||||
 | 
			
		||||
  # How many detected duplicates to allow through before
 | 
			
		||||
  # considering the message as spam
 | 
			
		||||
  THRESHOLD = 5
 | 
			
		||||
 | 
			
		||||
  def initialize(status)
 | 
			
		||||
    @account = status.account
 | 
			
		||||
    @status  = status
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def skip?
 | 
			
		||||
    disabled? || already_flagged? || trusted? || no_unsolicited_mentions? || solicited_reply?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def spam?
 | 
			
		||||
    if insufficient_data?
 | 
			
		||||
      false
 | 
			
		||||
    elsif nilsimsa?
 | 
			
		||||
      digests_over_threshold?('nilsimsa') { |_, other_digest| nilsimsa_compare_value(digest, other_digest) >= NILSIMSA_COMPARE_THRESHOLD }
 | 
			
		||||
    else
 | 
			
		||||
      digests_over_threshold?('md5') { |_, other_digest| other_digest == digest }
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def flag!
 | 
			
		||||
    auto_report_status!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remember!
 | 
			
		||||
    # The scores in sorted sets don't actually have enough bits to hold an exact
 | 
			
		||||
    # value of our snowflake IDs, so we use it only for its ordering property. To
 | 
			
		||||
    # get the correct status ID back, we have to save it in the string value
 | 
			
		||||
 | 
			
		||||
    redis.zadd(redis_key, @status.id, digest_with_algorithm)
 | 
			
		||||
    redis.zremrangebyrank(redis_key, 0, -(MAX_TRAIL_SIZE + 1))
 | 
			
		||||
    redis.expire(redis_key, EXPIRE_SET_AFTER)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def reset!
 | 
			
		||||
    redis.del(redis_key)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def hashable_text
 | 
			
		||||
    return @hashable_text if defined?(@hashable_text)
 | 
			
		||||
 | 
			
		||||
    @hashable_text = @status.text
 | 
			
		||||
    @hashable_text = remove_mentions(@hashable_text)
 | 
			
		||||
    @hashable_text = strip_tags(@hashable_text) unless @status.local?
 | 
			
		||||
    @hashable_text = normalize_unicode(@status.spoiler_text + ' ' + @hashable_text)
 | 
			
		||||
    @hashable_text = remove_whitespace(@hashable_text)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def insufficient_data?
 | 
			
		||||
    hashable_text.blank?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def digest
 | 
			
		||||
    @digest ||= begin
 | 
			
		||||
      if nilsimsa?
 | 
			
		||||
        Nilsimsa.new(hashable_text).hexdigest
 | 
			
		||||
      else
 | 
			
		||||
        Digest::MD5.hexdigest(hashable_text)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def digest_with_algorithm
 | 
			
		||||
    if nilsimsa?
 | 
			
		||||
      ['nilsimsa', digest, @status.id].join(':')
 | 
			
		||||
    else
 | 
			
		||||
      ['md5', digest, @status.id].join(':')
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  class << self
 | 
			
		||||
    def perform(status)
 | 
			
		||||
      spam_check = new(status)
 | 
			
		||||
 | 
			
		||||
      return if spam_check.skip?
 | 
			
		||||
 | 
			
		||||
      if spam_check.spam?
 | 
			
		||||
        spam_check.flag!
 | 
			
		||||
      else
 | 
			
		||||
        spam_check.remember!
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def disabled?
 | 
			
		||||
    !Setting.spam_check_enabled
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remove_mentions(text)
 | 
			
		||||
    return text.gsub(Account::MENTION_RE, '') if @status.local?
 | 
			
		||||
 | 
			
		||||
    Nokogiri::HTML.fragment(text).tap do |html|
 | 
			
		||||
      mentions = @status.mentions.map { |mention| ActivityPub::TagManager.instance.url_for(mention.account) }
 | 
			
		||||
 | 
			
		||||
      html.traverse do |element|
 | 
			
		||||
        element.unlink if element.name == 'a' && mentions.include?(element['href'])
 | 
			
		||||
      end
 | 
			
		||||
    end.to_s
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def normalize_unicode(text)
 | 
			
		||||
    text.unicode_normalize(:nfkc).downcase
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remove_whitespace(text)
 | 
			
		||||
    text.gsub(/\s+/, ' ').strip
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def auto_report_status!
 | 
			
		||||
    status_ids = Status.where(visibility: %i(public unlisted)).where(id: matching_status_ids).pluck(:id) + [@status.id] if @status.distributable?
 | 
			
		||||
    ReportService.new.call(Account.representative, @account, status_ids: status_ids, comment: I18n.t('spam_check.spam_detected'))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def already_flagged?
 | 
			
		||||
    @account.silenced? || @account.targeted_reports.unresolved.where(account_id: -99).exists?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def trusted?
 | 
			
		||||
    @account.trust_level > Account::TRUST_LEVELS[:untrusted] || (@account.local? && @account.user_staff?)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def no_unsolicited_mentions?
 | 
			
		||||
    @status.mentions.all? { |mention| mention.silent? || (!@account.local? && !mention.account.local?) || mention.account.following?(@account) }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def solicited_reply?
 | 
			
		||||
    !@status.thread.nil? && @status.thread.mentions.where(account: @account).exists?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def nilsimsa_compare_value(first, second)
 | 
			
		||||
    first  = [first].pack('H*')
 | 
			
		||||
    second = [second].pack('H*')
 | 
			
		||||
    bits   = 0
 | 
			
		||||
 | 
			
		||||
    0.upto(31) do |i|
 | 
			
		||||
      bits += Nilsimsa::POPC[255 & (first[i].ord ^ second[i].ord)].ord
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    128 - bits # -128 <= Nilsimsa Compare Value <= 128
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def nilsimsa?
 | 
			
		||||
    hashable_text.size > NILSIMSA_MIN_SIZE
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def other_digests
 | 
			
		||||
    redis.zrange(redis_key, 0, -1)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def digests_over_threshold?(filter_algorithm)
 | 
			
		||||
    other_digests.select do |record|
 | 
			
		||||
      algorithm, other_digest, status_id = record.split(':')
 | 
			
		||||
 | 
			
		||||
      next unless algorithm == filter_algorithm
 | 
			
		||||
 | 
			
		||||
      yield algorithm, other_digest, status_id
 | 
			
		||||
    end.size >= THRESHOLD
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def matching_status_ids
 | 
			
		||||
    if nilsimsa?
 | 
			
		||||
      other_digests.filter_map { |record| record.split(':')[2] if record.start_with?('nilsimsa') && nilsimsa_compare_value(digest, record.split(':')[1]) >= NILSIMSA_COMPARE_THRESHOLD }
 | 
			
		||||
    else
 | 
			
		||||
      other_digests.filter_map { |record| record.split(':')[2] if record.start_with?('md5') && record.split(':')[1] == digest }
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def redis_key
 | 
			
		||||
    @redis_key ||= "spam_check:#{@account.id}"
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -6,11 +6,22 @@ class StatusReachFinder
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def inboxes
 | 
			
		||||
    Account.where(id: reached_account_ids).inboxes
 | 
			
		||||
    (reached_account_inboxes + followers_inboxes + relay_inboxes).uniq
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def reached_account_inboxes
 | 
			
		||||
    # When the status is a reblog, there are no interactions with it
 | 
			
		||||
    # directly, we assume all interactions are with the original one
 | 
			
		||||
 | 
			
		||||
    if @status.reblog?
 | 
			
		||||
      []
 | 
			
		||||
    else
 | 
			
		||||
      Account.where(id: reached_account_ids).inboxes
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def reached_account_ids
 | 
			
		||||
    [
 | 
			
		||||
      replied_to_account_id,
 | 
			
		||||
| 
						 | 
				
			
			@ -49,4 +60,16 @@ class StatusReachFinder
 | 
			
		|||
  def replies_account_ids
 | 
			
		||||
    @status.replies.pluck(:account_id)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def followers_inboxes
 | 
			
		||||
    @status.account.followers.inboxes
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def relay_inboxes
 | 
			
		||||
    if @status.public_visibility?
 | 
			
		||||
      Relay.enabled.pluck(:inbox_url)
 | 
			
		||||
    else
 | 
			
		||||
      []
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,14 +22,6 @@ class TagManager
 | 
			
		|||
    uri.normalized_host
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def same_acct?(canonical, needle)
 | 
			
		||||
    return true if canonical.casecmp(needle).zero?
 | 
			
		||||
 | 
			
		||||
    username, domain = needle.split('@')
 | 
			
		||||
 | 
			
		||||
    local_domain?(domain) && canonical.casecmp(username).zero?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def local_url?(url)
 | 
			
		||||
    uri    = Addressable::URI.parse(url).normalize
 | 
			
		||||
    return false unless uri.host
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -114,6 +114,7 @@ class Account < ApplicationRecord
 | 
			
		|||
  scope :matches_domain, ->(value) { where(arel_table[:domain].matches("%#{value}%")) }
 | 
			
		||||
  scope :searchable, -> { without_suspended.where(moved_to_account_id: nil) }
 | 
			
		||||
  scope :discoverable, -> { searchable.without_silenced.where(discoverable: true).left_outer_joins(:account_stat) }
 | 
			
		||||
  scope :followable_by, ->(account) { joins(arel_table.join(Follow.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(Follow.arel_table[:target_account_id]).and(Follow.arel_table[:account_id].eq(account.id))).join_sources).where(Follow.arel_table[:id].eq(nil)).joins(arel_table.join(FollowRequest.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:id].eq(FollowRequest.arel_table[:target_account_id]).and(FollowRequest.arel_table[:account_id].eq(account.id))).join_sources).where(FollowRequest.arel_table[:id].eq(nil)) }
 | 
			
		||||
  scope :tagged_with, ->(tag) { joins(:accounts_tags).where(accounts_tags: { tag_id: tag }) }
 | 
			
		||||
  scope :by_recent_status, -> { order(Arel.sql('(case when account_stats.last_status_at is null then 1 else 0 end) asc, account_stats.last_status_at desc, accounts.id desc')) }
 | 
			
		||||
  scope :by_recent_sign_in, -> { order(Arel.sql('(case when users.current_sign_in_at is null then 1 else 0 end) asc, users.current_sign_in_at desc, accounts.id desc')) }
 | 
			
		||||
| 
						 | 
				
			
			@ -238,6 +239,7 @@ class Account < ApplicationRecord
 | 
			
		|||
    transaction do
 | 
			
		||||
      create_deletion_request!
 | 
			
		||||
      update!(suspended_at: date, suspension_origin: origin)
 | 
			
		||||
      create_canonical_email_block!
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -245,6 +247,7 @@ class Account < ApplicationRecord
 | 
			
		|||
    transaction do
 | 
			
		||||
      deletion_request&.destroy!
 | 
			
		||||
      update!(suspended_at: nil, suspension_origin: nil)
 | 
			
		||||
      destroy_canonical_email_block!
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -365,7 +368,7 @@ class Account < ApplicationRecord
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def excluded_from_timeline_account_ids
 | 
			
		||||
    Rails.cache.fetch("exclude_account_ids_for:#{id}") { blocking.pluck(:target_account_id) + blocked_by.pluck(:account_id) + muting.pluck(:target_account_id) }
 | 
			
		||||
    Rails.cache.fetch("exclude_account_ids_for:#{id}") { block_relationships.pluck(:target_account_id) + blocked_by_relationships.pluck(:account_id) + mute_relationships.pluck(:target_account_id) }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def excluded_from_timeline_domains
 | 
			
		||||
| 
						 | 
				
			
			@ -570,4 +573,16 @@ class Account < ApplicationRecord
 | 
			
		|||
  def clean_feed_manager
 | 
			
		||||
    FeedManager.instance.clean_feeds!(:home, [id])
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def create_canonical_email_block!
 | 
			
		||||
    return unless local? && user_email.present?
 | 
			
		||||
 | 
			
		||||
    CanonicalEmailBlock.create(reference_account: self, email: user_email)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def destroy_canonical_email_block!
 | 
			
		||||
    return unless local?
 | 
			
		||||
 | 
			
		||||
    CanonicalEmailBlock.where(reference_account: self).delete_all
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										17
									
								
								app/models/account_suggestions.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								app/models/account_suggestions.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class AccountSuggestions
 | 
			
		||||
  class Suggestion < ActiveModelSerializers::Model
 | 
			
		||||
    attributes :account, :source
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.get(account, limit)
 | 
			
		||||
    suggestions = PotentialFriendshipTracker.get(account, limit).map { |target_account| Suggestion.new(account: target_account, source: :past_interaction) }
 | 
			
		||||
    suggestions.concat(FollowRecommendation.get(account, limit - suggestions.size, suggestions.map { |suggestion| suggestion.account.id }).map { |target_account| Suggestion.new(account: target_account, source: :global) }) if suggestions.size < limit
 | 
			
		||||
    suggestions
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.remove(account, target_account_id)
 | 
			
		||||
    PotentialFriendshipTracker.remove(account.id, target_account_id)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										25
									
								
								app/models/account_summary.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								app/models/account_summary.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,25 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
# == Schema Information
 | 
			
		||||
#
 | 
			
		||||
# Table name: account_summaries
 | 
			
		||||
#
 | 
			
		||||
#  account_id :bigint(8)        primary key
 | 
			
		||||
#  language   :string
 | 
			
		||||
#  sensitive  :boolean
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class AccountSummary < ApplicationRecord
 | 
			
		||||
  self.primary_key = :account_id
 | 
			
		||||
 | 
			
		||||
  scope :safe, -> { where(sensitive: false) }
 | 
			
		||||
  scope :localized, ->(locale) { where(language: locale) }
 | 
			
		||||
  scope :filtered, -> { joins(arel_table.join(FollowRecommendationSuppression.arel_table, Arel::Nodes::OuterJoin).on(arel_table[:account_id].eq(FollowRecommendationSuppression.arel_table[:account_id])).join_sources).where(FollowRecommendationSuppression.arel_table[:id].eq(nil)) }
 | 
			
		||||
 | 
			
		||||
  def self.refresh
 | 
			
		||||
    Scenic.database.refresh_materialized_view(table_name, concurrently: true, cascade: false)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def readonly?
 | 
			
		||||
    true
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										27
									
								
								app/models/canonical_email_block.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/models/canonical_email_block.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,27 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
# == Schema Information
 | 
			
		||||
#
 | 
			
		||||
# Table name: canonical_email_blocks
 | 
			
		||||
#
 | 
			
		||||
#  id                   :bigint(8)        not null, primary key
 | 
			
		||||
#  canonical_email_hash :string           default(""), not null
 | 
			
		||||
#  reference_account_id :bigint(8)        not null
 | 
			
		||||
#  created_at           :datetime         not null
 | 
			
		||||
#  updated_at           :datetime         not null
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class CanonicalEmailBlock < ApplicationRecord
 | 
			
		||||
  include EmailHelper
 | 
			
		||||
 | 
			
		||||
  belongs_to :reference_account, class_name: 'Account'
 | 
			
		||||
 | 
			
		||||
  validates :canonical_email_hash, presence: true
 | 
			
		||||
 | 
			
		||||
  def email=(email)
 | 
			
		||||
    self.canonical_email_hash = email_to_canonical_email_hash(email)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.block?(email)
 | 
			
		||||
    where(canonical_email_hash: email_to_canonical_email_hash(email)).exists?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -63,5 +63,8 @@ module AccountAssociations
 | 
			
		|||
 | 
			
		||||
    # Account deletion requests
 | 
			
		||||
    has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy
 | 
			
		||||
 | 
			
		||||
    # Follow recommendations
 | 
			
		||||
    has_one :follow_recommendation_suppression, inverse_of: :account, dependent: :destroy
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										39
									
								
								app/models/follow_recommendation.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/models/follow_recommendation.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,39 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
# == Schema Information
 | 
			
		||||
#
 | 
			
		||||
# Table name: follow_recommendations
 | 
			
		||||
#
 | 
			
		||||
#  account_id :bigint(8)        primary key
 | 
			
		||||
#  rank       :decimal(, )
 | 
			
		||||
#  reason     :text             is an Array
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class FollowRecommendation < ApplicationRecord
 | 
			
		||||
  self.primary_key = :account_id
 | 
			
		||||
 | 
			
		||||
  belongs_to :account_summary, foreign_key: :account_id
 | 
			
		||||
  belongs_to :account, foreign_key: :account_id
 | 
			
		||||
 | 
			
		||||
  scope :safe, -> { joins(:account_summary).merge(AccountSummary.safe) }
 | 
			
		||||
  scope :localized, ->(locale) { joins(:account_summary).merge(AccountSummary.localized(locale)) }
 | 
			
		||||
  scope :filtered, -> { joins(:account_summary).merge(AccountSummary.filtered) }
 | 
			
		||||
 | 
			
		||||
  def readonly?
 | 
			
		||||
    true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def self.get(account, limit, exclude_account_ids = [])
 | 
			
		||||
    account_ids = Redis.current.zrevrange("follow_recommendations:#{account.user_locale}", 0, -1).map(&:to_i) - exclude_account_ids - [account.id]
 | 
			
		||||
 | 
			
		||||
    return [] if account_ids.empty? || limit < 1
 | 
			
		||||
 | 
			
		||||
    accounts = Account.followable_by(account)
 | 
			
		||||
                      .not_excluded_by_account(account)
 | 
			
		||||
                      .not_domain_blocked_by_account(account)
 | 
			
		||||
                      .where(id: account_ids)
 | 
			
		||||
                      .limit(limit)
 | 
			
		||||
                      .index_by(&:id)
 | 
			
		||||
 | 
			
		||||
    account_ids.map { |id| accounts[id] }.compact
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										26
									
								
								app/models/follow_recommendation_filter.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								app/models/follow_recommendation_filter.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,26 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class FollowRecommendationFilter
 | 
			
		||||
  KEYS = %i(
 | 
			
		||||
    language
 | 
			
		||||
    status
 | 
			
		||||
  ).freeze
 | 
			
		||||
 | 
			
		||||
  attr_reader :params, :language
 | 
			
		||||
 | 
			
		||||
  def initialize(params)
 | 
			
		||||
    @language = params.delete('language') || I18n.locale
 | 
			
		||||
    @params   = params
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def results
 | 
			
		||||
    if params['status'] == 'suppressed'
 | 
			
		||||
      Account.joins(:follow_recommendation_suppression).order(FollowRecommendationSuppression.arel_table[:id].desc).to_a
 | 
			
		||||
    else
 | 
			
		||||
      account_ids = Redis.current.zrevrange("follow_recommendations:#{@language}", 0, -1).map(&:to_i)
 | 
			
		||||
      accounts    = Account.where(id: account_ids).index_by(&:id)
 | 
			
		||||
 | 
			
		||||
      account_ids.map { |id| accounts[id] }.compact
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										28
									
								
								app/models/follow_recommendation_suppression.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/models/follow_recommendation_suppression.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,28 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
# == Schema Information
 | 
			
		||||
#
 | 
			
		||||
# Table name: follow_recommendation_suppressions
 | 
			
		||||
#
 | 
			
		||||
#  id         :bigint(8)        not null, primary key
 | 
			
		||||
#  account_id :bigint(8)        not null
 | 
			
		||||
#  created_at :datetime         not null
 | 
			
		||||
#  updated_at :datetime         not null
 | 
			
		||||
#
 | 
			
		||||
 | 
			
		||||
class FollowRecommendationSuppression < ApplicationRecord
 | 
			
		||||
  include Redisable
 | 
			
		||||
 | 
			
		||||
  belongs_to :account
 | 
			
		||||
 | 
			
		||||
  after_commit :remove_follow_recommendations, on: :create
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def remove_follow_recommendations
 | 
			
		||||
    redis.pipelined do
 | 
			
		||||
      I18n.available_locales.each do |locale|
 | 
			
		||||
        redis.zrem("follow_recommendations:#{locale}", account_id)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -21,6 +21,10 @@ class Form::AccountBatch
 | 
			
		|||
      approve!
 | 
			
		||||
    when 'reject'
 | 
			
		||||
      reject!
 | 
			
		||||
    when 'suppress_follow_recommendation'
 | 
			
		||||
      suppress_follow_recommendation!
 | 
			
		||||
    when 'unsuppress_follow_recommendation'
 | 
			
		||||
      unsuppress_follow_recommendation!
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -79,4 +83,18 @@ class Form::AccountBatch
 | 
			
		|||
    records.each { |account| authorize(account.user, :reject?) }
 | 
			
		||||
           .each { |account| DeleteAccountService.new.call(account, reserve_email: false, reserve_username: false) }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def suppress_follow_recommendation!
 | 
			
		||||
    authorize(:follow_recommendation, :suppress?)
 | 
			
		||||
 | 
			
		||||
    accounts.each do |account|
 | 
			
		||||
      FollowRecommendationSuppression.create(account: account)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def unsuppress_follow_recommendation!
 | 
			
		||||
    authorize(:follow_recommendation, :unsuppress?)
 | 
			
		||||
 | 
			
		||||
    FollowRecommendationSuppression.where(account_id: account_ids).destroy_all
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -35,7 +35,6 @@ class Form::AdminSettings
 | 
			
		|||
    mascot
 | 
			
		||||
    show_reblogs_in_public_timelines
 | 
			
		||||
    show_replies_in_public_timelines
 | 
			
		||||
    spam_check_enabled
 | 
			
		||||
    trends
 | 
			
		||||
    trendable_by_default
 | 
			
		||||
    show_domain_blocks
 | 
			
		||||
| 
						 | 
				
			
			@ -59,7 +58,6 @@ class Form::AdminSettings
 | 
			
		|||
    enable_keybase
 | 
			
		||||
    show_reblogs_in_public_timelines
 | 
			
		||||
    show_replies_in_public_timelines
 | 
			
		||||
    spam_check_enabled
 | 
			
		||||
    trends
 | 
			
		||||
    trendable_by_default
 | 
			
		||||
    noindex
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -24,81 +24,101 @@ class Web::PushSubscription < ApplicationRecord
 | 
			
		|||
  validates :key_p256dh, presence: true
 | 
			
		||||
  validates :key_auth, presence: true
 | 
			
		||||
 | 
			
		||||
  def push(notification)
 | 
			
		||||
    I18n.with_locale(associated_user&.locale || I18n.default_locale) do
 | 
			
		||||
      push_payload(payload_for_notification(notification), 48.hours.seconds)
 | 
			
		||||
    end
 | 
			
		||||
  delegate :locale, to: :associated_user
 | 
			
		||||
 | 
			
		||||
  def encrypt(payload)
 | 
			
		||||
    Webpush::Encryption.encrypt(payload, key_p256dh, key_auth)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def audience
 | 
			
		||||
    @audience ||= Addressable::URI.parse(endpoint).normalized_site
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def crypto_key_header
 | 
			
		||||
    p256ecdsa = vapid_key.public_key_for_push_header
 | 
			
		||||
 | 
			
		||||
    "p256ecdsa=#{p256ecdsa}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def authorization_header
 | 
			
		||||
    jwt = JWT.encode({ aud: audience, exp: 24.hours.from_now.to_i, sub: "mailto:#{contact_email}" }, vapid_key.curve, 'ES256', typ: 'JWT')
 | 
			
		||||
 | 
			
		||||
    "WebPush #{jwt}"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def pushable?(notification)
 | 
			
		||||
    data&.key?('alerts') && ActiveModel::Type::Boolean.new.cast(data['alerts'][notification.type.to_s])
 | 
			
		||||
    policy_allows_notification?(notification) && alert_enabled_for_notification_type?(notification)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def associated_user
 | 
			
		||||
    return @associated_user if defined?(@associated_user)
 | 
			
		||||
 | 
			
		||||
    @associated_user = if user_id.nil?
 | 
			
		||||
                         session_activation.user
 | 
			
		||||
                       else
 | 
			
		||||
                         user
 | 
			
		||||
                       end
 | 
			
		||||
    @associated_user = begin
 | 
			
		||||
      if user_id.nil?
 | 
			
		||||
        session_activation.user
 | 
			
		||||
      else
 | 
			
		||||
        user
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def associated_access_token
 | 
			
		||||
    return @associated_access_token if defined?(@associated_access_token)
 | 
			
		||||
 | 
			
		||||
    @associated_access_token = if access_token_id.nil?
 | 
			
		||||
                                 find_or_create_access_token.token
 | 
			
		||||
                               else
 | 
			
		||||
                                 access_token.token
 | 
			
		||||
                               end
 | 
			
		||||
    @associated_access_token = begin
 | 
			
		||||
      if access_token_id.nil?
 | 
			
		||||
        find_or_create_access_token.token
 | 
			
		||||
      else
 | 
			
		||||
        access_token.token
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  class << self
 | 
			
		||||
    def unsubscribe_for(application_id, resource_owner)
 | 
			
		||||
      access_token_ids = Doorkeeper::AccessToken.where(application_id: application_id, resource_owner_id: resource_owner.id, revoked_at: nil)
 | 
			
		||||
                                                .pluck(:id)
 | 
			
		||||
 | 
			
		||||
      access_token_ids = Doorkeeper::AccessToken.where(application_id: application_id, resource_owner_id: resource_owner.id, revoked_at: nil).pluck(:id)
 | 
			
		||||
      where(access_token_id: access_token_ids).delete_all
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def push_payload(message, ttl = 5.minutes.seconds)
 | 
			
		||||
    Webpush.payload_send(
 | 
			
		||||
      message: Oj.dump(message),
 | 
			
		||||
      endpoint: endpoint,
 | 
			
		||||
      p256dh: key_p256dh,
 | 
			
		||||
      auth: key_auth,
 | 
			
		||||
      ttl: ttl,
 | 
			
		||||
      ssl_timeout: 10,
 | 
			
		||||
      open_timeout: 10,
 | 
			
		||||
      read_timeout: 10,
 | 
			
		||||
      vapid: {
 | 
			
		||||
        subject: "mailto:#{::Setting.site_contact_email}",
 | 
			
		||||
        private_key: Rails.configuration.x.vapid_private_key,
 | 
			
		||||
        public_key: Rails.configuration.x.vapid_public_key,
 | 
			
		||||
      }
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def payload_for_notification(notification)
 | 
			
		||||
    ActiveModelSerializers::SerializableResource.new(
 | 
			
		||||
      notification,
 | 
			
		||||
      serializer: Web::NotificationSerializer,
 | 
			
		||||
      scope: self,
 | 
			
		||||
      scope_name: :current_push_subscription
 | 
			
		||||
    ).as_json
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def find_or_create_access_token
 | 
			
		||||
    Doorkeeper::AccessToken.find_or_create_for(
 | 
			
		||||
      application: Doorkeeper::Application.find_by(superapp: true),
 | 
			
		||||
      resource_owner: session_activation.user_id,
 | 
			
		||||
      resource_owner: user_id || session_activation.user_id,
 | 
			
		||||
      scopes: Doorkeeper::OAuth::Scopes.from_string('read write follow push'),
 | 
			
		||||
      expires_in: Doorkeeper.configuration.access_token_expires_in,
 | 
			
		||||
      use_refresh_token: Doorkeeper.configuration.refresh_token_enabled?
 | 
			
		||||
    )
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def vapid_key
 | 
			
		||||
    @vapid_key ||= Webpush::VapidKey.from_keys(Rails.configuration.x.vapid_public_key, Rails.configuration.x.vapid_private_key)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def contact_email
 | 
			
		||||
    @contact_email ||= ::Setting.site_contact_email
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def alert_enabled_for_notification_type?(notification)
 | 
			
		||||
    truthy?(data&.dig('alerts', notification.type.to_s))
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def policy_allows_notification?(notification)
 | 
			
		||||
    case data&.dig('policy')
 | 
			
		||||
    when nil, 'all'
 | 
			
		||||
      true
 | 
			
		||||
    when 'none'
 | 
			
		||||
      false
 | 
			
		||||
    when 'followed'
 | 
			
		||||
      notification.account.following?(notification.from_account)
 | 
			
		||||
    when 'follower'
 | 
			
		||||
      notification.from_account.following?(notification.account)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def truthy?(val)
 | 
			
		||||
    ActiveModel::Type::Boolean.new.cast(val)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										15
									
								
								app/policies/follow_recommendation_policy.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/policies/follow_recommendation_policy.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,15 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class FollowRecommendationPolicy < ApplicationPolicy
 | 
			
		||||
  def show?
 | 
			
		||||
    staff?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def suppress?
 | 
			
		||||
    staff?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def unsuppress?
 | 
			
		||||
    staff?
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										7
									
								
								app/serializers/rest/suggestion_serializer.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								app/serializers/rest/suggestion_serializer.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,7 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class REST::SuggestionSerializer < ActiveModel::Serializer
 | 
			
		||||
  attributes :source
 | 
			
		||||
 | 
			
		||||
  has_one :account, serializer: REST::AccountSerializer
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -43,7 +43,6 @@ class ProcessMentionsService < BaseService
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    status.save!
 | 
			
		||||
    check_for_spam(status)
 | 
			
		||||
 | 
			
		||||
    mentions.each { |mention| create_notification(mention) }
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -72,8 +71,4 @@ class ProcessMentionsService < BaseService
 | 
			
		|||
  def resolve_account_service
 | 
			
		||||
    ResolveAccountService.new
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def check_for_spam(status)
 | 
			
		||||
    SpamCheck.perform(status)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,10 +27,7 @@ class RemoveStatusService < BaseService
 | 
			
		|||
        # original object being removed implicitly removes reblogs
 | 
			
		||||
        # of it. The Delete activity of the original is forwarded
 | 
			
		||||
        # separately.
 | 
			
		||||
        if @account.local? && !@options[:original_removed]
 | 
			
		||||
          remove_from_remote_followers
 | 
			
		||||
          remove_from_remote_reach
 | 
			
		||||
        end
 | 
			
		||||
        remove_from_remote_reach if @account.local? && !@options[:original_removed]
 | 
			
		||||
 | 
			
		||||
        # Since reblogs don't mention anyone, don't get reblogged,
 | 
			
		||||
        # favourited and don't contain their own media attachments
 | 
			
		||||
| 
						 | 
				
			
			@ -42,7 +39,6 @@ class RemoveStatusService < BaseService
 | 
			
		|||
          remove_from_public
 | 
			
		||||
          remove_from_media if @status.media_attachments.any?
 | 
			
		||||
          remove_from_direct if status.direct_visibility?
 | 
			
		||||
          remove_from_spam_check
 | 
			
		||||
          remove_media
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -85,13 +81,10 @@ class RemoveStatusService < BaseService
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def remove_from_remote_reach
 | 
			
		||||
    return if @status.reblog?
 | 
			
		||||
 | 
			
		||||
    # People who got mentioned in the status, or who
 | 
			
		||||
    # reblogged it from someone else might not follow
 | 
			
		||||
    # the author and wouldn't normally receive the
 | 
			
		||||
    # delete notification - so here, we explicitly
 | 
			
		||||
    # send it to them
 | 
			
		||||
    # Followers, relays, people who got mentioned in the status,
 | 
			
		||||
    # or who reblogged it from someone else might not follow
 | 
			
		||||
    # the author and wouldn't normally receive the delete
 | 
			
		||||
    # notification - so here, we explicitly send it to them
 | 
			
		||||
 | 
			
		||||
    status_reach_finder = StatusReachFinder.new(@status)
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -100,24 +93,6 @@ class RemoveStatusService < BaseService
 | 
			
		|||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remove_from_remote_followers
 | 
			
		||||
    ActivityPub::DeliveryWorker.push_bulk(@account.followers.inboxes) do |inbox_url|
 | 
			
		||||
      [signed_activity_json, @account.id, inbox_url]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    relay! if relayable?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def relayable?
 | 
			
		||||
    @status.public_visibility?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def relay!
 | 
			
		||||
    ActivityPub::DeliveryWorker.push_bulk(Relay.enabled.pluck(:inbox_url)) do |inbox_url|
 | 
			
		||||
      [signed_activity_json, @account.id, inbox_url]
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def signed_activity_json
 | 
			
		||||
    @signed_activity_json ||= Oj.dump(serialize_payload(@status, @status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer, signer: @account))
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -171,10 +146,6 @@ class RemoveStatusService < BaseService
 | 
			
		|||
    @status.media_attachments.destroy_all
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def remove_from_spam_check
 | 
			
		||||
    redis.zremrangebyscore("spam_check:#{@status.account_id}", @status.id, @status.id)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def lock_options
 | 
			
		||||
    { redis: Redis.current, key: "distribute:#{@status.id}" }
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -10,6 +10,8 @@ class ReportService < BaseService
 | 
			
		|||
    @comment        = options.delete(:comment) || ''
 | 
			
		||||
    @options        = options
 | 
			
		||||
 | 
			
		||||
    raise ActiveRecord::RecordNotFound if @target_account.suspended?
 | 
			
		||||
 | 
			
		||||
    create_report!
 | 
			
		||||
    notify_staff!
 | 
			
		||||
    forward_to_origin! if !@target_account.local? && ActiveModel::Type::Boolean.new.cast(@options[:forward])
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -42,7 +42,13 @@ class SuspendAccountService < BaseService
 | 
			
		|||
  end
 | 
			
		||||
 | 
			
		||||
  def distribute_update_actor!
 | 
			
		||||
    ActivityPub::UpdateDistributionWorker.perform_async(@account.id) if @account.local?
 | 
			
		||||
    return unless @account.local?
 | 
			
		||||
 | 
			
		||||
    account_reach_finder = AccountReachFinder.new(@account)
 | 
			
		||||
 | 
			
		||||
    ActivityPub::DeliveryWorker.push_bulk(account_reach_finder.inboxes) do |inbox_url|
 | 
			
		||||
      [signed_activity_json, @account.id, inbox_url]
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def unmerge_from_home_timelines!
 | 
			
		||||
| 
						 | 
				
			
			@ -90,4 +96,8 @@ class SuspendAccountService < BaseService
 | 
			
		|||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def signed_activity_json
 | 
			
		||||
    @signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account))
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -12,6 +12,7 @@ class UnsuspendAccountService < BaseService
 | 
			
		|||
    merge_into_home_timelines!
 | 
			
		||||
    merge_into_list_timelines!
 | 
			
		||||
    publish_media_attachments!
 | 
			
		||||
    distribute_update_actor!
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
| 
						 | 
				
			
			@ -36,6 +37,16 @@ class UnsuspendAccountService < BaseService
 | 
			
		|||
    # @account would now be nil.
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def distribute_update_actor!
 | 
			
		||||
    return unless @account.local?
 | 
			
		||||
 | 
			
		||||
    account_reach_finder = AccountReachFinder.new(@account)
 | 
			
		||||
 | 
			
		||||
    ActivityPub::DeliveryWorker.push_bulk(account_reach_finder.inboxes) do |inbox_url|
 | 
			
		||||
      [signed_activity_json, @account.id, inbox_url]
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def merge_into_home_timelines!
 | 
			
		||||
    @account.followers_for_local_distribution.find_each do |follower|
 | 
			
		||||
      FeedManager.instance.merge_into_home(@account, follower)
 | 
			
		||||
| 
						 | 
				
			
			@ -81,4 +92,8 @@ class UnsuspendAccountService < BaseService
 | 
			
		|||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def signed_activity_json
 | 
			
		||||
    @signed_activity_json ||= Oj.dump(serialize_payload(@account, ActivityPub::UpdateSerializer, signer: @account))
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -6,26 +6,25 @@ class BlacklistedEmailValidator < ActiveModel::Validator
 | 
			
		|||
 | 
			
		||||
    @email = user.email
 | 
			
		||||
 | 
			
		||||
    user.errors.add(:email, :blocked) if blocked_email?
 | 
			
		||||
    user.errors.add(:email, :blocked) if blocked_email_provider?
 | 
			
		||||
    user.errors.add(:email, :taken) if blocked_canonical_email?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def blocked_email?
 | 
			
		||||
    on_blacklist? || not_on_whitelist?
 | 
			
		||||
  def blocked_email_provider?
 | 
			
		||||
    disallowed_through_email_domain_block? || disallowed_through_configuration? || not_allowed_through_configuration?
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def on_blacklist?
 | 
			
		||||
    return true  if EmailDomainBlock.block?(@email)
 | 
			
		||||
    return false if Rails.configuration.x.email_domains_blacklist.blank?
 | 
			
		||||
 | 
			
		||||
    domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
 | 
			
		||||
    regexp  = Regexp.new("@(.+\\.)?(#{domains})", true)
 | 
			
		||||
 | 
			
		||||
    regexp.match?(@email)
 | 
			
		||||
  def blocked_canonical_email?
 | 
			
		||||
    CanonicalEmailBlock.block?(@email)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def not_on_whitelist?
 | 
			
		||||
  def disallowed_through_email_domain_block?
 | 
			
		||||
    EmailDomainBlock.block?(@email)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def not_allowed_through_configuration?
 | 
			
		||||
    return false if Rails.configuration.x.email_domains_whitelist.blank?
 | 
			
		||||
 | 
			
		||||
    domains = Rails.configuration.x.email_domains_whitelist.gsub('.', '\.')
 | 
			
		||||
| 
						 | 
				
			
			@ -33,4 +32,13 @@ class BlacklistedEmailValidator < ActiveModel::Validator
 | 
			
		|||
 | 
			
		||||
    @email !~ regexp
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def disallowed_through_configuration?
 | 
			
		||||
    return false if Rails.configuration.x.email_domains_blacklist.blank?
 | 
			
		||||
 | 
			
		||||
    domains = Rails.configuration.x.email_domains_blacklist.gsub('.', '\.')
 | 
			
		||||
    regexp  = Regexp.new("@(.+\\.)?(#{domains})", true)
 | 
			
		||||
 | 
			
		||||
    regexp.match?(@email)
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -79,8 +79,6 @@
 | 
			
		|||
          = feature_hint(link_to(t('admin.dashboard.trends'), edit_admin_settings_path), @trends_enabled)
 | 
			
		||||
        %li
 | 
			
		||||
          = feature_hint(link_to(t('admin.dashboard.feature_relay'), admin_relays_path), @relay_enabled)
 | 
			
		||||
        %li
 | 
			
		||||
          = feature_hint(link_to(t('admin.dashboard.feature_spam_check'), edit_admin_settings_path), @spam_check_enabled)
 | 
			
		||||
 | 
			
		||||
  .dashboard__widgets__versions
 | 
			
		||||
    %div
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										20
									
								
								app/views/admin/follow_recommendations/_account.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								app/views/admin/follow_recommendations/_account.html.haml
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
.batch-table__row
 | 
			
		||||
  %label.batch-table__row__select.batch-table__row__select--aligned.batch-checkbox
 | 
			
		||||
    = f.check_box :account_ids, { multiple: true, include_hidden: false }, account.id
 | 
			
		||||
  .batch-table__row__content.batch-table__row__content--unpadded
 | 
			
		||||
    %table.accounts-table
 | 
			
		||||
      %tbody
 | 
			
		||||
        %tr
 | 
			
		||||
          %td= account_link_to account
 | 
			
		||||
          %td.accounts-table__count.optional
 | 
			
		||||
            = number_to_human account.statuses_count, strip_insignificant_zeros: true
 | 
			
		||||
            %small= t('accounts.posts', count: account.statuses_count).downcase
 | 
			
		||||
          %td.accounts-table__count.optional
 | 
			
		||||
            = number_to_human account.followers_count, strip_insignificant_zeros: true
 | 
			
		||||
            %small= t('accounts.followers', count: account.followers_count).downcase
 | 
			
		||||
          %td.accounts-table__count
 | 
			
		||||
            - if account.last_status_at.present?
 | 
			
		||||
              %time.time-ago{ datetime: account.last_status_at.to_date.iso8601, title: l(account.last_status_at.to_date) }= l account.last_status_at
 | 
			
		||||
            - else
 | 
			
		||||
              \-
 | 
			
		||||
            %small= t('accounts.last_active')
 | 
			
		||||
							
								
								
									
										41
									
								
								app/views/admin/follow_recommendations/show.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								app/views/admin/follow_recommendations/show.html.haml
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,41 @@
 | 
			
		|||
- content_for :page_title do
 | 
			
		||||
  = t('admin.follow_recommendations.title')
 | 
			
		||||
 | 
			
		||||
- content_for :header_tags do
 | 
			
		||||
  = javascript_pack_tag 'admin', async: true, crossorigin: 'anonymous'
 | 
			
		||||
 | 
			
		||||
%p= t('admin.follow_recommendations.description_html')
 | 
			
		||||
 | 
			
		||||
%hr.spacer/
 | 
			
		||||
 | 
			
		||||
= form_tag admin_follow_recommendations_path, method: 'GET', class: 'simple_form' do
 | 
			
		||||
  .filters
 | 
			
		||||
    .filter-subset.filter-subset--with-select
 | 
			
		||||
      %strong= t('admin.follow_recommendations.language')
 | 
			
		||||
      .input.select.optional
 | 
			
		||||
        = select_tag :language, options_for_select(I18n.available_locales.map { |key| [human_locale(key), key]}, @language)
 | 
			
		||||
 | 
			
		||||
    .filter-subset
 | 
			
		||||
      %strong= t('admin.follow_recommendations.status')
 | 
			
		||||
      %ul
 | 
			
		||||
        %li= filter_link_to t('admin.accounts.moderation.active'), status: nil
 | 
			
		||||
        %li= filter_link_to t('admin.follow_recommendations.suppressed'), status: 'suppressed'
 | 
			
		||||
 | 
			
		||||
= form_for(@form, url: admin_follow_recommendations_path, method: :patch) do |f|
 | 
			
		||||
  - RelationshipFilter::KEYS.each do |key|
 | 
			
		||||
    = hidden_field_tag key, params[key] if params[key].present?
 | 
			
		||||
 | 
			
		||||
  .batch-table
 | 
			
		||||
    .batch-table__toolbar
 | 
			
		||||
      %label.batch-table__toolbar__select.batch-checkbox-all
 | 
			
		||||
        = check_box_tag :batch_checkbox_all, nil, false
 | 
			
		||||
      .batch-table__toolbar__actions
 | 
			
		||||
        - if params[:status].blank? && can?(:suppress, :follow_recommendation)
 | 
			
		||||
          = f.button safe_join([fa_icon('times'), t('admin.follow_recommendations.suppress')]), name: :suppress, class: 'table-action-link', type: :submit, data: { confirm: t('admin.reports.are_you_sure') }
 | 
			
		||||
        - if params[:status] == 'suppressed' && can?(:unsuppress, :follow_recommendation)
 | 
			
		||||
          = f.button safe_join([fa_icon('plus'), t('admin.follow_recommendations.unsuppress')]), name: :unsuppress, class: 'table-action-link', type: :submit
 | 
			
		||||
    .batch-table__body
 | 
			
		||||
      - if @accounts.empty?
 | 
			
		||||
        = nothing_here 'nothing-here--under-tabs'
 | 
			
		||||
      - else
 | 
			
		||||
        = render partial: 'account', collection: @accounts, locals: { f: f }
 | 
			
		||||
| 
						 | 
				
			
			@ -1,8 +1,9 @@
 | 
			
		|||
- content_for :page_title do
 | 
			
		||||
  = t('admin.rules.title')
 | 
			
		||||
 | 
			
		||||
.simple_form
 | 
			
		||||
  %p.hint= t('admin.rules.description')
 | 
			
		||||
%p= t('admin.rules.description_html')
 | 
			
		||||
 | 
			
		||||
%hr.spacer/
 | 
			
		||||
 | 
			
		||||
- if can? :create, :rule
 | 
			
		||||
  = simple_form_for @rule, url: admin_rules_path do |f|
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -101,9 +101,6 @@
 | 
			
		|||
  .fields-group
 | 
			
		||||
    = f.input :show_replies_in_public_timelines, as: :boolean, wrapper: :with_label, label: t('admin.settings.show_replies_in_public_timelines.title'), hint: t('admin.settings.show_replies_in_public_timelines.desc_html')
 | 
			
		||||
 | 
			
		||||
  .fields-group
 | 
			
		||||
    = f.input :spam_check_enabled, as: :boolean, wrapper: :with_label, label: t('admin.settings.spam_check_enabled.title'), hint: t('admin.settings.spam_check_enabled.desc_html')
 | 
			
		||||
 | 
			
		||||
  %hr.spacer/
 | 
			
		||||
 | 
			
		||||
  .fields-group
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
<%= t 'devise.mailer.webauthn_credentia.added.title' %>
 | 
			
		||||
<%= t 'devise.mailer.webauthn_credential.added.title' %>
 | 
			
		||||
 | 
			
		||||
===
 | 
			
		||||
 | 
			
		||||
<%= t 'devise.mailer.webauthn_credentia.added.explanation' %>
 | 
			
		||||
<%= t 'devise.mailer.webauthn_credential.added.explanation' %>
 | 
			
		||||
 | 
			
		||||
=> <%= edit_user_registration_url %>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										61
									
								
								app/workers/scheduler/follow_recommendations_scheduler.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								app/workers/scheduler/follow_recommendations_scheduler.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,61 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
class Scheduler::FollowRecommendationsScheduler
 | 
			
		||||
  include Sidekiq::Worker
 | 
			
		||||
  include Redisable
 | 
			
		||||
 | 
			
		||||
  sidekiq_options retry: 0
 | 
			
		||||
 | 
			
		||||
  # The maximum number of accounts that can be requested in one page from the
 | 
			
		||||
  # API is 80, and the suggestions API does not allow pagination. This number
 | 
			
		||||
  # leaves some room for accounts being filtered during live access
 | 
			
		||||
  SET_SIZE = 100
 | 
			
		||||
 | 
			
		||||
  def perform
 | 
			
		||||
    # Maintaining a materialized view speeds-up subsequent queries significantly
 | 
			
		||||
    AccountSummary.refresh
 | 
			
		||||
 | 
			
		||||
    fallback_recommendations = FollowRecommendation.safe.filtered.limit(SET_SIZE).index_by(&:account_id)
 | 
			
		||||
 | 
			
		||||
    I18n.available_locales.each do |locale|
 | 
			
		||||
      recommendations = begin
 | 
			
		||||
        if AccountSummary.safe.filtered.localized(locale).exists? # We can skip the work if no accounts with that language exist
 | 
			
		||||
          FollowRecommendation.safe.filtered.localized(locale).limit(SET_SIZE).index_by(&:account_id)
 | 
			
		||||
        else
 | 
			
		||||
          {}
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      # Use language-agnostic results if there are not enough language-specific ones
 | 
			
		||||
      missing = SET_SIZE - recommendations.keys.size
 | 
			
		||||
 | 
			
		||||
      if missing.positive?
 | 
			
		||||
        added = 0
 | 
			
		||||
 | 
			
		||||
        # Avoid duplicate results
 | 
			
		||||
        fallback_recommendations.each_value do |recommendation|
 | 
			
		||||
          next if recommendations.key?(recommendation.account_id)
 | 
			
		||||
 | 
			
		||||
          recommendations[recommendation.account_id] = recommendation
 | 
			
		||||
          added += 1
 | 
			
		||||
 | 
			
		||||
          break if added >= missing
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      redis.pipelined do
 | 
			
		||||
        redis.del(key(locale))
 | 
			
		||||
 | 
			
		||||
        recommendations.each_value do |recommendation|
 | 
			
		||||
          redis.zadd(key(locale), recommendation.rank, recommendation.account_id)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def key(locale)
 | 
			
		||||
    "follow_recommendations:#{locale}"
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -3,22 +3,67 @@
 | 
			
		|||
class Web::PushNotificationWorker
 | 
			
		||||
  include Sidekiq::Worker
 | 
			
		||||
 | 
			
		||||
  sidekiq_options backtrace: true, retry: 5
 | 
			
		||||
  sidekiq_options queue: 'push', retry: 5
 | 
			
		||||
 | 
			
		||||
  TTL     = 48.hours.to_s
 | 
			
		||||
  URGENCY = 'normal'
 | 
			
		||||
 | 
			
		||||
  def perform(subscription_id, notification_id)
 | 
			
		||||
    subscription = ::Web::PushSubscription.find(subscription_id)
 | 
			
		||||
    notification = Notification.find(notification_id)
 | 
			
		||||
    @subscription = Web::PushSubscription.find(subscription_id)
 | 
			
		||||
    @notification = Notification.find(notification_id)
 | 
			
		||||
 | 
			
		||||
    subscription.push(notification) unless notification.activity.nil?
 | 
			
		||||
  rescue Webpush::ResponseError => e
 | 
			
		||||
    code = e.response.code.to_i
 | 
			
		||||
    # Polymorphically associated activity could have been deleted
 | 
			
		||||
    # in the meantime, so we have to double-check before proceeding
 | 
			
		||||
    return unless @notification.activity.present? && @subscription.pushable?(@notification)
 | 
			
		||||
 | 
			
		||||
    if (400..499).cover?(code) && ![408, 429].include?(code)
 | 
			
		||||
      subscription.destroy!
 | 
			
		||||
    else
 | 
			
		||||
      raise e
 | 
			
		||||
    payload = @subscription.encrypt(push_notification_json)
 | 
			
		||||
 | 
			
		||||
    request_pool.with(@subscription.audience) do |http_client|
 | 
			
		||||
      request = Request.new(:post, @subscription.endpoint, body: payload.fetch(:ciphertext), http_client: http_client)
 | 
			
		||||
 | 
			
		||||
      request.add_headers(
 | 
			
		||||
        'Content-Type'     => 'application/octet-stream',
 | 
			
		||||
        'Ttl'              => TTL,
 | 
			
		||||
        'Urgency'          => URGENCY,
 | 
			
		||||
        'Content-Encoding' => 'aesgcm',
 | 
			
		||||
        'Encryption'       => "salt=#{Webpush.encode64(payload.fetch(:salt)).delete('=')}",
 | 
			
		||||
        'Crypto-Key'       => "dh=#{Webpush.encode64(payload.fetch(:server_public_key)).delete('=')};#{@subscription.crypto_key_header}",
 | 
			
		||||
        'Authorization'    => @subscription.authorization_header
 | 
			
		||||
      )
 | 
			
		||||
 | 
			
		||||
      request.perform do |response|
 | 
			
		||||
        # If the server responds with an error in the 4xx range
 | 
			
		||||
        # that isn't about rate-limiting or timeouts, we can
 | 
			
		||||
        # assume that the subscription is invalid or expired
 | 
			
		||||
        # and must be removed
 | 
			
		||||
 | 
			
		||||
        if (400..499).cover?(response.code) && ![408, 429].include?(response.code)
 | 
			
		||||
          @subscription.destroy!
 | 
			
		||||
        elsif !(200...300).cover?(response.code)
 | 
			
		||||
          raise Mastodon::UnexpectedResponseError, response
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  rescue ActiveRecord::RecordNotFound
 | 
			
		||||
    true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  private
 | 
			
		||||
 | 
			
		||||
  def push_notification_json
 | 
			
		||||
    json = I18n.with_locale(@subscription.locale || I18n.default_locale) do
 | 
			
		||||
      ActiveModelSerializers::SerializableResource.new(
 | 
			
		||||
        @notification,
 | 
			
		||||
        serializer: Web::NotificationSerializer,
 | 
			
		||||
        scope: @subscription,
 | 
			
		||||
        scope_name: :current_push_subscription
 | 
			
		||||
      ).as_json
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    Oj.dump(json)
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def request_pool
 | 
			
		||||
    RequestPool.current
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -29,6 +29,7 @@ require_relative '../lib/webpacker/helper_extensions'
 | 
			
		|||
require_relative '../lib/action_dispatch/cookie_jar_extensions'
 | 
			
		||||
require_relative '../lib/rails/engine_extensions'
 | 
			
		||||
require_relative '../lib/active_record/database_tasks_extensions'
 | 
			
		||||
require_relative '../lib/active_record/batches'
 | 
			
		||||
 | 
			
		||||
Dotenv::Railtie.load
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -90,9 +90,12 @@ Rails.application.configure do
 | 
			
		|||
  config.action_mailer.perform_caching = false
 | 
			
		||||
 | 
			
		||||
  # E-mails
 | 
			
		||||
  outgoing_email_address = ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost')
 | 
			
		||||
  outgoing_mail_domain   = Mail::Address.new(outgoing_email_address).domain
 | 
			
		||||
  config.action_mailer.default_options = {
 | 
			
		||||
    from: ENV.fetch('SMTP_FROM_ADDRESS', 'notifications@localhost'),
 | 
			
		||||
    reply_to: ENV['SMTP_REPLY_TO']
 | 
			
		||||
    from: outgoing_email_address,
 | 
			
		||||
    reply_to: ENV['SMTP_REPLY_TO'],
 | 
			
		||||
    'Message-ID': -> { "<#{Mail.random_tag}@#{outgoing_mail_domain}>" },
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  config.action_mailer.smtp_settings = {
 | 
			
		||||
| 
						 | 
				
			
			@ -116,10 +119,10 @@ Rails.application.configure do
 | 
			
		|||
    'X-Frame-Options'         => 'DENY',
 | 
			
		||||
    'X-Content-Type-Options'  => 'nosniff',
 | 
			
		||||
    'X-XSS-Protection'        => '1; mode=block',
 | 
			
		||||
    'Permissions-Policy'      => 'interest-cohort=()',
 | 
			
		||||
    'Referrer-Policy'         => 'same-origin',
 | 
			
		||||
    'Strict-Transport-Security' => 'max-age=63072000; includeSubDomains; preload',
 | 
			
		||||
    'X-Clacks-Overhead' => 'GNU Natalie Nguyen'
 | 
			
		||||
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  config.x.otp_secret = ENV.fetch('OTP_SECRET')
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -53,11 +53,13 @@ Rails.application.config.content_security_policy_nonce_generator = -> request {
 | 
			
		|||
 | 
			
		||||
Rails.application.config.content_security_policy_nonce_directives = %w(style-src)
 | 
			
		||||
 | 
			
		||||
PgHero::HomeController.content_security_policy do |p|
 | 
			
		||||
  p.script_src :self, :unsafe_inline, assets_host
 | 
			
		||||
  p.style_src  :self, :unsafe_inline, assets_host
 | 
			
		||||
end
 | 
			
		||||
Rails.application.reloader.to_prepare do
 | 
			
		||||
  PgHero::HomeController.content_security_policy do |p|
 | 
			
		||||
    p.script_src :self, :unsafe_inline, assets_host
 | 
			
		||||
    p.style_src  :self, :unsafe_inline, assets_host
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
PgHero::HomeController.after_action do
 | 
			
		||||
  request.content_security_policy_nonce_generator = nil
 | 
			
		||||
  PgHero::HomeController.after_action do
 | 
			
		||||
    request.content_security_policy_nonce_generator = nil
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -52,6 +52,11 @@ Doorkeeper.configure do
 | 
			
		|||
  # Issue access tokens with refresh token (disabled by default)
 | 
			
		||||
  # use_refresh_token
 | 
			
		||||
 | 
			
		||||
  # Forbids creating/updating applications with arbitrary scopes that are
 | 
			
		||||
  # not in configuration, i.e. `default_scopes` or `optional_scopes`.
 | 
			
		||||
  # (Disabled by default)
 | 
			
		||||
  enforce_configured_scopes
 | 
			
		||||
 | 
			
		||||
  # Provide support for an owner to be assigned to each registered application (disabled by default)
 | 
			
		||||
  # Optional parameter :confirmation => true (default false) if you want to enforce ownership of
 | 
			
		||||
  # a registered application
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -112,7 +112,9 @@ else
 | 
			
		|||
  )
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
Paperclip.options[:content_type_mappings] = { csv: Import::FILE_TYPES }
 | 
			
		||||
Rails.application.reloader.to_prepare do
 | 
			
		||||
  Paperclip.options[:content_type_mappings] = { csv: Import::FILE_TYPES }
 | 
			
		||||
end
 | 
			
		||||
 | 
			
		||||
# In some places in the code, we rescue this exception, but we don't always
 | 
			
		||||
# load the S3 library, so it may be an undefined constant:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,3 +1,5 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
ActionController::Base.log_warning_on_csrf_failure = false
 | 
			
		||||
Rails.application.reloader.to_prepare do
 | 
			
		||||
  ActionController::Base.log_warning_on_csrf_failure = false
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -315,10 +315,12 @@ en:
 | 
			
		|||
      new:
 | 
			
		||||
        create: Create announcement
 | 
			
		||||
        title: New announcement
 | 
			
		||||
      publish: Publish
 | 
			
		||||
      published_msg: Announcement successfully published!
 | 
			
		||||
      scheduled_for: Scheduled for %{time}
 | 
			
		||||
      scheduled_msg: Announcement scheduled for publication!
 | 
			
		||||
      title: Announcements
 | 
			
		||||
      unpublish: Unpublish
 | 
			
		||||
      unpublished_msg: Announcement successfully unpublished!
 | 
			
		||||
      updated_msg: Announcement successfully updated!
 | 
			
		||||
    custom_emojis:
 | 
			
		||||
| 
						 | 
				
			
			@ -363,7 +365,6 @@ en:
 | 
			
		|||
      feature_profile_directory: Profile directory
 | 
			
		||||
      feature_registrations: Registrations
 | 
			
		||||
      feature_relay: Federation relay
 | 
			
		||||
      feature_spam_check: Anti-spam
 | 
			
		||||
      feature_timeline_preview: Timeline preview
 | 
			
		||||
      features: Features
 | 
			
		||||
      hidden_service: Federation with hidden services
 | 
			
		||||
| 
						 | 
				
			
			@ -441,6 +442,14 @@ en:
 | 
			
		|||
        create: Add domain
 | 
			
		||||
        title: Block new e-mail domain
 | 
			
		||||
      title: Blocked e-mail domains
 | 
			
		||||
    follow_recommendations:
 | 
			
		||||
      description_html: "<strong>Follow recommendations help new users quickly find interesting content</strong>. When a user has not interacted with others enough to form personalized follow recommendations, these accounts are recommended instead. They are re-calculated on a daily basis from a mix of accounts with the highest recent engagements and highest local follower counts for a given language."
 | 
			
		||||
      language: For language
 | 
			
		||||
      status: Status
 | 
			
		||||
      suppress: Suppress follow recommendation
 | 
			
		||||
      suppressed: Suppressed
 | 
			
		||||
      title: Follow recommendations
 | 
			
		||||
      unsuppress: Restore follow recommendation
 | 
			
		||||
    instances:
 | 
			
		||||
      by_domain: Domain
 | 
			
		||||
      delivery_available: Delivery is available
 | 
			
		||||
| 
						 | 
				
			
			@ -545,8 +554,10 @@ en:
 | 
			
		|||
      updated_at: Updated
 | 
			
		||||
    rules:
 | 
			
		||||
      add_new: Add rule
 | 
			
		||||
      description: While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. Make it easier to see your server's rules at a glance by providing them in a flat bullet point list. Try to keep individual rules short and simple, but try not to split them up into many separate items either.
 | 
			
		||||
      delete: Delete
 | 
			
		||||
      description_html: While most claim to have read and agree to the terms of service, usually people do not read through until after a problem arises. <strong>Make it easier to see your server's rules at a glance by providing them in a flat bullet point list.</strong> Try to keep individual rules short and simple, but try not to split them up into many separate items either.
 | 
			
		||||
      edit: Edit rule
 | 
			
		||||
      empty: No server rules have been defined yet.
 | 
			
		||||
      title: Server rules
 | 
			
		||||
    settings:
 | 
			
		||||
      activity_api_enabled:
 | 
			
		||||
| 
						 | 
				
			
			@ -627,9 +638,6 @@ en:
 | 
			
		|||
        desc_html: You can write your own privacy policy, terms of service or other legalese. You can use HTML tags
 | 
			
		||||
        title: Custom terms of service
 | 
			
		||||
      site_title: Server name
 | 
			
		||||
      spam_check_enabled:
 | 
			
		||||
        desc_html: Mastodon can auto-report accounts that send repeated unsolicited messages. There may be false positives.
 | 
			
		||||
        title: Anti-spam automation
 | 
			
		||||
      thumbnail:
 | 
			
		||||
        desc_html: Used for previews via OpenGraph and API. 1200x630px recommended
 | 
			
		||||
        title: Server thumbnail
 | 
			
		||||
| 
						 | 
				
			
			@ -691,6 +699,7 @@ en:
 | 
			
		|||
      add_new: Add new
 | 
			
		||||
      delete: Delete
 | 
			
		||||
      edit_preset: Edit warning preset
 | 
			
		||||
      empty: You haven't defined any warning presets yet.
 | 
			
		||||
      title: Manage warning presets
 | 
			
		||||
  admin_mailer:
 | 
			
		||||
    new_pending_account:
 | 
			
		||||
| 
						 | 
				
			
			@ -1209,8 +1218,6 @@ en:
 | 
			
		|||
    relationships: Follows and followers
 | 
			
		||||
    two_factor_authentication: Two-factor Auth
 | 
			
		||||
    webauthn_authentication: Security keys
 | 
			
		||||
  spam_check:
 | 
			
		||||
    spam_detected: This is an automated report. Spam has been detected.
 | 
			
		||||
  statuses:
 | 
			
		||||
    attached:
 | 
			
		||||
      audio:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,19 +30,19 @@ en:
 | 
			
		|||
      defaults:
 | 
			
		||||
        autofollow: People who sign up through the invite will automatically follow you
 | 
			
		||||
        avatar: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
 | 
			
		||||
        bot: This account mainly performs automated actions and might not be monitored
 | 
			
		||||
        bot: Signal to others that the account mainly performs automated actions and might not be monitored
 | 
			
		||||
        context: One or multiple contexts where the filter should apply
 | 
			
		||||
        current_password: For security purposes please enter the password of the current account
 | 
			
		||||
        current_username: To confirm, please enter the username of the current account
 | 
			
		||||
        digest: Only sent after a long period of inactivity and only if you have received any personal messages in your absence
 | 
			
		||||
        discoverable: The profile directory is another way by which your account can reach a wider audience
 | 
			
		||||
        discoverable: Allow your account to be discovered by strangers through recommendations and other features
 | 
			
		||||
        email: You will be sent a confirmation e-mail
 | 
			
		||||
        fields: You can have up to 4 items displayed as a table on your profile
 | 
			
		||||
        header: PNG, GIF or JPG. At most %{size}. Will be downscaled to %{dimensions}px
 | 
			
		||||
        inbox_url: Copy the URL from the frontpage of the relay you want to use
 | 
			
		||||
        irreversible: Filtered toots will disappear irreversibly, even if filter is later removed
 | 
			
		||||
        locale: The language of the user interface, e-mails and push notifications
 | 
			
		||||
        locked: Requires you to manually approve followers
 | 
			
		||||
        locked: Manually control who can follow you by approving follow requests
 | 
			
		||||
        password: Use at least 8 characters
 | 
			
		||||
        phrase: Will be matched regardless of casing in text or content warning of a toot
 | 
			
		||||
        scopes: Which APIs the application will be allowed to access. If you select a top-level scope, you don't need to select individual ones.
 | 
			
		||||
| 
						 | 
				
			
			@ -51,7 +51,7 @@ en:
 | 
			
		|||
        setting_display_media_default: Hide media marked as sensitive
 | 
			
		||||
        setting_display_media_hide_all: Always hide media
 | 
			
		||||
        setting_display_media_show_all: Always show media
 | 
			
		||||
        setting_hide_network: Who you follow and who follows you will not be shown on your profile
 | 
			
		||||
        setting_hide_network: Who you follow and who follows you will be hidden on your profile
 | 
			
		||||
        setting_noindex: Affects your public profile and status pages
 | 
			
		||||
        setting_show_application: The application you use to toot will be displayed in the detailed view of your toots
 | 
			
		||||
        setting_use_blurhash: Gradients are based on the colors of the hidden visuals but obfuscate any details
 | 
			
		||||
| 
						 | 
				
			
			@ -128,7 +128,7 @@ en:
 | 
			
		|||
        context: Filter contexts
 | 
			
		||||
        current_password: Current password
 | 
			
		||||
        data: Data
 | 
			
		||||
        discoverable: List this account on the directory
 | 
			
		||||
        discoverable: Suggest account to others
 | 
			
		||||
        display_name: Display name
 | 
			
		||||
        email: E-mail address
 | 
			
		||||
        expires_in: Expire after
 | 
			
		||||
| 
						 | 
				
			
			@ -138,7 +138,7 @@ en:
 | 
			
		|||
        inbox_url: URL of the relay inbox
 | 
			
		||||
        irreversible: Drop instead of hide
 | 
			
		||||
        locale: Interface language
 | 
			
		||||
        locked: Lock account
 | 
			
		||||
        locked: Require follow requests
 | 
			
		||||
        max_uses: Max number of uses
 | 
			
		||||
        new_password: New password
 | 
			
		||||
        note: Bio
 | 
			
		||||
| 
						 | 
				
			
			@ -160,7 +160,7 @@ en:
 | 
			
		|||
        setting_display_media_hide_all: Hide all
 | 
			
		||||
        setting_display_media_show_all: Show all
 | 
			
		||||
        setting_expand_spoilers: Always expand toots marked with content warnings
 | 
			
		||||
        setting_hide_network: Hide your network
 | 
			
		||||
        setting_hide_network: Hide your social graph
 | 
			
		||||
        setting_noindex: Opt-out of search engine indexing
 | 
			
		||||
        setting_reduce_motion: Reduce motion in animations
 | 
			
		||||
        setting_show_application: Disclose application used to send toots
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -45,6 +45,7 @@ SimpleNavigation::Configuration.run do |navigation|
 | 
			
		|||
      s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_url, highlights_on: %r{/admin/accounts|/admin/pending_accounts}
 | 
			
		||||
      s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path
 | 
			
		||||
      s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}
 | 
			
		||||
      s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}
 | 
			
		||||
      s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_url(limited: whitelist_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.admin? }
 | 
			
		||||
      s.item :email_domain_blocks, safe_join([fa_icon('envelope fw'), t('admin.email_domain_blocks.title')]), admin_email_domain_blocks_url, highlights_on: %r{/admin/email_domain_blocks}, if: -> { current_user.admin? }
 | 
			
		||||
      s.item :ip_blocks, safe_join([fa_icon('ban fw'), t('admin.ip_blocks.title')]), admin_ip_blocks_url, highlights_on: %r{/admin/ip_blocks}, if: -> { current_user.admin? }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -3,8 +3,6 @@
 | 
			
		|||
require 'sidekiq_unique_jobs/web'
 | 
			
		||||
require 'sidekiq-scheduler/web'
 | 
			
		||||
 | 
			
		||||
Sidekiq::Web.set :session_secret, Rails.application.secrets[:secret_key_base]
 | 
			
		||||
 | 
			
		||||
Rails.application.routes.draw do
 | 
			
		||||
  root 'home#index'
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -296,6 +294,7 @@ Rails.application.routes.draw do
 | 
			
		|||
    end
 | 
			
		||||
 | 
			
		||||
    resources :account_moderation_notes, only: [:create, :destroy]
 | 
			
		||||
    resource :follow_recommendations, only: [:show, :update]
 | 
			
		||||
 | 
			
		||||
    resources :tags, only: [:index, :show, :update] do
 | 
			
		||||
      collection do
 | 
			
		||||
| 
						 | 
				
			
			@ -513,6 +512,7 @@ Rails.application.routes.draw do
 | 
			
		|||
    namespace :v2 do
 | 
			
		||||
      resources :media, only: [:create]
 | 
			
		||||
      get '/search', to: 'search#index', as: :search
 | 
			
		||||
      resources :suggestions, only: [:index]
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    namespace :web do
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -75,7 +75,6 @@ defaults: &defaults
 | 
			
		|||
  show_reblogs_in_public_timelines: false
 | 
			
		||||
  show_replies_in_public_timelines: false
 | 
			
		||||
  default_content_type: 'text/plain'
 | 
			
		||||
  spam_check_enabled: true
 | 
			
		||||
  show_domain_blocks: 'disabled'
 | 
			
		||||
  show_domain_blocks_rationale: 'disabled'
 | 
			
		||||
  outgoing_spoilers: ''
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -25,6 +25,10 @@
 | 
			
		|||
    cron: '<%= Random.rand(0..59) %> <%= Random.rand(0..2) %> * * *'
 | 
			
		||||
    class: Scheduler::FeedCleanupScheduler
 | 
			
		||||
    queue: scheduler
 | 
			
		||||
  follow_recommendations_scheduler:
 | 
			
		||||
    cron: '<%= Random.rand(0..59) %> <%= Random.rand(6..9) %> * * *'
 | 
			
		||||
    class: Scheduler::FollowRecommendationsScheduler
 | 
			
		||||
    queue: scheduler
 | 
			
		||||
  doorkeeper_cleanup_scheduler:
 | 
			
		||||
    cron: '<%= Random.rand(0..59) %> <%= Random.rand(0..2) %> * * 0'
 | 
			
		||||
    class: Scheduler::DoorkeeperCleanupScheduler
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										17
									
								
								db/migrate/20210306164523_account_ids_to_timestamp_ids.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								db/migrate/20210306164523_account_ids_to_timestamp_ids.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,17 @@
 | 
			
		|||
class AccountIdsToTimestampIds < ActiveRecord::Migration[5.1]
 | 
			
		||||
  def up
 | 
			
		||||
    # Set up the accounts.id column to use our timestamp-based IDs.
 | 
			
		||||
    safety_assured do
 | 
			
		||||
      execute("ALTER TABLE accounts ALTER COLUMN id SET DEFAULT timestamp_id('accounts')")
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    # Make sure we have a sequence to use.
 | 
			
		||||
    Mastodon::Snowflake.ensure_id_sequences_exist
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  def down
 | 
			
		||||
    execute("LOCK accounts")
 | 
			
		||||
    execute("SELECT setval('accounts_id_seq', (SELECT MAX(id) FROM accounts))")
 | 
			
		||||
    execute("ALTER TABLE accounts ALTER COLUMN id SET DEFAULT nextval('accounts_id_seq')")
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										9
									
								
								db/migrate/20210322164601_create_account_summaries.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								db/migrate/20210322164601_create_account_summaries.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
class CreateAccountSummaries < ActiveRecord::Migration[5.2]
 | 
			
		||||
  def change
 | 
			
		||||
    create_view :account_summaries, materialized: true
 | 
			
		||||
 | 
			
		||||
    # To be able to refresh the view concurrently,
 | 
			
		||||
    # at least one unique index is required
 | 
			
		||||
    safety_assured { add_index :account_summaries, :account_id, unique: true }
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,5 @@
 | 
			
		|||
class CreateFollowRecommendations < ActiveRecord::Migration[5.2]
 | 
			
		||||
  def change
 | 
			
		||||
    create_view :follow_recommendations
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,9 @@
 | 
			
		|||
class CreateFollowRecommendationSuppressions < ActiveRecord::Migration[6.1]
 | 
			
		||||
  def change
 | 
			
		||||
    create_table :follow_recommendation_suppressions do |t|
 | 
			
		||||
      t.references :account, null: false, foreign_key: { on_delete: :cascade }, index: { unique: true }
 | 
			
		||||
 | 
			
		||||
      t.timestamps
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										10
									
								
								db/migrate/20210416200740_create_canonical_email_blocks.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								db/migrate/20210416200740_create_canonical_email_blocks.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
class CreateCanonicalEmailBlocks < ActiveRecord::Migration[6.1]
 | 
			
		||||
  def change
 | 
			
		||||
    create_table :canonical_email_blocks do |t|
 | 
			
		||||
      t.string :canonical_email_hash, null: false, default: '', index: { unique: true }
 | 
			
		||||
      t.belongs_to :reference_account, null: false, foreign_key: { on_cascade: :delete, to_table: 'accounts' }
 | 
			
		||||
 | 
			
		||||
      t.timestamps
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										75
									
								
								db/schema.rb
									
									
									
									
									
								
							
							
						
						
									
										75
									
								
								db/schema.rb
									
									
									
									
									
								
							| 
						 | 
				
			
			@ -2,15 +2,15 @@
 | 
			
		|||
# of editing this file, please use the migrations feature of Active Record to
 | 
			
		||||
# incrementally modify your database, and then regenerate this schema definition.
 | 
			
		||||
#
 | 
			
		||||
# Note that this schema.rb definition is the authoritative source for your
 | 
			
		||||
# database schema. If you need to create the application database on another
 | 
			
		||||
# system, you should be using db:schema:load, not running all the migrations
 | 
			
		||||
# from scratch. The latter is a flawed and unsustainable approach (the more migrations
 | 
			
		||||
# you'll amass, the slower it'll run and the greater likelihood for issues).
 | 
			
		||||
# This file is the source Rails uses to define your schema when running `bin/rails
 | 
			
		||||
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
 | 
			
		||||
# be faster and is potentially less error prone than running all of your
 | 
			
		||||
# migrations from scratch. Old migrations may fail to apply correctly if those
 | 
			
		||||
# migrations use external dependencies or application code.
 | 
			
		||||
#
 | 
			
		||||
# It's strongly recommended that you check this file into your version control system.
 | 
			
		||||
 | 
			
		||||
ActiveRecord::Schema.define(version: 2021_03_08_133107) do
 | 
			
		||||
ActiveRecord::Schema.define(version: 2021_04_16_200740) do
 | 
			
		||||
 | 
			
		||||
  # These are extensions that must be enabled in order to support this database
 | 
			
		||||
  enable_extension "plpgsql"
 | 
			
		||||
| 
						 | 
				
			
			@ -142,7 +142,7 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
 | 
			
		|||
    t.index ["target_account_id"], name: "index_account_warnings_on_target_account_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "accounts", force: :cascade do |t|
 | 
			
		||||
  create_table "accounts", id: :bigint, default: -> { "timestamp_id('accounts'::text)" }, force: :cascade do |t|
 | 
			
		||||
    t.string "username", default: "", null: false
 | 
			
		||||
    t.string "domain"
 | 
			
		||||
    t.string "secret", default: "", null: false
 | 
			
		||||
| 
						 | 
				
			
			@ -280,6 +280,15 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
 | 
			
		|||
    t.index ["status_id"], name: "index_bookmarks_on_status_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "canonical_email_blocks", force: :cascade do |t|
 | 
			
		||||
    t.string "canonical_email_hash", default: "", null: false
 | 
			
		||||
    t.bigint "reference_account_id", null: false
 | 
			
		||||
    t.datetime "created_at", precision: 6, null: false
 | 
			
		||||
    t.datetime "updated_at", precision: 6, null: false
 | 
			
		||||
    t.index ["canonical_email_hash"], name: "index_canonical_email_blocks_on_canonical_email_hash", unique: true
 | 
			
		||||
    t.index ["reference_account_id"], name: "index_canonical_email_blocks_on_reference_account_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "conversation_mutes", force: :cascade do |t|
 | 
			
		||||
    t.bigint "conversation_id", null: false
 | 
			
		||||
    t.bigint "account_id", null: false
 | 
			
		||||
| 
						 | 
				
			
			@ -406,6 +415,13 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
 | 
			
		|||
    t.index ["tag_id"], name: "index_featured_tags_on_tag_id"
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "follow_recommendation_suppressions", force: :cascade do |t|
 | 
			
		||||
    t.bigint "account_id", null: false
 | 
			
		||||
    t.datetime "created_at", precision: 6, null: false
 | 
			
		||||
    t.datetime "updated_at", precision: 6, null: false
 | 
			
		||||
    t.index ["account_id"], name: "index_follow_recommendation_suppressions_on_account_id", unique: true
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  create_table "follow_requests", force: :cascade do |t|
 | 
			
		||||
    t.datetime "created_at", null: false
 | 
			
		||||
    t.datetime "updated_at", null: false
 | 
			
		||||
| 
						 | 
				
			
			@ -986,6 +1002,7 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
 | 
			
		|||
  add_foreign_key "blocks", "accounts", name: "fk_4269e03e65", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "bookmarks", "accounts", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "bookmarks", "statuses", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "canonical_email_blocks", "accounts", column: "reference_account_id"
 | 
			
		||||
  add_foreign_key "conversation_mutes", "accounts", name: "fk_225b4212bb", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "conversation_mutes", "conversations", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "custom_filters", "accounts", on_delete: :cascade
 | 
			
		||||
| 
						 | 
				
			
			@ -998,6 +1015,7 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
 | 
			
		|||
  add_foreign_key "favourites", "statuses", name: "fk_b0e856845e", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "featured_tags", "accounts", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "featured_tags", "tags", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "follow_recommendation_suppressions", "accounts", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "follow_requests", "accounts", column: "target_account_id", name: "fk_9291ec025d", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "follow_requests", "accounts", name: "fk_76d644b0e7", on_delete: :cascade
 | 
			
		||||
  add_foreign_key "follows", "accounts", column: "target_account_id", name: "fk_745ca29eac", on_delete: :cascade
 | 
			
		||||
| 
						 | 
				
			
			@ -1081,4 +1099,47 @@ ActiveRecord::Schema.define(version: 2021_03_08_133107) do
 | 
			
		|||
  SQL
 | 
			
		||||
  add_index "instances", ["domain"], name: "index_instances_on_domain", unique: true
 | 
			
		||||
 | 
			
		||||
  create_view "account_summaries", materialized: true, sql_definition: <<-SQL
 | 
			
		||||
      SELECT accounts.id AS account_id,
 | 
			
		||||
      mode() WITHIN GROUP (ORDER BY t0.language) AS language,
 | 
			
		||||
      mode() WITHIN GROUP (ORDER BY t0.sensitive) AS sensitive
 | 
			
		||||
     FROM (accounts
 | 
			
		||||
       CROSS JOIN LATERAL ( SELECT statuses.account_id,
 | 
			
		||||
              statuses.language,
 | 
			
		||||
              statuses.sensitive
 | 
			
		||||
             FROM statuses
 | 
			
		||||
            WHERE ((statuses.account_id = accounts.id) AND (statuses.deleted_at IS NULL))
 | 
			
		||||
            ORDER BY statuses.id DESC
 | 
			
		||||
           LIMIT 20) t0)
 | 
			
		||||
    WHERE ((accounts.suspended_at IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.discoverable = true) AND (accounts.locked = false))
 | 
			
		||||
    GROUP BY accounts.id;
 | 
			
		||||
  SQL
 | 
			
		||||
  add_index "account_summaries", ["account_id"], name: "index_account_summaries_on_account_id", unique: true
 | 
			
		||||
 | 
			
		||||
  create_view "follow_recommendations", sql_definition: <<-SQL
 | 
			
		||||
      SELECT t0.account_id,
 | 
			
		||||
      sum(t0.rank) AS rank,
 | 
			
		||||
      array_agg(t0.reason) AS reason
 | 
			
		||||
     FROM ( SELECT accounts.id AS account_id,
 | 
			
		||||
              ((count(follows.id))::numeric / (1.0 + (count(follows.id))::numeric)) AS rank,
 | 
			
		||||
              'most_followed'::text AS reason
 | 
			
		||||
             FROM ((follows
 | 
			
		||||
               JOIN accounts ON ((accounts.id = follows.target_account_id)))
 | 
			
		||||
               JOIN users ON ((users.account_id = follows.account_id)))
 | 
			
		||||
            WHERE ((users.current_sign_in_at >= (now() - 'P30D'::interval)) AND (accounts.suspended_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.locked = false) AND (accounts.discoverable = true))
 | 
			
		||||
            GROUP BY accounts.id
 | 
			
		||||
           HAVING (count(follows.id) >= 5)
 | 
			
		||||
          UNION ALL
 | 
			
		||||
           SELECT accounts.id AS account_id,
 | 
			
		||||
              (sum((status_stats.reblogs_count + status_stats.favourites_count)) / (1.0 + sum((status_stats.reblogs_count + status_stats.favourites_count)))) AS rank,
 | 
			
		||||
              'most_interactions'::text AS reason
 | 
			
		||||
             FROM ((status_stats
 | 
			
		||||
               JOIN statuses ON ((statuses.id = status_stats.status_id)))
 | 
			
		||||
               JOIN accounts ON ((accounts.id = statuses.account_id)))
 | 
			
		||||
            WHERE ((statuses.id >= (((date_part('epoch'::text, (now() - 'P30D'::interval)) * (1000)::double precision))::bigint << 16)) AND (accounts.suspended_at IS NULL) AND (accounts.moved_to_account_id IS NULL) AND (accounts.silenced_at IS NULL) AND (accounts.locked = false) AND (accounts.discoverable = true))
 | 
			
		||||
            GROUP BY accounts.id
 | 
			
		||||
           HAVING (sum((status_stats.reblogs_count + status_stats.favourites_count)) >= (5)::numeric)) t0
 | 
			
		||||
    GROUP BY t0.account_id
 | 
			
		||||
    ORDER BY (sum(t0.rank)) DESC;
 | 
			
		||||
  SQL
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										22
									
								
								db/views/account_summaries_v01.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								db/views/account_summaries_v01.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,22 @@
 | 
			
		|||
SELECT
 | 
			
		||||
  accounts.id AS account_id,
 | 
			
		||||
  mode() WITHIN GROUP (ORDER BY language ASC) AS language,
 | 
			
		||||
  mode() WITHIN GROUP (ORDER BY sensitive ASC) AS sensitive
 | 
			
		||||
FROM accounts
 | 
			
		||||
CROSS JOIN LATERAL (
 | 
			
		||||
  SELECT
 | 
			
		||||
    statuses.account_id,
 | 
			
		||||
    statuses.language,
 | 
			
		||||
    statuses.sensitive
 | 
			
		||||
  FROM statuses
 | 
			
		||||
  WHERE statuses.account_id = accounts.id
 | 
			
		||||
    AND statuses.deleted_at IS NULL
 | 
			
		||||
  ORDER BY statuses.id DESC
 | 
			
		||||
  LIMIT 20
 | 
			
		||||
) t0
 | 
			
		||||
WHERE accounts.suspended_at IS NULL
 | 
			
		||||
  AND accounts.silenced_at IS NULL
 | 
			
		||||
  AND accounts.moved_to_account_id IS NULL
 | 
			
		||||
  AND accounts.discoverable = 't'
 | 
			
		||||
  AND accounts.locked = 'f'
 | 
			
		||||
GROUP BY accounts.id
 | 
			
		||||
							
								
								
									
										38
									
								
								db/views/follow_recommendations_v01.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								db/views/follow_recommendations_v01.sql
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,38 @@
 | 
			
		|||
SELECT
 | 
			
		||||
  account_id,
 | 
			
		||||
  sum(rank) AS rank,
 | 
			
		||||
  array_agg(reason) AS reason
 | 
			
		||||
FROM (
 | 
			
		||||
  SELECT
 | 
			
		||||
    accounts.id AS account_id,
 | 
			
		||||
    count(follows.id) / (1.0 + count(follows.id)) AS rank,
 | 
			
		||||
    'most_followed' AS reason
 | 
			
		||||
  FROM follows
 | 
			
		||||
  INNER JOIN accounts ON accounts.id = follows.target_account_id
 | 
			
		||||
  INNER JOIN users ON users.account_id = follows.account_id
 | 
			
		||||
  WHERE users.current_sign_in_at >= (now() - interval '30 days')
 | 
			
		||||
    AND accounts.suspended_at IS NULL
 | 
			
		||||
    AND accounts.moved_to_account_id IS NULL
 | 
			
		||||
    AND accounts.silenced_at IS NULL
 | 
			
		||||
    AND accounts.locked = 'f'
 | 
			
		||||
    AND accounts.discoverable = 't'
 | 
			
		||||
  GROUP BY accounts.id
 | 
			
		||||
  HAVING count(follows.id) >= 5
 | 
			
		||||
  UNION ALL
 | 
			
		||||
  SELECT accounts.id AS account_id,
 | 
			
		||||
         sum(reblogs_count + favourites_count) / (1.0 + sum(reblogs_count + favourites_count)) AS rank,
 | 
			
		||||
         'most_interactions' AS reason
 | 
			
		||||
  FROM status_stats
 | 
			
		||||
  INNER JOIN statuses ON statuses.id = status_stats.status_id
 | 
			
		||||
  INNER JOIN accounts ON accounts.id = statuses.account_id
 | 
			
		||||
  WHERE statuses.id >= ((date_part('epoch', now() - interval '30 days') * 1000)::bigint << 16)
 | 
			
		||||
    AND accounts.suspended_at IS NULL
 | 
			
		||||
    AND accounts.moved_to_account_id IS NULL
 | 
			
		||||
    AND accounts.silenced_at IS NULL
 | 
			
		||||
    AND accounts.locked = 'f'
 | 
			
		||||
    AND accounts.discoverable = 't'
 | 
			
		||||
  GROUP BY accounts.id
 | 
			
		||||
  HAVING sum(reblogs_count + favourites_count) >= 5
 | 
			
		||||
) t0
 | 
			
		||||
GROUP BY account_id
 | 
			
		||||
ORDER BY rank DESC
 | 
			
		||||
							
								
								
									
										44
									
								
								lib/active_record/batches.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								lib/active_record/batches.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,44 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
module ActiveRecord
 | 
			
		||||
  module Batches
 | 
			
		||||
    def pluck_each(*column_names)
 | 
			
		||||
      relation = self
 | 
			
		||||
 | 
			
		||||
      options = column_names.extract_options!
 | 
			
		||||
 | 
			
		||||
      flatten     = column_names.size == 1
 | 
			
		||||
      batch_limit = options[:batch_limit] || 1_000
 | 
			
		||||
      order       = options[:order] || :asc
 | 
			
		||||
 | 
			
		||||
      column_names.unshift(primary_key)
 | 
			
		||||
 | 
			
		||||
      relation = relation.reorder(batch_order(order)).limit(batch_limit)
 | 
			
		||||
      relation.skip_query_cache!
 | 
			
		||||
 | 
			
		||||
      batch_relation = relation
 | 
			
		||||
 | 
			
		||||
      loop do
 | 
			
		||||
        batch = batch_relation.pluck(*column_names)
 | 
			
		||||
 | 
			
		||||
        break if batch.empty?
 | 
			
		||||
 | 
			
		||||
        primary_key_offset = batch.last[0]
 | 
			
		||||
 | 
			
		||||
        batch.each do |record|
 | 
			
		||||
          if flatten
 | 
			
		||||
            yield record[1]
 | 
			
		||||
          else
 | 
			
		||||
            yield record[1..-1]
 | 
			
		||||
          end
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        break if batch.size < batch_limit
 | 
			
		||||
 | 
			
		||||
        batch_relation = relation.where(
 | 
			
		||||
          predicate_builder[primary_key, primary_key_offset, order == :desc ? :lt : :gt]
 | 
			
		||||
        )
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -91,7 +91,7 @@ namespace :emojis do
 | 
			
		|||
  desc 'Generate emoji variants with white borders'
 | 
			
		||||
  task :generate_borders do
 | 
			
		||||
    src = Rails.root.join('app', 'javascript', 'mastodon', 'features', 'emoji', 'emoji_map.json')
 | 
			
		||||
    emojis = '🎱🐜⚫🖤⬛◼️◾◼️✒️▪️💣🎳📷📸♣️🕶️✴️🔌💂♀️📽️🍳🦍💂🔪🕳️🕹️🕋🖊️🖋️💂♂️🎤🎓🎥🎼♠️🎩🦃📼📹🎮🐃🏴🐞🕺📱📲👽⚾🐔☁️💨🕊️👀🍥👻🐐❕❔⛸️🌩️🔊🔇📃🌧️🐏🍚🍙🐓🐑💀☠️🌨️🔉🔈💬💭🏐🏳️⚪⬜◽◻️▫️'
 | 
			
		||||
    emojis = '🎱🐜⚫🖤⬛◼️◾◼️✒️▪️💣🎳📷📸♣️🕶️✴️🔌💂♀️📽️🍳🦍💂🔪🕳️🕹️🕋🖊️🖋️💂♂️🎤🎓🎥🎼♠️🎩🦃📼📹🎮🐃🏴🐞🕺📱📲🚲👽⚾🐔☁️💨🕊️👀🍥👻🐐❕❔⛸️🌩️🔊🔇📃🌧️🐏🍚🍙🐓🐑💀☠️🌨️🔉🔈💬💭🏐🏳️⚪⬜◽◻️▫️'
 | 
			
		||||
 | 
			
		||||
    map = Oj.load(File.read(src))
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										34
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								package.json
									
									
									
									
									
								
							| 
						 | 
				
			
			@ -60,12 +60,12 @@
 | 
			
		|||
  },
 | 
			
		||||
  "private": true,
 | 
			
		||||
  "dependencies": {
 | 
			
		||||
    "@babel/core": "^7.13.14",
 | 
			
		||||
    "@babel/core": "^7.13.15",
 | 
			
		||||
    "@babel/plugin-proposal-class-properties": "^7.8.3",
 | 
			
		||||
    "@babel/plugin-proposal-decorators": "^7.13.5",
 | 
			
		||||
    "@babel/plugin-proposal-decorators": "^7.13.15",
 | 
			
		||||
    "@babel/plugin-transform-react-inline-elements": "^7.12.13",
 | 
			
		||||
    "@babel/plugin-transform-runtime": "^7.13.10",
 | 
			
		||||
    "@babel/preset-env": "^7.13.12",
 | 
			
		||||
    "@babel/plugin-transform-runtime": "^7.13.15",
 | 
			
		||||
    "@babel/preset-env": "^7.13.15",
 | 
			
		||||
    "@babel/preset-react": "^7.13.13",
 | 
			
		||||
    "@babel/runtime": "^7.13.10",
 | 
			
		||||
    "@gamestdio/websocket": "^0.3.2",
 | 
			
		||||
| 
						 | 
				
			
			@ -83,12 +83,12 @@
 | 
			
		|||
    "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
 | 
			
		||||
    "babel-runtime": "^6.26.0",
 | 
			
		||||
    "blurhash": "^1.1.3",
 | 
			
		||||
    "classnames": "^2.2.5",
 | 
			
		||||
    "classnames": "^2.3.1",
 | 
			
		||||
    "color-blend": "^3.0.1",
 | 
			
		||||
    "compression-webpack-plugin": "^6.1.1",
 | 
			
		||||
    "cross-env": "^7.0.3",
 | 
			
		||||
    "css-loader": "^5.2.0",
 | 
			
		||||
    "cssnano": "^4.1.10",
 | 
			
		||||
    "css-loader": "^5.2.2",
 | 
			
		||||
    "cssnano": "^4.1.11",
 | 
			
		||||
    "detect-passive-events": "^2.0.3",
 | 
			
		||||
    "dotenv": "^8.2.0",
 | 
			
		||||
    "emoji-mart": "Gargron/emoji-mart#build",
 | 
			
		||||
| 
						 | 
				
			
			@ -109,11 +109,11 @@
 | 
			
		|||
    "intl-messageformat": "^2.2.0",
 | 
			
		||||
    "intl-relativeformat": "^6.4.3",
 | 
			
		||||
    "is-nan": "^1.3.2",
 | 
			
		||||
    "js-yaml": "^4.0.0",
 | 
			
		||||
    "js-yaml": "^4.1.0",
 | 
			
		||||
    "lodash": "^4.17.21",
 | 
			
		||||
    "mark-loader": "^0.1.6",
 | 
			
		||||
    "marky": "^1.2.1",
 | 
			
		||||
    "mini-css-extract-plugin": "^1.4.0",
 | 
			
		||||
    "mini-css-extract-plugin": "^1.5.0",
 | 
			
		||||
    "mkdirp": "^1.0.4",
 | 
			
		||||
    "npmlog": "^4.1.2",
 | 
			
		||||
    "object-assign": "^4.1.1",
 | 
			
		||||
| 
						 | 
				
			
			@ -146,7 +146,7 @@
 | 
			
		|||
    "react-swipeable-views": "^0.13.9",
 | 
			
		||||
    "react-textarea-autosize": "^8.3.2",
 | 
			
		||||
    "react-toggle": "^4.1.2",
 | 
			
		||||
    "redis": "^3.0.2",
 | 
			
		||||
    "redis": "^3.1.1",
 | 
			
		||||
    "redux": "^4.0.5",
 | 
			
		||||
    "redux-immutable": "^4.0.0",
 | 
			
		||||
    "redux-thunk": "^2.2.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -155,7 +155,7 @@
 | 
			
		|||
    "requestidlecallback": "^0.3.0",
 | 
			
		||||
    "reselect": "^4.0.0",
 | 
			
		||||
    "rimraf": "^3.0.2",
 | 
			
		||||
    "sass": "^1.32.8",
 | 
			
		||||
    "sass": "^1.32.10",
 | 
			
		||||
    "sass-loader": "^10.1.1",
 | 
			
		||||
    "stacktrace-js": "^2.0.2",
 | 
			
		||||
    "stringz": "^2.1.0",
 | 
			
		||||
| 
						 | 
				
			
			@ -167,23 +167,23 @@
 | 
			
		|||
    "twitter-text": "3.1.0",
 | 
			
		||||
    "uuid": "^8.3.1",
 | 
			
		||||
    "webpack": "^4.46.0",
 | 
			
		||||
    "webpack-assets-manifest": "^4.0.2",
 | 
			
		||||
    "webpack-bundle-analyzer": "^4.4.0",
 | 
			
		||||
    "webpack-assets-manifest": "^4.0.5",
 | 
			
		||||
    "webpack-bundle-analyzer": "^4.4.1",
 | 
			
		||||
    "webpack-cli": "^3.3.12",
 | 
			
		||||
    "webpack-merge": "^5.7.3",
 | 
			
		||||
    "wicg-inert": "^3.1.1",
 | 
			
		||||
    "ws": "^7.4.4"
 | 
			
		||||
    "ws": "^7.4.5"
 | 
			
		||||
  },
 | 
			
		||||
  "devDependencies": {
 | 
			
		||||
    "@testing-library/jest-dom": "^5.11.10",
 | 
			
		||||
    "@testing-library/react": "^11.2.6",
 | 
			
		||||
    "babel-eslint": "^10.1.0",
 | 
			
		||||
    "babel-jest": "^26.6.3",
 | 
			
		||||
    "eslint": "^7.23.0",
 | 
			
		||||
    "eslint": "^7.24.0",
 | 
			
		||||
    "eslint-plugin-import": "~2.22.1",
 | 
			
		||||
    "eslint-plugin-jsx-a11y": "~6.4.1",
 | 
			
		||||
    "eslint-plugin-promise": "~4.3.1",
 | 
			
		||||
    "eslint-plugin-react": "~7.23.1",
 | 
			
		||||
    "eslint-plugin-promise": "~5.1.0",
 | 
			
		||||
    "eslint-plugin-react": "~7.23.2",
 | 
			
		||||
    "jest": "^26.6.3",
 | 
			
		||||
    "raf": "^3.4.1",
 | 
			
		||||
    "react-intl-translations-manager": "^5.0.3",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										19
									
								
								public/emoji/1f6b2_border.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								public/emoji/1f6b2_border.svg
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
<?xml version="1.0"?>
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 40 40">
 | 
			
		||||
  <g>
 | 
			
		||||
    <path d="M7 24c1.957 0 3.633 1.135 4.455 2.772l3.477-1.739C13.488 22.058 10.446 20 6.916 20c-1.301 0-2.534.285-3.649.787l1.668 3.67C5.566 24.17 6.262 24 7 24zm22 0c1.467 0 2.772.643 3.688 1.648l2.897-2.635C33.952 21.169 31.573 20 28.916 20c-3.576 0-6.652 2.111-8.073 5.15l3.648 1.722C25.293 25.18 27.003 24 29 24z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
 | 
			
		||||
    <path d="M7 22c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.133-7-7-7zm0 12c-2.761 0-5-2.238-5-5s2.239-5 5-5 5 2.238 5 5-2.238 5-5 5zm22-12c-3.865 0-7 3.134-7 7s3.135 7 7 7c3.867 0 7-3.134 7-7s-3.133-7-7-7zm0 12c-2.761 0-5-2.238-5-5s2.239-5 5-5c2.762 0 5 2.238 5 5s-2.238 5-5 5z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
 | 
			
		||||
    <path d="M29.984 28.922c-.005-.067-.021-.132-.04-.198-.019-.065-.04-.126-.071-.186-.013-.024-.015-.052-.029-.075l-7-11c-.297-.466-.914-.604-1.381-.307-.299.19-.444.513-.445.843H12c-.552 0-1 .447-1 1 0 .553.448 1 1 1h10c.027 0 .05-.014.077-.016L27.178 28H18c-.552 0-1 .447-1 1s.448 1 1 1h11.001c.116 0 .23-.028.343-.069.034-.013.064-.027.097-.043.031-.017.066-.024.097-.044.03-.02.048-.051.075-.072.055-.044.103-.089.147-.143.041-.049.074-.099.104-.154.03-.056.055-.11.075-.172.021-.066.033-.132.04-.201.004-.036.021-.066.021-.102 0-.027-.014-.051-.016-.078z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
 | 
			
		||||
    <path d="M21.581 16l-2.899 8.117-5.929-6.775c-.364-.415-.996-.459-1.411-.094-.415.364-.457.995-.094 1.411l6.664 7.615-.854 2.39c-.185.519.086 1.092.606 1.277.111.04.224.059.336.059.411 0 .796-.255.942-.664L23.705 16h-2.124z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
 | 
			
		||||
    <path d="M7 30c-.15 0-.303-.034-.446-.105-.494-.247-.694-.848-.447-1.342l3.062-6.106C9.186 22.419 11 19.651 11 17c0-3.242-2.293-4.043-2.316-4.051-.524-.175-.807-.741-.632-1.265.174-.524.739-.81 1.265-.632C9.467 11.102 13 12.333 13 17c0 3.068-1.836 6.042-2.131 6.497l-2.974 5.949C7.72 29.798 7.367 30 7 30z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
 | 
			
		||||
    <path d="M14.612 13.663c-.054 0-.11-.004-.165-.014l-6-1c-.544-.091-.913-.606-.822-1.151.091-.544.601-.913 1.151-.822l6 1c.544.091.913.606.822 1.151-.082.489-.506.836-.986.836zM26.383 17c-.03 0-.059-.002-.089-.006l-5.672-.708c-.372-.046-.644-.374-.62-.748.023-.374.333-.665.707-.665.041 0 4.067-.018 5.989-1.299.25-.167.582-.157.824.026.239.185.337.501.241.788l-.709 2.127c-.096.293-.369.485-.671.485z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
 | 
			
		||||
    <path d="M20 29c0 1.104-.895 2-2 2-1.104 0-2-.896-2-2s.896-2 2-2c1.105 0 2 .896 2 2z" stroke="white" stroke-linejoin="round" stroke-width="4px"/>
 | 
			
		||||
  </g>
 | 
			
		||||
  <path fill="#EA596E" d="M7 24c1.957 0 3.633 1.135 4.455 2.772l3.477-1.739C13.488 22.058 10.446 20 6.916 20c-1.301 0-2.534.285-3.649.787l1.668 3.67C5.566 24.17 6.262 24 7 24zm22 0c1.467 0 2.772.643 3.688 1.648l2.897-2.635C33.952 21.169 31.573 20 28.916 20c-3.576 0-6.652 2.111-8.073 5.15l3.648 1.722C25.293 25.18 27.003 24 29 24z"/>
 | 
			
		||||
  <path fill="#292F33" d="M7 22c-3.866 0-7 3.134-7 7s3.134 7 7 7 7-3.134 7-7-3.133-7-7-7zm0 12c-2.761 0-5-2.238-5-5s2.239-5 5-5 5 2.238 5 5-2.238 5-5 5zm22-12c-3.865 0-7 3.134-7 7s3.135 7 7 7c3.867 0 7-3.134 7-7s-3.133-7-7-7zm0 12c-2.761 0-5-2.238-5-5s2.239-5 5-5c2.762 0 5 2.238 5 5s-2.238 5-5 5z"/>
 | 
			
		||||
  <path fill="#DD2E44" d="M29.984 28.922c-.005-.067-.021-.132-.04-.198-.019-.065-.04-.126-.071-.186-.013-.024-.015-.052-.029-.075l-7-11c-.297-.466-.914-.604-1.381-.307-.299.19-.444.513-.445.843H12c-.552 0-1 .447-1 1 0 .553.448 1 1 1h10c.027 0 .05-.014.077-.016L27.178 28H18c-.552 0-1 .447-1 1s.448 1 1 1h11.001c.116 0 .23-.028.343-.069.034-.013.064-.027.097-.043.031-.017.066-.024.097-.044.03-.02.048-.051.075-.072.055-.044.103-.089.147-.143.041-.049.074-.099.104-.154.03-.056.055-.11.075-.172.021-.066.033-.132.04-.201.004-.036.021-.066.021-.102 0-.027-.014-.051-.016-.078z"/>
 | 
			
		||||
  <path fill="#DD2E44" d="M21.581 16l-2.899 8.117-5.929-6.775c-.364-.415-.996-.459-1.411-.094-.415.364-.457.995-.094 1.411l6.664 7.615-.854 2.39c-.185.519.086 1.092.606 1.277.111.04.224.059.336.059.411 0 .796-.255.942-.664L23.705 16h-2.124z"/>
 | 
			
		||||
  <path fill="#DD2E44" d="M7 30c-.15 0-.303-.034-.446-.105-.494-.247-.694-.848-.447-1.342l3.062-6.106C9.186 22.419 11 19.651 11 17c0-3.242-2.293-4.043-2.316-4.051-.524-.175-.807-.741-.632-1.265.174-.524.739-.81 1.265-.632C9.467 11.102 13 12.333 13 17c0 3.068-1.836 6.042-2.131 6.497l-2.974 5.949C7.72 29.798 7.367 30 7 30z"/>
 | 
			
		||||
  <path fill="#292F33" d="M14.612 13.663c-.054 0-.11-.004-.165-.014l-6-1c-.544-.091-.913-.606-.822-1.151.091-.544.601-.913 1.151-.822l6 1c.544.091.913.606.822 1.151-.082.489-.506.836-.986.836zM26.383 17c-.03 0-.059-.002-.089-.006l-5.672-.708c-.372-.046-.644-.374-.62-.748.023-.374.333-.665.707-.665.041 0 4.067-.018 5.989-1.299.25-.167.582-.157.824.026.239.185.337.501.241.788l-.709 2.127c-.096.293-.369.485-.671.485z"/>
 | 
			
		||||
  <path fill="#66757F" d="M20 29c0 1.104-.895 2-2 2-1.104 0-2-.896-2-2s.896-2 2-2c1.105 0 2 .896 2 2z"/>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 4.9 KiB  | 
| 
						 | 
				
			
			@ -4,23 +4,83 @@ RSpec.describe Api::V1::AppsController, type: :controller do
 | 
			
		|||
  render_views
 | 
			
		||||
 | 
			
		||||
  describe 'POST #create' do
 | 
			
		||||
    let(:client_name) { 'Test app' }
 | 
			
		||||
    let(:scopes) { nil }
 | 
			
		||||
    let(:redirect_uris) { 'urn:ietf:wg:oauth:2.0:oob' }
 | 
			
		||||
    let(:website) { nil }
 | 
			
		||||
 | 
			
		||||
    let(:app_params) do
 | 
			
		||||
      {
 | 
			
		||||
        client_name: client_name,
 | 
			
		||||
        redirect_uris: redirect_uris,
 | 
			
		||||
        scopes: scopes,
 | 
			
		||||
        website: website,
 | 
			
		||||
      }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    before do
 | 
			
		||||
      post :create, params: { client_name: 'Test app', redirect_uris: 'urn:ietf:wg:oauth:2.0:oob' }
 | 
			
		||||
      post :create, params: app_params
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns http success' do
 | 
			
		||||
      expect(response).to have_http_status(200)
 | 
			
		||||
    context 'with valid params' do
 | 
			
		||||
      it 'returns http success' do
 | 
			
		||||
        expect(response).to have_http_status(200)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'creates an OAuth app' do
 | 
			
		||||
        expect(Doorkeeper::Application.find_by(name: client_name)).to_not be nil
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'returns client ID and client secret' do
 | 
			
		||||
        json = body_as_json
 | 
			
		||||
 | 
			
		||||
        expect(json[:client_id]).to_not be_blank
 | 
			
		||||
        expect(json[:client_secret]).to_not be_blank
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'creates an OAuth app' do
 | 
			
		||||
      expect(Doorkeeper::Application.find_by(name: 'Test app')).to_not be nil
 | 
			
		||||
    context 'with an unsupported scope' do
 | 
			
		||||
      let(:scopes) { 'hoge' }
 | 
			
		||||
 | 
			
		||||
      it 'returns http unprocessable entity' do
 | 
			
		||||
        expect(response).to have_http_status(422)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns client ID and client secret' do
 | 
			
		||||
      json = body_as_json
 | 
			
		||||
    context 'with many duplicate scopes' do
 | 
			
		||||
      let(:scopes) { (%w(read) * 40).join(' ') }
 | 
			
		||||
 | 
			
		||||
      expect(json[:client_id]).to_not be_blank
 | 
			
		||||
      expect(json[:client_secret]).to_not be_blank
 | 
			
		||||
      it 'returns http success' do
 | 
			
		||||
        expect(response).to have_http_status(200)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      it 'only saves the scope once' do
 | 
			
		||||
        expect(Doorkeeper::Application.find_by(name: client_name).scopes.to_s).to eq 'read'
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with a too-long name' do
 | 
			
		||||
      let(:client_name) { 'hoge' * 20 }
 | 
			
		||||
 | 
			
		||||
      it 'returns http unprocessable entity' do
 | 
			
		||||
        expect(response).to have_http_status(422)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with a too-long website' do
 | 
			
		||||
      let(:website) { 'https://foo.bar/' + ('hoge' * 2_000) }
 | 
			
		||||
 | 
			
		||||
      it 'returns http unprocessable entity' do
 | 
			
		||||
        expect(response).to have_http_status(422)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'with a too-long redirect_uris' do
 | 
			
		||||
      let(:redirect_uris) { 'https://foo.bar/' + ('hoge' * 2_000) }
 | 
			
		||||
 | 
			
		||||
      it 'returns http unprocessable entity' do
 | 
			
		||||
        expect(response).to have_http_status(422)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -27,20 +27,27 @@ describe Api::V1::Push::SubscriptionsController do
 | 
			
		|||
  let(:alerts_payload) do
 | 
			
		||||
    {
 | 
			
		||||
      data: {
 | 
			
		||||
        policy: 'all',
 | 
			
		||||
 | 
			
		||||
        alerts: {
 | 
			
		||||
          follow: true,
 | 
			
		||||
          follow_request: true,
 | 
			
		||||
          favourite: false,
 | 
			
		||||
          reblog: true,
 | 
			
		||||
          mention: false,
 | 
			
		||||
          poll: true,
 | 
			
		||||
          status: false,
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }.with_indifferent_access
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'POST #create' do
 | 
			
		||||
    it 'saves push subscriptions' do
 | 
			
		||||
    before do
 | 
			
		||||
      post :create, params: create_payload
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'saves push subscriptions' do
 | 
			
		||||
      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])
 | 
			
		||||
 | 
			
		||||
      expect(push_subscription.endpoint).to eq(create_payload[:subscription][:endpoint])
 | 
			
		||||
| 
						 | 
				
			
			@ -52,31 +59,34 @@ describe Api::V1::Push::SubscriptionsController do
 | 
			
		|||
 | 
			
		||||
    it 'replaces old subscription on repeat calls' do
 | 
			
		||||
      post :create, params: create_payload
 | 
			
		||||
      post :create, params: create_payload
 | 
			
		||||
 | 
			
		||||
      expect(Web::PushSubscription.where(endpoint: create_payload[:subscription][:endpoint]).count).to eq 1
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'PUT #update' do
 | 
			
		||||
    it 'changes alert settings' do
 | 
			
		||||
    before do
 | 
			
		||||
      post :create, params: create_payload
 | 
			
		||||
      put :update, params: alerts_payload
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'changes alert settings' do
 | 
			
		||||
      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])
 | 
			
		||||
 | 
			
		||||
      expect(push_subscription.data.dig('alerts', 'follow')).to eq(alerts_payload[:data][:alerts][:follow].to_s)
 | 
			
		||||
      expect(push_subscription.data.dig('alerts', 'favourite')).to eq(alerts_payload[:data][:alerts][:favourite].to_s)
 | 
			
		||||
      expect(push_subscription.data.dig('alerts', 'reblog')).to eq(alerts_payload[:data][:alerts][:reblog].to_s)
 | 
			
		||||
      expect(push_subscription.data.dig('alerts', 'mention')).to eq(alerts_payload[:data][:alerts][:mention].to_s)
 | 
			
		||||
      expect(push_subscription.data['policy']).to eq(alerts_payload[:data][:policy])
 | 
			
		||||
 | 
			
		||||
      %w(follow follow_request favourite reblog mention poll status).each do |type|
 | 
			
		||||
        expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe 'DELETE #destroy' do
 | 
			
		||||
    it 'removes the subscription' do
 | 
			
		||||
    before do
 | 
			
		||||
      post :create, params: create_payload
 | 
			
		||||
      delete :destroy
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'removes the subscription' do
 | 
			
		||||
      expect(Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])).to be_nil
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -22,11 +22,16 @@ describe Api::Web::PushSubscriptionsController do
 | 
			
		|||
  let(:alerts_payload) do
 | 
			
		||||
    {
 | 
			
		||||
      data: {
 | 
			
		||||
        policy: 'all',
 | 
			
		||||
 | 
			
		||||
        alerts: {
 | 
			
		||||
          follow: true,
 | 
			
		||||
          follow_request: false,
 | 
			
		||||
          favourite: false,
 | 
			
		||||
          reblog: true,
 | 
			
		||||
          mention: false,
 | 
			
		||||
          poll: true,
 | 
			
		||||
          status: false,
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			@ -59,10 +64,11 @@ describe Api::Web::PushSubscriptionsController do
 | 
			
		|||
 | 
			
		||||
        push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])
 | 
			
		||||
 | 
			
		||||
        expect(push_subscription.data['alerts']['follow']).to eq(alerts_payload[:data][:alerts][:follow].to_s)
 | 
			
		||||
        expect(push_subscription.data['alerts']['favourite']).to eq(alerts_payload[:data][:alerts][:favourite].to_s)
 | 
			
		||||
        expect(push_subscription.data['alerts']['reblog']).to eq(alerts_payload[:data][:alerts][:reblog].to_s)
 | 
			
		||||
        expect(push_subscription.data['alerts']['mention']).to eq(alerts_payload[:data][:alerts][:mention].to_s)
 | 
			
		||||
        expect(push_subscription.data['policy']).to eq 'all'
 | 
			
		||||
 | 
			
		||||
        %w(follow follow_request favourite reblog mention poll status).each do |type|
 | 
			
		||||
          expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			@ -81,10 +87,11 @@ describe Api::Web::PushSubscriptionsController do
 | 
			
		|||
 | 
			
		||||
      push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:subscription][:endpoint])
 | 
			
		||||
 | 
			
		||||
      expect(push_subscription.data['alerts']['follow']).to eq(alerts_payload[:data][:alerts][:follow].to_s)
 | 
			
		||||
      expect(push_subscription.data['alerts']['favourite']).to eq(alerts_payload[:data][:alerts][:favourite].to_s)
 | 
			
		||||
      expect(push_subscription.data['alerts']['reblog']).to eq(alerts_payload[:data][:alerts][:reblog].to_s)
 | 
			
		||||
      expect(push_subscription.data['alerts']['mention']).to eq(alerts_payload[:data][:alerts][:mention].to_s)
 | 
			
		||||
      expect(push_subscription.data['policy']).to eq 'all'
 | 
			
		||||
 | 
			
		||||
      %w(follow follow_request favourite reblog mention poll status).each do |type|
 | 
			
		||||
        expect(push_subscription.data['alerts'][type]).to eq(alerts_payload[:data][:alerts][type.to_sym].to_s)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										4
									
								
								spec/fabricators/canonical_email_block_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								spec/fabricators/canonical_email_block_fabricator.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
Fabricator(:canonical_email_block) do
 | 
			
		||||
  email "test@example.com"
 | 
			
		||||
  reference_account { Fabricate(:account) }
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
Fabricator(:follow_recommendation_suppression) do
 | 
			
		||||
  account
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -1,192 +0,0 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe SpamCheck do
 | 
			
		||||
  let!(:sender) { Fabricate(:account) }
 | 
			
		||||
  let!(:alice) { Fabricate(:account, username: 'alice') }
 | 
			
		||||
  let!(:bob) { Fabricate(:account, username: 'bob') }
 | 
			
		||||
 | 
			
		||||
  def status_with_html(text, options = {})
 | 
			
		||||
    status = PostStatusService.new.call(sender, { text: text }.merge(options))
 | 
			
		||||
    status.update_columns(text: Formatter.instance.format(status), local: false)
 | 
			
		||||
    status
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#hashable_text' do
 | 
			
		||||
    it 'removes mentions from HTML for remote statuses' do
 | 
			
		||||
      status = status_with_html('@alice Hello')
 | 
			
		||||
      expect(described_class.new(status).hashable_text).to eq 'hello'
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'removes mentions from text for local statuses' do
 | 
			
		||||
      status = PostStatusService.new.call(alice, text: "Hey @#{sender.username}, how are you?")
 | 
			
		||||
      expect(described_class.new(status).hashable_text).to eq 'hey , how are you?'
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#insufficient_data?' do
 | 
			
		||||
    it 'returns true when there is no text' do
 | 
			
		||||
      status = status_with_html('@alice')
 | 
			
		||||
      expect(described_class.new(status).insufficient_data?).to be true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false when there is text' do
 | 
			
		||||
      status = status_with_html('@alice h')
 | 
			
		||||
      expect(described_class.new(status).insufficient_data?).to be false
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#digest' do
 | 
			
		||||
    it 'returns a string' do
 | 
			
		||||
      status = status_with_html('@alice Hello world')
 | 
			
		||||
      expect(described_class.new(status).digest).to be_a String
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#spam?' do
 | 
			
		||||
    it 'returns false for a unique status' do
 | 
			
		||||
      status = status_with_html('@alice Hello')
 | 
			
		||||
      expect(described_class.new(status).spam?).to be false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false for different statuses to the same recipient' do
 | 
			
		||||
      status1 = status_with_html('@alice Hello')
 | 
			
		||||
      described_class.new(status1).remember!
 | 
			
		||||
      status2 = status_with_html('@alice Are you available to talk?')
 | 
			
		||||
      expect(described_class.new(status2).spam?).to be false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false for statuses with different content warnings' do
 | 
			
		||||
      status1 = status_with_html('@alice Are you available to talk?')
 | 
			
		||||
      described_class.new(status1).remember!
 | 
			
		||||
      status2 = status_with_html('@alice Are you available to talk?', spoiler_text: 'This is a completely different matter than what I was talking about previously, I swear!')
 | 
			
		||||
      expect(described_class.new(status2).spam?).to be false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false for different statuses to different recipients' do
 | 
			
		||||
      status1 = status_with_html('@alice How is it going?')
 | 
			
		||||
      described_class.new(status1).remember!
 | 
			
		||||
      status2 = status_with_html('@bob Are you okay?')
 | 
			
		||||
      expect(described_class.new(status2).spam?).to be false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false for very short different statuses to different recipients' do
 | 
			
		||||
      status1 = status_with_html('@alice 🙄')
 | 
			
		||||
      described_class.new(status1).remember!
 | 
			
		||||
      status2 = status_with_html('@bob Huh?')
 | 
			
		||||
      expect(described_class.new(status2).spam?).to be false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false for statuses with no text' do
 | 
			
		||||
      status1 = status_with_html('@alice')
 | 
			
		||||
      described_class.new(status1).remember!
 | 
			
		||||
      status2 = status_with_html('@bob')
 | 
			
		||||
      expect(described_class.new(status2).spam?).to be false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true for duplicate statuses to the same recipient' do
 | 
			
		||||
      described_class::THRESHOLD.times do
 | 
			
		||||
        status1 = status_with_html('@alice Hello')
 | 
			
		||||
        described_class.new(status1).remember!
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      status2 = status_with_html('@alice Hello')
 | 
			
		||||
      expect(described_class.new(status2).spam?).to be true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true for duplicate statuses to different recipients' do
 | 
			
		||||
      described_class::THRESHOLD.times do
 | 
			
		||||
        status1 = status_with_html('@alice Hello')
 | 
			
		||||
        described_class.new(status1).remember!
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      status2 = status_with_html('@bob Hello')
 | 
			
		||||
      expect(described_class.new(status2).spam?).to be true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true for nearly identical statuses with random numbers' do
 | 
			
		||||
      source_text = 'Sodium, atomic number 11, was first isolated by Humphry Davy in 1807. A chemical component of salt, he named it Na in honor of the saltiest region on earth, North America.'
 | 
			
		||||
 | 
			
		||||
      described_class::THRESHOLD.times do
 | 
			
		||||
        status1 = status_with_html('@alice ' + source_text + ' 1234')
 | 
			
		||||
        described_class.new(status1).remember!
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      status2 = status_with_html('@bob ' + source_text + ' 9568')
 | 
			
		||||
      expect(described_class.new(status2).spam?).to be true
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#skip?' do
 | 
			
		||||
    it 'returns true when the sender is already silenced' do
 | 
			
		||||
      status = status_with_html('@alice Hello')
 | 
			
		||||
      sender.silence!
 | 
			
		||||
      expect(described_class.new(status).skip?).to be true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true when the mentioned person follows the sender' do
 | 
			
		||||
      status = status_with_html('@alice Hello')
 | 
			
		||||
      alice.follow!(sender)
 | 
			
		||||
      expect(described_class.new(status).skip?).to be true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false when even one mentioned person doesn\'t follow the sender' do
 | 
			
		||||
      status = status_with_html('@alice @bob Hello')
 | 
			
		||||
      alice.follow!(sender)
 | 
			
		||||
      expect(described_class.new(status).skip?).to be false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true when the sender is replying to a status that mentions the sender' do
 | 
			
		||||
      parent = PostStatusService.new.call(alice, text: "Hey @#{sender.username}, how are you?")
 | 
			
		||||
      status = status_with_html('@alice @bob Hello', thread: parent)
 | 
			
		||||
      expect(described_class.new(status).skip?).to be true
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#remember!' do
 | 
			
		||||
    let(:status) { status_with_html('@alice') }
 | 
			
		||||
    let(:spam_check) { described_class.new(status) }
 | 
			
		||||
    let(:redis_key) { spam_check.send(:redis_key) }
 | 
			
		||||
 | 
			
		||||
    it 'remembers' do
 | 
			
		||||
      expect(Redis.current.exists?(redis_key)).to be true
 | 
			
		||||
      spam_check.remember!
 | 
			
		||||
      expect(Redis.current.exists?(redis_key)).to be true
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#reset!' do
 | 
			
		||||
    let(:status) { status_with_html('@alice') }
 | 
			
		||||
    let(:spam_check) { described_class.new(status) }
 | 
			
		||||
    let(:redis_key) { spam_check.send(:redis_key) }
 | 
			
		||||
 | 
			
		||||
    before do
 | 
			
		||||
      spam_check.remember!
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'resets' do
 | 
			
		||||
      expect(Redis.current.exists?(redis_key)).to be true
 | 
			
		||||
      spam_check.reset!
 | 
			
		||||
      expect(Redis.current.exists?(redis_key)).to be false
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#flag!' do
 | 
			
		||||
    let!(:status1) { status_with_html('@alice General Kenobi you are a bold one') }
 | 
			
		||||
    let!(:status2) { status_with_html('@alice @bob General Kenobi, you are a bold one') }
 | 
			
		||||
 | 
			
		||||
    before do
 | 
			
		||||
      described_class.new(status1).remember!
 | 
			
		||||
      described_class.new(status2).flag!
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'creates a report about the account' do
 | 
			
		||||
      expect(sender.targeted_reports.unresolved.count).to eq 1
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'attaches both matching statuses to the report' do
 | 
			
		||||
      expect(sender.targeted_reports.first.status_ids).to include(status1.id, status2.id)
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -83,40 +83,4 @@ RSpec.describe TagManager do
 | 
			
		|||
      expect(TagManager.instance.local_url?('https://domainn.test/')).to eq false
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '#same_acct?' do
 | 
			
		||||
    # The following comparisons MUST be case-insensitive.
 | 
			
		||||
 | 
			
		||||
    it 'returns true if the needle has a correct username and domain for remote user' do
 | 
			
		||||
      expect(TagManager.instance.same_acct?('username@domain.test', 'UsErNaMe@DoMaIn.Test')).to eq true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false if the needle is missing a domain for remote user' do
 | 
			
		||||
      expect(TagManager.instance.same_acct?('username@domain.test', 'UsErNaMe')).to eq false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false if the needle has an incorrect domain for remote user' do
 | 
			
		||||
      expect(TagManager.instance.same_acct?('username@domain.test', 'UsErNaMe@incorrect.test')).to eq false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false if the needle has an incorrect username for remote user' do
 | 
			
		||||
      expect(TagManager.instance.same_acct?('username@domain.test', 'incorrect@DoMaIn.test')).to eq false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true if the needle has a correct username and domain for local user' do
 | 
			
		||||
      expect(TagManager.instance.same_acct?('username', 'UsErNaMe@Cb6E6126.nGrOk.Io')).to eq true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true if the needle is missing a domain for local user' do
 | 
			
		||||
      expect(TagManager.instance.same_acct?('username', 'UsErNaMe')).to eq true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false if the needle has an incorrect username for local user' do
 | 
			
		||||
      expect(TagManager.instance.same_acct?('username', 'UsErNaM@Cb6E6126.nGrOk.Io')).to eq false
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false if the needle has an incorrect domain for local user' do
 | 
			
		||||
      expect(TagManager.instance.same_acct?('username', 'incorrect@Cb6E6126.nGrOk.Io')).to eq false
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										47
									
								
								spec/models/canonical_email_block_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										47
									
								
								spec/models/canonical_email_block_spec.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,47 @@
 | 
			
		|||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe CanonicalEmailBlock, type: :model do
 | 
			
		||||
  describe '#email=' do
 | 
			
		||||
    let(:target_hash) { '973dfe463ec85785f5f95af5ba3906eedb2d931c24e69824a89ea65dba4e813b' }
 | 
			
		||||
 | 
			
		||||
    it 'sets canonical_email_hash' do
 | 
			
		||||
      subject.email = 'test@example.com'
 | 
			
		||||
      expect(subject.canonical_email_hash).to eq target_hash
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'sets the same hash even with dot permutations' do
 | 
			
		||||
      subject.email = 't.e.s.t@example.com'
 | 
			
		||||
      expect(subject.canonical_email_hash).to eq target_hash
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'sets the same hash even with extensions' do
 | 
			
		||||
      subject.email = 'test+mastodon1@example.com'
 | 
			
		||||
      expect(subject.canonical_email_hash).to eq target_hash
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'sets the same hash with different casing' do
 | 
			
		||||
      subject.email = 'Test@EXAMPLE.com'
 | 
			
		||||
      expect(subject.canonical_email_hash).to eq target_hash
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  describe '.block?' do
 | 
			
		||||
    let!(:canonical_email_block) { Fabricate(:canonical_email_block, email: 'foo@bar.com') }
 | 
			
		||||
 | 
			
		||||
    it 'returns true for the same email' do
 | 
			
		||||
      expect(described_class.block?('foo@bar.com')).to be true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true for the same email with dots' do
 | 
			
		||||
      expect(described_class.block?('f.oo@bar.com')).to be true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns true for the same email with extensions' do
 | 
			
		||||
      expect(described_class.block?('foo+spam@bar.com')).to be true
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'returns false for different email' do
 | 
			
		||||
      expect(described_class.block?('hoge@bar.com')).to be false
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										4
									
								
								spec/models/follow_recommendation_suppression_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								spec/models/follow_recommendation_suppression_spec.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,4 @@
 | 
			
		|||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe FollowRecommendationSuppression, type: :model do
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			@ -1,16 +1,94 @@
 | 
			
		|||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
RSpec.describe Web::PushSubscription, type: :model do
 | 
			
		||||
  let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } }
 | 
			
		||||
  let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) }
 | 
			
		||||
  let(:account) { Fabricate(:account) }
 | 
			
		||||
 | 
			
		||||
  let(:policy) { 'all' }
 | 
			
		||||
 | 
			
		||||
  let(:data) do
 | 
			
		||||
    {
 | 
			
		||||
      policy: policy,
 | 
			
		||||
 | 
			
		||||
      alerts: {
 | 
			
		||||
        mention: true,
 | 
			
		||||
        reblog: false,
 | 
			
		||||
        follow: true,
 | 
			
		||||
        follow_request: false,
 | 
			
		||||
        favourite: true,
 | 
			
		||||
      },
 | 
			
		||||
    }
 | 
			
		||||
  end
 | 
			
		||||
 | 
			
		||||
  subject { described_class.new(data: data) }
 | 
			
		||||
 | 
			
		||||
  describe '#pushable?' do
 | 
			
		||||
    it 'obeys alert settings' do
 | 
			
		||||
      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true
 | 
			
		||||
      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false
 | 
			
		||||
      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true
 | 
			
		||||
      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false
 | 
			
		||||
      expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true
 | 
			
		||||
    let(:notification_type) { :mention }
 | 
			
		||||
    let(:notification) { Fabricate(:notification, account: account, type: notification_type) }
 | 
			
		||||
 | 
			
		||||
    %i(mention reblog follow follow_request favourite).each do |type|
 | 
			
		||||
      context "when notification is a #{type}" do
 | 
			
		||||
        let(:notification_type) { type }
 | 
			
		||||
 | 
			
		||||
        it "returns boolean corresonding to alert setting" do
 | 
			
		||||
          expect(subject.pushable?(notification)).to eq data[:alerts][type]
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when policy is all' do
 | 
			
		||||
      let(:policy) { 'all' }
 | 
			
		||||
 | 
			
		||||
      it 'returns true' do
 | 
			
		||||
        expect(subject.pushable?(notification)).to eq true
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when policy is none' do
 | 
			
		||||
      let(:policy) { 'none' }
 | 
			
		||||
 | 
			
		||||
      it 'returns false' do
 | 
			
		||||
        expect(subject.pushable?(notification)).to eq false
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when policy is followed' do
 | 
			
		||||
      let(:policy) { 'followed' }
 | 
			
		||||
 | 
			
		||||
      context 'and notification is from someone you follow' do
 | 
			
		||||
        before do
 | 
			
		||||
          account.follow!(notification.from_account)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'returns true' do
 | 
			
		||||
          expect(subject.pushable?(notification)).to eq true
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'and notification is not from someone you follow' do
 | 
			
		||||
        it 'returns false' do
 | 
			
		||||
          expect(subject.pushable?(notification)).to eq false
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'when policy is follower' do
 | 
			
		||||
      let(:policy) { 'follower' }
 | 
			
		||||
 | 
			
		||||
      context 'and notification is from someone who follows you' do
 | 
			
		||||
        before do
 | 
			
		||||
          notification.from_account.follow!(account)
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'returns true' do
 | 
			
		||||
          expect(subject.pushable?(notification)).to eq true
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'and notification is not from someone who follows you' do
 | 
			
		||||
        it 'returns false' do
 | 
			
		||||
          expect(subject.pushable?(notification)).to eq false
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -9,23 +9,36 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
 | 
			
		|||
 | 
			
		||||
    before do
 | 
			
		||||
      allow(user).to receive(:valid_invitation?) { false }
 | 
			
		||||
      allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
 | 
			
		||||
      described_class.new.validate(user)
 | 
			
		||||
      allow_any_instance_of(described_class).to receive(:blocked_email_provider?) { blocked_email }
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context 'blocked_email?' do
 | 
			
		||||
    subject { described_class.new.validate(user); errors }
 | 
			
		||||
 | 
			
		||||
    context 'when e-mail provider is blocked' do
 | 
			
		||||
      let(:blocked_email) { true }
 | 
			
		||||
 | 
			
		||||
      it 'calls errors.add' do
 | 
			
		||||
        expect(errors).to have_received(:add).with(:email, :blocked)
 | 
			
		||||
      it 'adds error' do
 | 
			
		||||
        expect(subject).to have_received(:add).with(:email, :blocked)
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    context '!blocked_email?' do
 | 
			
		||||
    context 'when e-mail provider is not blocked' do
 | 
			
		||||
      let(:blocked_email) { false }
 | 
			
		||||
 | 
			
		||||
      it 'not calls errors.add' do
 | 
			
		||||
        expect(errors).not_to have_received(:add).with(:email, :blocked)
 | 
			
		||||
      it 'does not add errors' do
 | 
			
		||||
        expect(subject).not_to have_received(:add).with(:email, :blocked)
 | 
			
		||||
      end
 | 
			
		||||
 | 
			
		||||
      context 'when canonical e-mail is blocked' do
 | 
			
		||||
        let(:other_user) { Fabricate(:user, email: 'i.n.f.o@mail.com') }
 | 
			
		||||
 | 
			
		||||
        before do
 | 
			
		||||
          other_user.account.suspend!
 | 
			
		||||
        end
 | 
			
		||||
 | 
			
		||||
        it 'adds error' do
 | 
			
		||||
          expect(subject).to have_received(:add).with(:email, :taken)
 | 
			
		||||
        end
 | 
			
		||||
      end
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										48
									
								
								spec/workers/web/push_notification_worker_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								spec/workers/web/push_notification_worker_spec.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -0,0 +1,48 @@
 | 
			
		|||
# frozen_string_literal: true
 | 
			
		||||
 | 
			
		||||
require 'rails_helper'
 | 
			
		||||
 | 
			
		||||
describe Web::PushNotificationWorker do
 | 
			
		||||
  subject { described_class.new }
 | 
			
		||||
 | 
			
		||||
  let(:p256dh) { 'BN4GvZtEZiZuqFxSKVZfSfluwKBD7UxHNBmWkfiZfCtgDE8Bwh-_MtLXbBxTBAWH9r7IPKL0lhdcaqtL1dfxU5E=' }
 | 
			
		||||
  let(:auth) { 'Q2BoAjC09xH3ywDLNJr-dA==' }
 | 
			
		||||
  let(:endpoint) { 'https://updates.push.services.mozilla.com/push/v1/subscription-id' }
 | 
			
		||||
  let(:user) { Fabricate(:user) }
 | 
			
		||||
  let(:notification) { Fabricate(:notification) }
 | 
			
		||||
  let(:subscription) { Fabricate(:web_push_subscription, user_id: user.id, key_p256dh: p256dh, key_auth: auth, endpoint: endpoint, data: { alerts: { notification.type => true } }) }
 | 
			
		||||
  let(:vapid_public_key) { 'BB37UCyc8LLX4PNQSe-04vSFvpUWGrENubUaslVFM_l5TxcGVMY0C3RXPeUJAQHKYlcOM2P4vTYmkoo0VZGZTM4=' }
 | 
			
		||||
  let(:vapid_private_key) { 'OPrw1Sum3gRoL4-DXfSCC266r-qfFSRZrnj8MgIhRHg=' }
 | 
			
		||||
  let(:vapid_key) { Webpush::VapidKey.from_keys(vapid_public_key, vapid_private_key) }
 | 
			
		||||
  let(:contact_email) { 'sender@example.com' }
 | 
			
		||||
  let(:ciphertext) { "+\xB8\xDBT}\x13\xB6\xDD.\xF9\xB0\xA7\xC8\xD2\x80\xFD\x99#\xF7\xAC\x83\xA4\xDB,\x1F\xB5\xB9w\x85>\xF7\xADr" }
 | 
			
		||||
  let(:salt) { "X\x97\x953\xE4X\xF8_w\xE7T\x95\xC51q\xFE" }
 | 
			
		||||
  let(:server_public_key) { "\x04\b-RK9w\xDD$\x16lFz\xF9=\xB4~\xC6\x12k\xF3\xF40t\xA9\xC1\fR\xC3\x81\x80\xAC\f\x7F\xE4\xCC\x8E\xC2\x88 n\x8BB\xF1\x9C\x14\a\xFA\x8D\xC9\x80\xA1\xDDyU\\&c\x01\x88#\x118Ua" }
 | 
			
		||||
  let(:shared_secret) { "\t\xA7&\x85\t\xC5m\b\xA8\xA7\xF8B{1\xADk\xE1y'm\xEDE\xEC\xDD\xEDj\xB3$s\xA9\xDA\xF0" }
 | 
			
		||||
  let(:payload) { { ciphertext: ciphertext, salt: salt, server_public_key: server_public_key, shared_secret: shared_secret } }
 | 
			
		||||
 | 
			
		||||
  describe 'perform' do
 | 
			
		||||
    before do
 | 
			
		||||
      allow_any_instance_of(subscription.class).to receive(:contact_email).and_return(contact_email)
 | 
			
		||||
      allow_any_instance_of(subscription.class).to receive(:vapid_key).and_return(vapid_key)
 | 
			
		||||
      allow(Webpush::Encryption).to receive(:encrypt).and_return(payload)
 | 
			
		||||
      allow(JWT).to receive(:encode).and_return('jwt.encoded.payload')
 | 
			
		||||
 | 
			
		||||
      stub_request(:post, endpoint).to_return(status: 201, body: '')
 | 
			
		||||
 | 
			
		||||
      subject.perform(subscription.id, notification.id)
 | 
			
		||||
    end
 | 
			
		||||
 | 
			
		||||
    it 'calls the relevant service with the correct headers' do
 | 
			
		||||
      expect(a_request(:post, endpoint).with(headers: {
 | 
			
		||||
        'Content-Encoding' => 'aesgcm',
 | 
			
		||||
        'Content-Type' => 'application/octet-stream',
 | 
			
		||||
        'Crypto-Key' => 'dh=BAgtUks5d90kFmxGevk9tH7GEmvz9DB0qcEMUsOBgKwMf-TMjsKIIG6LQvGcFAf6jcmAod15VVwmYwGIIxE4VWE;p256ecdsa=' + vapid_public_key.delete('='),
 | 
			
		||||
        'Encryption' => 'salt=WJeVM-RY-F9351SVxTFx_g',
 | 
			
		||||
        'Ttl' => '172800',
 | 
			
		||||
        'Urgency' => 'normal',
 | 
			
		||||
        'Authorization' => 'WebPush jwt.encoded.payload',
 | 
			
		||||
      }, body: "+\xB8\xDBT}\u0013\xB6\xDD.\xF9\xB0\xA7\xC8Ҁ\xFD\x99#\xF7\xAC\x83\xA4\xDB,\u001F\xB5\xB9w\x85>\xF7\xADr")).to have_been_made
 | 
			
		||||
    end
 | 
			
		||||
  end
 | 
			
		||||
end
 | 
			
		||||
							
								
								
									
										427
									
								
								yarn.lock
									
									
									
									
									
								
							
							
						
						
									
										427
									
								
								yarn.lock
									
									
									
									
									
								
							| 
						 | 
				
			
			@ -16,24 +16,24 @@
 | 
			
		|||
  dependencies:
 | 
			
		||||
    "@babel/highlight" "^7.12.13"
 | 
			
		||||
 | 
			
		||||
"@babel/compat-data@^7.13.0", "@babel/compat-data@^7.13.12", "@babel/compat-data@^7.13.8":
 | 
			
		||||
  version "7.13.12"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.12.tgz#a8a5ccac19c200f9dd49624cac6e19d7be1236a1"
 | 
			
		||||
  integrity sha512-3eJJ841uKxeV8dcN/2yGEUy+RfgQspPEgQat85umsE1rotuquQ2AbIub4S6j7c50a2d+4myc+zSlnXeIHrOnhQ==
 | 
			
		||||
"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.13.12", "@babel/compat-data@^7.13.15", "@babel/compat-data@^7.13.8":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.13.15.tgz#7e8eea42d0b64fda2b375b22d06c605222e848f4"
 | 
			
		||||
  integrity sha512-ltnibHKR1VnrU4ymHyQ/CXtNXI6yZC0oJThyW78Hft8XndANwi+9H+UIklBDraIjFEJzw8wmcM427oDd9KS5wA==
 | 
			
		||||
 | 
			
		||||
"@babel/core@^7.1.0", "@babel/core@^7.13.14", "@babel/core@^7.7.2", "@babel/core@^7.7.5":
 | 
			
		||||
  version "7.13.14"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.14.tgz#8e46ebbaca460a63497c797e574038ab04ae6d06"
 | 
			
		||||
  integrity sha512-wZso/vyF4ki0l0znlgM4inxbdrUvCb+cVz8grxDq+6C9k6qbqoIJteQOKicaKjCipU3ISV+XedCqpL2RJJVehA==
 | 
			
		||||
"@babel/core@^7.1.0", "@babel/core@^7.13.15", "@babel/core@^7.7.2", "@babel/core@^7.7.5":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.13.15.tgz#a6d40917df027487b54312202a06812c4f7792d0"
 | 
			
		||||
  integrity sha512-6GXmNYeNjS2Uz+uls5jalOemgIhnTMeaXo+yBUA72kC2uX/8VW6XyhVIo2L8/q0goKQA3EVKx0KOQpVKSeWadQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/code-frame" "^7.12.13"
 | 
			
		||||
    "@babel/generator" "^7.13.9"
 | 
			
		||||
    "@babel/helper-compilation-targets" "^7.13.13"
 | 
			
		||||
    "@babel/helper-module-transforms" "^7.13.14"
 | 
			
		||||
    "@babel/helpers" "^7.13.10"
 | 
			
		||||
    "@babel/parser" "^7.13.13"
 | 
			
		||||
    "@babel/parser" "^7.13.15"
 | 
			
		||||
    "@babel/template" "^7.12.13"
 | 
			
		||||
    "@babel/traverse" "^7.13.13"
 | 
			
		||||
    "@babel/traverse" "^7.13.15"
 | 
			
		||||
    "@babel/types" "^7.13.14"
 | 
			
		||||
    convert-source-map "^1.7.0"
 | 
			
		||||
    debug "^4.1.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -81,7 +81,7 @@
 | 
			
		|||
    "@babel/helper-annotate-as-pure" "^7.12.13"
 | 
			
		||||
    "@babel/types" "^7.12.13"
 | 
			
		||||
 | 
			
		||||
"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.13.10", "@babel/helper-compilation-targets@^7.13.13", "@babel/helper-compilation-targets@^7.13.8":
 | 
			
		||||
"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.13.13", "@babel/helper-compilation-targets@^7.13.8":
 | 
			
		||||
  version "7.13.13"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.13.13.tgz#2b2972a0926474853f41e4adbc69338f520600e5"
 | 
			
		||||
  integrity sha512-q1kcdHNZehBwD9jYPh3WyXcsFERi39X4I59I3NadciWtNDyZ6x+GboOxncFK0kXlKIv6BJm5acncehXWUjWQMQ==
 | 
			
		||||
| 
						 | 
				
			
			@ -91,10 +91,10 @@
 | 
			
		|||
    browserslist "^4.14.5"
 | 
			
		||||
    semver "^6.3.0"
 | 
			
		||||
 | 
			
		||||
"@babel/helper-create-class-features-plugin@^7.13.0":
 | 
			
		||||
  version "7.13.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.0.tgz#28d04ad9cfbd1ed1d8b988c9ea7b945263365846"
 | 
			
		||||
  integrity sha512-twwzhthM4/+6o9766AW2ZBHpIHPSGrPGk1+WfHiu13u/lBnggXGNYCpeAyVfNwGDKfkhEDp+WOD/xafoJ2iLjA==
 | 
			
		||||
"@babel/helper-create-class-features-plugin@^7.13.0", "@babel/helper-create-class-features-plugin@^7.13.11":
 | 
			
		||||
  version "7.13.11"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.13.11.tgz#30d30a005bca2c953f5653fc25091a492177f4f6"
 | 
			
		||||
  integrity sha512-ays0I7XYq9xbjCSvT+EvysLgfc3tOkwCULHjrnscGT3A9qD4sk3wXnJ3of0MAWsWGjdinFvajHU2smYuqXKMrw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/helper-function-name" "^7.12.13"
 | 
			
		||||
    "@babel/helper-member-expression-to-functions" "^7.13.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -110,10 +110,10 @@
 | 
			
		|||
    "@babel/helper-annotate-as-pure" "^7.12.13"
 | 
			
		||||
    regexpu-core "^4.7.1"
 | 
			
		||||
 | 
			
		||||
"@babel/helper-define-polyfill-provider@^0.1.2":
 | 
			
		||||
  version "0.1.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.1.2.tgz#619f01afe1deda460676c25c463b42eaefdb71a2"
 | 
			
		||||
  integrity sha512-hWeolZJivTNGHXHzJjQz/NwDaG4mGXf22ZroOP8bQYgvHNzaQ5tylsVbAcAS2oDjXBwpu8qH2I/654QFS2rDpw==
 | 
			
		||||
"@babel/helper-define-polyfill-provider@^0.2.0":
 | 
			
		||||
  version "0.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.2.0.tgz#a640051772045fedaaecc6f0c6c69f02bdd34bf1"
 | 
			
		||||
  integrity sha512-JT8tHuFjKBo8NnaUbblz7mIu1nnvUDiHVjXXkulZULyidvo/7P6TY7+YqpV37IfF+KUFxmlK04elKtGKXaiVgw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/helper-compilation-targets" "^7.13.0"
 | 
			
		||||
    "@babel/helper-module-imports" "^7.12.13"
 | 
			
		||||
| 
						 | 
				
			
			@ -176,21 +176,7 @@
 | 
			
		|||
  dependencies:
 | 
			
		||||
    "@babel/types" "^7.13.12"
 | 
			
		||||
 | 
			
		||||
"@babel/helper-module-imports@^7.0.0-beta.49":
 | 
			
		||||
  version "7.12.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.5.tgz#1bfc0229f794988f76ed0a4d4e90860850b54dfb"
 | 
			
		||||
  integrity sha512-SR713Ogqg6++uexFRORf/+nPXMmWIn80TALu0uaFb+iQIUoR7bOC7zBWyzBs5b3tBBJXuyD0cRu1F15GyzjOWA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/types" "^7.12.5"
 | 
			
		||||
 | 
			
		||||
"@babel/helper-module-imports@^7.12.13":
 | 
			
		||||
  version "7.12.13"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.12.13.tgz#ec67e4404f41750463e455cc3203f6a32e93fcb0"
 | 
			
		||||
  integrity sha512-NGmfvRp9Rqxy0uHSSVP+SRIW1q31a7Ji10cLBcqSDUngGentY4FRiHOFZFE1CLU5eiL0oE8reH7Tg1y99TDM/g==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/types" "^7.12.13"
 | 
			
		||||
 | 
			
		||||
"@babel/helper-module-imports@^7.13.12":
 | 
			
		||||
"@babel/helper-module-imports@^7.0.0-beta.49", "@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.13.12":
 | 
			
		||||
  version "7.13.12"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.13.12.tgz#c6a369a6f3621cb25da014078684da9196b61977"
 | 
			
		||||
  integrity sha512-4cVvR2/1B693IuOvSI20xqqa/+bl7lqAMR59R4iu39R9aOX8/JoYY1sFaNvUMyMBGnHdwvJgUrzNLoUZxXypxA==
 | 
			
		||||
| 
						 | 
				
			
			@ -328,10 +314,10 @@
 | 
			
		|||
    chalk "^2.0.0"
 | 
			
		||||
    js-tokens "^4.0.0"
 | 
			
		||||
 | 
			
		||||
"@babel/parser@^7.1.0", "@babel/parser@^7.12.13", "@babel/parser@^7.13.13", "@babel/parser@^7.7.0":
 | 
			
		||||
  version "7.13.13"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.13.tgz#42f03862f4aed50461e543270916b47dd501f0df"
 | 
			
		||||
  integrity sha512-OhsyMrqygfk5v8HmWwOzlYjJrtLaFhF34MrfG/Z73DgYCI6ojNUTUp2TYbtnjo8PegeJp12eamsNettCQjKjVw==
 | 
			
		||||
"@babel/parser@^7.1.0", "@babel/parser@^7.12.13", "@babel/parser@^7.13.15", "@babel/parser@^7.7.0":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.13.15.tgz#8e66775fb523599acb6a289e12929fa5ab0954d8"
 | 
			
		||||
  integrity sha512-b9COtcAlVEQljy/9fbcMHpG+UIW9ReF+gpaxDHTlZd0c6/UU9ng8zdySAW9sRTzpvcdCHn6bUcbuYUgGzLAWVQ==
 | 
			
		||||
 | 
			
		||||
"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.13.12":
 | 
			
		||||
  version "7.13.12"
 | 
			
		||||
| 
						 | 
				
			
			@ -342,10 +328,10 @@
 | 
			
		|||
    "@babel/helper-skip-transparent-expression-wrappers" "^7.12.1"
 | 
			
		||||
    "@babel/plugin-proposal-optional-chaining" "^7.13.12"
 | 
			
		||||
 | 
			
		||||
"@babel/plugin-proposal-async-generator-functions@^7.13.8":
 | 
			
		||||
  version "7.13.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.8.tgz#87aacb574b3bc4b5603f6fe41458d72a5a2ec4b1"
 | 
			
		||||
  integrity sha512-rPBnhj+WgoSmgq+4gQUtXx/vOcU+UYtjy1AA/aeD61Hwj410fwYyqfUcRP3lR8ucgliVJL/G7sXcNUecC75IXA==
 | 
			
		||||
"@babel/plugin-proposal-async-generator-functions@^7.13.15":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.13.15.tgz#80e549df273a3b3050431b148c892491df1bcc5b"
 | 
			
		||||
  integrity sha512-VapibkWzFeoa6ubXy/NgV5U2U4MVnUlvnx6wo1XhlsaTrLYWE0UFpDQsVrmn22q5CzeloqJ8gEMHSKxuee6ZdA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/helper-plugin-utils" "^7.13.0"
 | 
			
		||||
    "@babel/helper-remap-async-to-generator" "^7.13.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -359,12 +345,12 @@
 | 
			
		|||
    "@babel/helper-create-class-features-plugin" "^7.13.0"
 | 
			
		||||
    "@babel/helper-plugin-utils" "^7.13.0"
 | 
			
		||||
 | 
			
		||||
"@babel/plugin-proposal-decorators@^7.13.5":
 | 
			
		||||
  version "7.13.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.13.5.tgz#d28071457a5ba8ee1394b23e38d5dcf32ea20ef7"
 | 
			
		||||
  integrity sha512-i0GDfVNuoapwiheevUOuSW67mInqJ8qw7uWfpjNVeHMn143kXblEy/bmL9AdZ/0yf/4BMQeWXezK0tQIvNPqag==
 | 
			
		||||
"@babel/plugin-proposal-decorators@^7.13.15":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.13.15.tgz#e91ccfef2dc24dd5bd5dcc9fc9e2557c684ecfb8"
 | 
			
		||||
  integrity sha512-ibAMAqUm97yzi+LPgdr5Nqb9CMkeieGHvwPg1ywSGjZrZHQEGqE01HmOio8kxRpA/+VtOHouIVy2FMpBbtltjA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/helper-create-class-features-plugin" "^7.13.0"
 | 
			
		||||
    "@babel/helper-create-class-features-plugin" "^7.13.11"
 | 
			
		||||
    "@babel/helper-plugin-utils" "^7.13.0"
 | 
			
		||||
    "@babel/plugin-syntax-decorators" "^7.12.13"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -796,10 +782,10 @@
 | 
			
		|||
    "@babel/helper-annotate-as-pure" "^7.10.4"
 | 
			
		||||
    "@babel/helper-plugin-utils" "^7.10.4"
 | 
			
		||||
 | 
			
		||||
"@babel/plugin-transform-regenerator@^7.12.13":
 | 
			
		||||
  version "7.12.13"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.12.13.tgz#b628bcc9c85260ac1aeb05b45bde25210194a2f5"
 | 
			
		||||
  integrity sha512-lxb2ZAvSLyJ2PEe47hoGWPmW22v7CtSl9jW8mingV4H2sEX/JOcrAj2nPuGWi56ERUm2bUpjKzONAuT6HCn2EA==
 | 
			
		||||
"@babel/plugin-transform-regenerator@^7.13.15":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.13.15.tgz#e5eb28945bf8b6563e7f818945f966a8d2997f39"
 | 
			
		||||
  integrity sha512-Bk9cOLSz8DiurcMETZ8E2YtIVJbFCPGW28DJWUakmyVWtQSm6Wsf0p4B4BfEr/eL2Nkhe/CICiUiMOCi1TPhuQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    regenerator-transform "^0.14.2"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -810,16 +796,16 @@
 | 
			
		|||
  dependencies:
 | 
			
		||||
    "@babel/helper-plugin-utils" "^7.12.13"
 | 
			
		||||
 | 
			
		||||
"@babel/plugin-transform-runtime@^7.13.10":
 | 
			
		||||
  version "7.13.10"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.13.10.tgz#a1e40d22e2bf570c591c9c7e5ab42d6bf1e419e1"
 | 
			
		||||
  integrity sha512-Y5k8ipgfvz5d/76tx7JYbKQTcgFSU6VgJ3kKQv4zGTKr+a9T/KBvfRvGtSFgKDQGt/DBykQixV0vNWKIdzWErA==
 | 
			
		||||
"@babel/plugin-transform-runtime@^7.13.15":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.13.15.tgz#2eddf585dd066b84102517e10a577f24f76a9cd7"
 | 
			
		||||
  integrity sha512-d+ezl76gx6Jal08XngJUkXM4lFXK/5Ikl9Mh4HKDxSfGJXmZ9xG64XT2oivBzfxb/eQ62VfvoMkaCZUKJMVrBA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/helper-module-imports" "^7.12.13"
 | 
			
		||||
    "@babel/helper-module-imports" "^7.13.12"
 | 
			
		||||
    "@babel/helper-plugin-utils" "^7.13.0"
 | 
			
		||||
    babel-plugin-polyfill-corejs2 "^0.1.4"
 | 
			
		||||
    babel-plugin-polyfill-corejs3 "^0.1.3"
 | 
			
		||||
    babel-plugin-polyfill-regenerator "^0.1.2"
 | 
			
		||||
    babel-plugin-polyfill-corejs2 "^0.2.0"
 | 
			
		||||
    babel-plugin-polyfill-corejs3 "^0.2.0"
 | 
			
		||||
    babel-plugin-polyfill-regenerator "^0.2.0"
 | 
			
		||||
    semver "^6.3.0"
 | 
			
		||||
 | 
			
		||||
"@babel/plugin-transform-shorthand-properties@^7.12.13":
 | 
			
		||||
| 
						 | 
				
			
			@ -873,17 +859,17 @@
 | 
			
		|||
    "@babel/helper-create-regexp-features-plugin" "^7.12.13"
 | 
			
		||||
    "@babel/helper-plugin-utils" "^7.12.13"
 | 
			
		||||
 | 
			
		||||
"@babel/preset-env@^7.13.12":
 | 
			
		||||
  version "7.13.12"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.13.12.tgz#6dff470478290582ac282fb77780eadf32480237"
 | 
			
		||||
  integrity sha512-JzElc6jk3Ko6zuZgBtjOd01pf9yYDEIH8BcqVuYIuOkzOwDesoa/Nz4gIo4lBG6K861KTV9TvIgmFuT6ytOaAA==
 | 
			
		||||
"@babel/preset-env@^7.13.15":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.13.15.tgz#c8a6eb584f96ecba183d3d414a83553a599f478f"
 | 
			
		||||
  integrity sha512-D4JAPMXcxk69PKe81jRJ21/fP/uYdcTZ3hJDF5QX2HSI9bBxxYw/dumdR6dGumhjxlprHPE4XWoPaqzZUVy2MA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/compat-data" "^7.13.12"
 | 
			
		||||
    "@babel/helper-compilation-targets" "^7.13.10"
 | 
			
		||||
    "@babel/compat-data" "^7.13.15"
 | 
			
		||||
    "@babel/helper-compilation-targets" "^7.13.13"
 | 
			
		||||
    "@babel/helper-plugin-utils" "^7.13.0"
 | 
			
		||||
    "@babel/helper-validator-option" "^7.12.17"
 | 
			
		||||
    "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.13.12"
 | 
			
		||||
    "@babel/plugin-proposal-async-generator-functions" "^7.13.8"
 | 
			
		||||
    "@babel/plugin-proposal-async-generator-functions" "^7.13.15"
 | 
			
		||||
    "@babel/plugin-proposal-class-properties" "^7.13.0"
 | 
			
		||||
    "@babel/plugin-proposal-dynamic-import" "^7.13.8"
 | 
			
		||||
    "@babel/plugin-proposal-export-namespace-from" "^7.12.13"
 | 
			
		||||
| 
						 | 
				
			
			@ -931,7 +917,7 @@
 | 
			
		|||
    "@babel/plugin-transform-object-super" "^7.12.13"
 | 
			
		||||
    "@babel/plugin-transform-parameters" "^7.13.0"
 | 
			
		||||
    "@babel/plugin-transform-property-literals" "^7.12.13"
 | 
			
		||||
    "@babel/plugin-transform-regenerator" "^7.12.13"
 | 
			
		||||
    "@babel/plugin-transform-regenerator" "^7.13.15"
 | 
			
		||||
    "@babel/plugin-transform-reserved-words" "^7.12.13"
 | 
			
		||||
    "@babel/plugin-transform-shorthand-properties" "^7.12.13"
 | 
			
		||||
    "@babel/plugin-transform-spread" "^7.13.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -941,10 +927,10 @@
 | 
			
		|||
    "@babel/plugin-transform-unicode-escapes" "^7.12.13"
 | 
			
		||||
    "@babel/plugin-transform-unicode-regex" "^7.12.13"
 | 
			
		||||
    "@babel/preset-modules" "^0.1.4"
 | 
			
		||||
    "@babel/types" "^7.13.12"
 | 
			
		||||
    babel-plugin-polyfill-corejs2 "^0.1.4"
 | 
			
		||||
    babel-plugin-polyfill-corejs3 "^0.1.3"
 | 
			
		||||
    babel-plugin-polyfill-regenerator "^0.1.2"
 | 
			
		||||
    "@babel/types" "^7.13.14"
 | 
			
		||||
    babel-plugin-polyfill-corejs2 "^0.2.0"
 | 
			
		||||
    babel-plugin-polyfill-corejs3 "^0.2.0"
 | 
			
		||||
    babel-plugin-polyfill-regenerator "^0.2.0"
 | 
			
		||||
    core-js-compat "^3.9.0"
 | 
			
		||||
    semver "^6.3.0"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -1002,21 +988,21 @@
 | 
			
		|||
    "@babel/parser" "^7.12.13"
 | 
			
		||||
    "@babel/types" "^7.12.13"
 | 
			
		||||
 | 
			
		||||
"@babel/traverse@^7.1.0", "@babel/traverse@^7.12.13", "@babel/traverse@^7.13.0", "@babel/traverse@^7.13.13", "@babel/traverse@^7.7.0":
 | 
			
		||||
  version "7.13.13"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.13.tgz#39aa9c21aab69f74d948a486dd28a2dbdbf5114d"
 | 
			
		||||
  integrity sha512-CblEcwmXKR6eP43oQGG++0QMTtCjAsa3frUuzHoiIJWpaIIi8dwMyEFUJoXRLxagGqCK+jALRwIO+o3R9p/uUg==
 | 
			
		||||
"@babel/traverse@^7.1.0", "@babel/traverse@^7.12.13", "@babel/traverse@^7.13.0", "@babel/traverse@^7.13.13", "@babel/traverse@^7.13.15", "@babel/traverse@^7.7.0":
 | 
			
		||||
  version "7.13.15"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.13.15.tgz#c38bf7679334ddd4028e8e1f7b3aa5019f0dada7"
 | 
			
		||||
  integrity sha512-/mpZMNvj6bce59Qzl09fHEs8Bt8NnpEDQYleHUPZQ3wXUMvXi+HJPLars68oAbmp839fGoOkv2pSL2z9ajCIaQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/code-frame" "^7.12.13"
 | 
			
		||||
    "@babel/generator" "^7.13.9"
 | 
			
		||||
    "@babel/helper-function-name" "^7.12.13"
 | 
			
		||||
    "@babel/helper-split-export-declaration" "^7.12.13"
 | 
			
		||||
    "@babel/parser" "^7.13.13"
 | 
			
		||||
    "@babel/types" "^7.13.13"
 | 
			
		||||
    "@babel/parser" "^7.13.15"
 | 
			
		||||
    "@babel/types" "^7.13.14"
 | 
			
		||||
    debug "^4.1.0"
 | 
			
		||||
    globals "^11.1.0"
 | 
			
		||||
 | 
			
		||||
"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.10.4", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.13.0", "@babel/types@^7.13.12", "@babel/types@^7.13.13", "@babel/types@^7.13.14", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
 | 
			
		||||
"@babel/types@^7.0.0", "@babel/types@^7.0.0-beta.49", "@babel/types@^7.10.4", "@babel/types@^7.12.1", "@babel/types@^7.12.13", "@babel/types@^7.13.0", "@babel/types@^7.13.12", "@babel/types@^7.13.14", "@babel/types@^7.3.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0":
 | 
			
		||||
  version "7.13.14"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.14.tgz#c35a4abb15c7cd45a2746d78ab328e362cbace0d"
 | 
			
		||||
  integrity sha512-A2aa3QTkWoyqsZZFl56MLUsfmh7O0gN41IPvXAE/++8ojpbz12SszD7JEGYVdn4f9Kt4amIei07swF1h4AqmmQ==
 | 
			
		||||
| 
						 | 
				
			
			@ -1025,15 +1011,6 @@
 | 
			
		|||
    lodash "^4.17.19"
 | 
			
		||||
    to-fast-properties "^2.0.0"
 | 
			
		||||
 | 
			
		||||
"@babel/types@^7.12.5":
 | 
			
		||||
  version "7.13.13"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.13.13.tgz#dcd8b815b38f537a3697ce84c8e3cc62197df96f"
 | 
			
		||||
  integrity sha512-kt+EpC6qDfIaqlP+DIbIJOclYy/A1YXs9dAf/ljbi+39Bcbc073H6jKVpXEr/EoIh5anGn5xq/yRVzKl+uIc9w==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/helper-validator-identifier" "^7.12.11"
 | 
			
		||||
    lodash "^4.17.19"
 | 
			
		||||
    to-fast-properties "^2.0.0"
 | 
			
		||||
 | 
			
		||||
"@bcoe/v8-coverage@^0.2.3":
 | 
			
		||||
  version "0.2.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39"
 | 
			
		||||
| 
						 | 
				
			
			@ -2287,29 +2264,29 @@ babel-plugin-macros@^2.8.0:
 | 
			
		|||
    cosmiconfig "^6.0.0"
 | 
			
		||||
    resolve "^1.12.0"
 | 
			
		||||
 | 
			
		||||
babel-plugin-polyfill-corejs2@^0.1.4:
 | 
			
		||||
  version "0.1.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.1.5.tgz#8fc4779965311393594a1b9ad3adefab3860c8fe"
 | 
			
		||||
  integrity sha512-5IzdFIjYWqlOFVr/hMYUpc+5fbfuvJTAISwIY58jhH++ZtawtNlcJnxAixlk8ahVwHCz1ipW/kpXYliEBp66wg==
 | 
			
		||||
babel-plugin-polyfill-corejs2@^0.2.0:
 | 
			
		||||
  version "0.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.2.0.tgz#686775bf9a5aa757e10520903675e3889caeedc4"
 | 
			
		||||
  integrity sha512-9bNwiR0dS881c5SHnzCmmGlMkJLl0OUZvxrxHo9w/iNoRuqaPjqlvBf4HrovXtQs/au5yKkpcdgfT1cC5PAZwg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/compat-data" "^7.13.0"
 | 
			
		||||
    "@babel/helper-define-polyfill-provider" "^0.1.2"
 | 
			
		||||
    "@babel/compat-data" "^7.13.11"
 | 
			
		||||
    "@babel/helper-define-polyfill-provider" "^0.2.0"
 | 
			
		||||
    semver "^6.1.1"
 | 
			
		||||
 | 
			
		||||
babel-plugin-polyfill-corejs3@^0.1.3:
 | 
			
		||||
  version "0.1.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.1.4.tgz#2ae290200e953bade30907b7a3bebcb696e6c59d"
 | 
			
		||||
  integrity sha512-ysSzFn/qM8bvcDAn4mC7pKk85Y5dVaoa9h4u0mHxOEpDzabsseONhUpR7kHxpUinfj1bjU7mUZqD23rMZBoeSg==
 | 
			
		||||
babel-plugin-polyfill-corejs3@^0.2.0:
 | 
			
		||||
  version "0.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.2.0.tgz#f4b4bb7b19329827df36ff56f6e6d367026cb7a2"
 | 
			
		||||
  integrity sha512-zZyi7p3BCUyzNxLx8KV61zTINkkV65zVkDAFNZmrTCRVhjo1jAS+YLvDJ9Jgd/w2tsAviCwFHReYfxO3Iql8Yg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/helper-define-polyfill-provider" "^0.1.2"
 | 
			
		||||
    core-js-compat "^3.8.1"
 | 
			
		||||
    "@babel/helper-define-polyfill-provider" "^0.2.0"
 | 
			
		||||
    core-js-compat "^3.9.1"
 | 
			
		||||
 | 
			
		||||
babel-plugin-polyfill-regenerator@^0.1.2:
 | 
			
		||||
  version "0.1.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.1.3.tgz#350f857225fc640ae1ec78d1536afcbb457db841"
 | 
			
		||||
  integrity sha512-hRjTJQiOYt/wBKEc+8V8p9OJ9799blAJcuKzn1JXh3pApHoWl1Emxh2BHc6MC7Qt6bbr3uDpNxaYQnATLIudEg==
 | 
			
		||||
babel-plugin-polyfill-regenerator@^0.2.0:
 | 
			
		||||
  version "0.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.2.0.tgz#853f5f5716f4691d98c84f8069c7636ea8da7ab8"
 | 
			
		||||
  integrity sha512-J7vKbCuD2Xi/eEHxquHN14bXAW9CXtecwuLrOIDJtcZzTaPzV1VdEfoUf9AzcRBMolKUQKM9/GVojeh0hFiqMg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/helper-define-polyfill-provider" "^0.1.2"
 | 
			
		||||
    "@babel/helper-define-polyfill-provider" "^0.2.0"
 | 
			
		||||
 | 
			
		||||
babel-plugin-preval@^5.0.0:
 | 
			
		||||
  version "5.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -2882,10 +2859,10 @@ char-regex@^1.0.2:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
 | 
			
		||||
  integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==
 | 
			
		||||
 | 
			
		||||
"chokidar@>=2.0.0 <4.0.0", chokidar@^3.4.1:
 | 
			
		||||
  version "3.4.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.4.1.tgz#e905bdecf10eaa0a0b1db0c664481cc4cbc22ba1"
 | 
			
		||||
  integrity sha512-TQTJyr2stihpC4Sya9hs2Xh+O2wf+igjL36Y75xx2WdHuiICcn/XJza46Jwt0eT5hVpQOzo3FpY3cj3RVYLX0g==
 | 
			
		||||
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1:
 | 
			
		||||
  version "3.5.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.1.tgz#ee9ce7bbebd2b79f49f304799d5468e31e14e68a"
 | 
			
		||||
  integrity sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    anymatch "~3.1.1"
 | 
			
		||||
    braces "~3.0.2"
 | 
			
		||||
| 
						 | 
				
			
			@ -2893,9 +2870,9 @@ char-regex@^1.0.2:
 | 
			
		|||
    is-binary-path "~2.1.0"
 | 
			
		||||
    is-glob "~4.0.1"
 | 
			
		||||
    normalize-path "~3.0.0"
 | 
			
		||||
    readdirp "~3.4.0"
 | 
			
		||||
    readdirp "~3.5.0"
 | 
			
		||||
  optionalDependencies:
 | 
			
		||||
    fsevents "~2.1.2"
 | 
			
		||||
    fsevents "~2.3.1"
 | 
			
		||||
 | 
			
		||||
chokidar@^2.1.8:
 | 
			
		||||
  version "2.1.8"
 | 
			
		||||
| 
						 | 
				
			
			@ -2966,10 +2943,10 @@ class-utils@^0.3.5:
 | 
			
		|||
    isobject "^3.0.0"
 | 
			
		||||
    static-extend "^0.1.1"
 | 
			
		||||
 | 
			
		||||
classnames@^2.2.5:
 | 
			
		||||
  version "2.2.6"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.2.6.tgz#43935bffdd291f326dad0a205309b38d00f650ce"
 | 
			
		||||
  integrity sha512-JR/iSQOSt+LQIWwrwEzJ9uk0xfN3mTVYMwt1Ir5mUcSN6pU+V4zQFFaJsclJbPuAUQH+yfWef6tm7l1quW3C8Q==
 | 
			
		||||
classnames@^2.2.5, classnames@^2.3.1:
 | 
			
		||||
  version "2.3.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.1.tgz#dfcfa3891e306ec1dad105d0e88f4417b8535e8e"
 | 
			
		||||
  integrity sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==
 | 
			
		||||
 | 
			
		||||
clean-stack@^2.0.0:
 | 
			
		||||
  version "2.2.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -3255,10 +3232,10 @@ copy-descriptor@^0.1.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/copy-descriptor/-/copy-descriptor-0.1.1.tgz#676f6eb3c39997c2ee1ac3a924fd6124748f578d"
 | 
			
		||||
  integrity sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=
 | 
			
		||||
 | 
			
		||||
core-js-compat@^3.8.1, core-js-compat@^3.9.0:
 | 
			
		||||
  version "3.9.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.9.0.tgz#29da39385f16b71e1915565aa0385c4e0963ad56"
 | 
			
		||||
  integrity sha512-YK6fwFjCOKWwGnjFUR3c544YsnA/7DoLL0ysncuOJ4pwbriAtOpvM2bygdlcXbvQCQZ7bBU9CL4t7tGl7ETRpQ==
 | 
			
		||||
core-js-compat@^3.9.0, core-js-compat@^3.9.1:
 | 
			
		||||
  version "3.10.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.10.1.tgz#62183a3a77ceeffcc420d907a3e6fc67d9b27f1c"
 | 
			
		||||
  integrity sha512-ZHQTdTPkqvw2CeHiZC970NNJcnwzT6YIueDMASKt+p3WbZsLXOcoD392SkcWhkC0wBBHhlfhqGKKsNCQUozYtg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    browserslist "^4.16.3"
 | 
			
		||||
    semver "7.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -3424,23 +3401,22 @@ css-list-helpers@^1.0.1:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    tcomb "^2.5.0"
 | 
			
		||||
 | 
			
		||||
css-loader@^5.2.0:
 | 
			
		||||
  version "5.2.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-5.2.0.tgz#a9ecda190500863673ce4434033710404efbff00"
 | 
			
		||||
  integrity sha512-MfRo2MjEeLXMlUkeUwN71Vx5oc6EJnx5UQ4Yi9iUtYQvrPtwLUucYptz0hc6n++kdNcyF5olYBS4vPjJDAcLkw==
 | 
			
		||||
css-loader@^5.2.2:
 | 
			
		||||
  version "5.2.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-5.2.2.tgz#65f2c1482255f15847ecad6cbc515cae8a5b234e"
 | 
			
		||||
  integrity sha512-IS722y7Lh2Yq+acMR74tdf3faMOLRP2RfLwS0VzSS7T98IHtacMWJLku3A0OBTFHB07zAa4nWBhA8gfxwQVWGQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    camelcase "^6.2.0"
 | 
			
		||||
    cssesc "^3.0.0"
 | 
			
		||||
    icss-utils "^5.1.0"
 | 
			
		||||
    loader-utils "^2.0.0"
 | 
			
		||||
    postcss "^8.2.8"
 | 
			
		||||
    postcss "^8.2.10"
 | 
			
		||||
    postcss-modules-extract-imports "^3.0.0"
 | 
			
		||||
    postcss-modules-local-by-default "^4.0.0"
 | 
			
		||||
    postcss-modules-scope "^3.0.0"
 | 
			
		||||
    postcss-modules-values "^4.0.0"
 | 
			
		||||
    postcss-value-parser "^4.1.0"
 | 
			
		||||
    schema-utils "^3.0.0"
 | 
			
		||||
    semver "^7.3.4"
 | 
			
		||||
    semver "^7.3.5"
 | 
			
		||||
 | 
			
		||||
css-select-base-adapter@^0.1.1:
 | 
			
		||||
  version "0.1.1"
 | 
			
		||||
| 
						 | 
				
			
			@ -3502,10 +3478,10 @@ cssesc@^3.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
 | 
			
		||||
  integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
 | 
			
		||||
 | 
			
		||||
cssnano-preset-default@^4.0.7:
 | 
			
		||||
  version "4.0.7"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.7.tgz#51ec662ccfca0f88b396dcd9679cdb931be17f76"
 | 
			
		||||
  integrity sha512-x0YHHx2h6p0fCl1zY9L9roD7rnlltugGu7zXSKQx6k2rYw0Hi3IqxcoAGF7u9Q5w1nt7vK0ulxV8Lo+EvllGsA==
 | 
			
		||||
cssnano-preset-default@^4.0.8:
 | 
			
		||||
  version "4.0.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-4.0.8.tgz#920622b1fc1e95a34e8838203f1397a504f2d3ff"
 | 
			
		||||
  integrity sha512-LdAyHuq+VRyeVREFmuxUZR1TXjQm8QQU/ktoo/x7bz+SdOge1YKc5eMN6pRW7YWBmyq59CqYba1dJ5cUukEjLQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    css-declaration-sorter "^4.0.1"
 | 
			
		||||
    cssnano-util-raw-cache "^4.0.1"
 | 
			
		||||
| 
						 | 
				
			
			@ -3535,7 +3511,7 @@ cssnano-preset-default@^4.0.7:
 | 
			
		|||
    postcss-ordered-values "^4.1.2"
 | 
			
		||||
    postcss-reduce-initial "^4.0.3"
 | 
			
		||||
    postcss-reduce-transforms "^4.0.2"
 | 
			
		||||
    postcss-svgo "^4.0.2"
 | 
			
		||||
    postcss-svgo "^4.0.3"
 | 
			
		||||
    postcss-unique-selectors "^4.0.1"
 | 
			
		||||
 | 
			
		||||
cssnano-util-get-arguments@^4.0.0:
 | 
			
		||||
| 
						 | 
				
			
			@ -3560,13 +3536,13 @@ cssnano-util-same-parent@^4.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/cssnano-util-same-parent/-/cssnano-util-same-parent-4.0.1.tgz#574082fb2859d2db433855835d9a8456ea18bbf3"
 | 
			
		||||
  integrity sha512-WcKx5OY+KoSIAxBW6UBBRay1U6vkYheCdjyVNDm85zt5K9mHoGOfsOsqIszfAqrQQFIIKgjh2+FDgIj/zsl21Q==
 | 
			
		||||
 | 
			
		||||
cssnano@^4.1.10:
 | 
			
		||||
  version "4.1.10"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.10.tgz#0ac41f0b13d13d465487e111b778d42da631b8b2"
 | 
			
		||||
  integrity sha512-5wny+F6H4/8RgNlaqab4ktc3e0/blKutmq8yNlBFXA//nSFFAqAngjNVRzUvCgYROULmZZUoosL/KSoZo5aUaQ==
 | 
			
		||||
cssnano@^4.1.11:
 | 
			
		||||
  version "4.1.11"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-4.1.11.tgz#c7b5f5b81da269cb1fd982cb960c1200910c9a99"
 | 
			
		||||
  integrity sha512-6gZm2htn7xIPJOHY824ERgj8cNPgPxyCSnkXc4v7YvNW+TdVfzgngHcEhy/8D11kUWRUMbke+tC+AUcUsnMz2g==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    cosmiconfig "^5.0.0"
 | 
			
		||||
    cssnano-preset-default "^4.0.7"
 | 
			
		||||
    cssnano-preset-default "^4.0.8"
 | 
			
		||||
    is-resolvable "^1.0.0"
 | 
			
		||||
    postcss "^7.0.0"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -3761,10 +3737,10 @@ delegates@^1.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
 | 
			
		||||
  integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
 | 
			
		||||
 | 
			
		||||
denque@^1.4.1:
 | 
			
		||||
  version "1.4.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/denque/-/denque-1.4.1.tgz#6744ff7641c148c3f8a69c307e51235c1f4a37cf"
 | 
			
		||||
  integrity sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ==
 | 
			
		||||
denque@^1.5.0:
 | 
			
		||||
  version "1.5.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/denque/-/denque-1.5.0.tgz#773de0686ff2d8ec2ff92914316a47b73b1c73de"
 | 
			
		||||
  integrity sha512-CYiCSgIF1p6EUByQPlGkKnP1M9g0ZV3qMIrqMqZqdwazygIA/YP2vrbcyl1h/WppKJTdl1F85cXIle+394iDAQ==
 | 
			
		||||
 | 
			
		||||
depd@~1.1.2:
 | 
			
		||||
  version "1.1.2"
 | 
			
		||||
| 
						 | 
				
			
			@ -4298,15 +4274,15 @@ eslint-plugin-jsx-a11y@~6.4.1:
 | 
			
		|||
    jsx-ast-utils "^3.1.0"
 | 
			
		||||
    language-tags "^1.0.5"
 | 
			
		||||
 | 
			
		||||
eslint-plugin-promise@~4.3.1:
 | 
			
		||||
  version "4.3.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-4.3.1.tgz#61485df2a359e03149fdafc0a68b0e030ad2ac45"
 | 
			
		||||
  integrity sha512-bY2sGqyptzFBDLh/GMbAxfdJC+b0f23ME63FOE4+Jao0oZ3E1LEwFtWJX/1pGMJLiTtrSSern2CRM/g+dfc0eQ==
 | 
			
		||||
eslint-plugin-promise@~5.1.0:
 | 
			
		||||
  version "5.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/eslint-plugin-promise/-/eslint-plugin-promise-5.1.0.tgz#fb2188fb734e4557993733b41aa1a688f46c6f24"
 | 
			
		||||
  integrity sha512-NGmI6BH5L12pl7ScQHbg7tvtk4wPxxj8yPHH47NvSmMtFneC077PSeY3huFj06ZWZvtbfxSPt3RuOQD5XcR4ng==
 | 
			
		||||
 | 
			
		||||
eslint-plugin-react@~7.23.1:
 | 
			
		||||
  version "7.23.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.23.1.tgz#f1a2e844c0d1967c822388204a8bc4dee8415b11"
 | 
			
		||||
  integrity sha512-MvFGhZjI8Z4HusajmSw0ougGrq3Gs4vT/0WgwksZgf5RrLrRa2oYAw56okU4tZJl8+j7IYNuTM+2RnFEuTSdRQ==
 | 
			
		||||
eslint-plugin-react@~7.23.2:
 | 
			
		||||
  version "7.23.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.23.2.tgz#2d2291b0f95c03728b55869f01102290e792d494"
 | 
			
		||||
  integrity sha512-AfjgFQB+nYszudkxRkTFu0UR1zEQig0ArVMPloKhxwlwkzaw/fBiH0QWcBBhZONlXqQC51+nfqFrkn4EzHcGBw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    array-includes "^3.1.3"
 | 
			
		||||
    array.prototype.flatmap "^1.2.4"
 | 
			
		||||
| 
						 | 
				
			
			@ -4393,10 +4369,10 @@ eslint@^2.7.0:
 | 
			
		|||
    text-table "~0.2.0"
 | 
			
		||||
    user-home "^2.0.0"
 | 
			
		||||
 | 
			
		||||
eslint@^7.23.0:
 | 
			
		||||
  version "7.23.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.23.0.tgz#8d029d252f6e8cf45894b4bee08f5493f8e94325"
 | 
			
		||||
  integrity sha512-kqvNVbdkjzpFy0XOszNwjkKzZ+6TcwCQ/h+ozlcIWwaimBBuhlQ4nN6kbiM2L+OjDcznkTJxzYfRFH92sx4a0Q==
 | 
			
		||||
eslint@^7.24.0:
 | 
			
		||||
  version "7.24.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.24.0.tgz#2e44fa62d93892bfdb100521f17345ba54b8513a"
 | 
			
		||||
  integrity sha512-k9gaHeHiFmGCDQ2rEfvULlSLruz6tgfA8DEn+rY9/oYPFFTlz55mM/Q/Rij1b2Y42jwZiK3lXvNTw6w6TXzcKQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    "@babel/code-frame" "7.12.11"
 | 
			
		||||
    "@eslint/eslintrc" "^0.4.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -4992,11 +4968,16 @@ fsevents@^1.2.7:
 | 
			
		|||
    bindings "^1.5.0"
 | 
			
		||||
    nan "^2.12.1"
 | 
			
		||||
 | 
			
		||||
fsevents@^2.1.2, fsevents@~2.1.2:
 | 
			
		||||
fsevents@^2.1.2:
 | 
			
		||||
  version "2.1.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.1.3.tgz#fb738703ae8d2f9fe900c33836ddebee8b97f23e"
 | 
			
		||||
  integrity sha512-Auw9a4AxqWpa9GUfj370BMPzzyncfBABW8Mab7BGWBYDj4Isgq+cDKtx0i6u9jcX9pQDnswsaaOTgTmA5pEjuQ==
 | 
			
		||||
 | 
			
		||||
fsevents@~2.3.1:
 | 
			
		||||
  version "2.3.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a"
 | 
			
		||||
  integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==
 | 
			
		||||
 | 
			
		||||
function-bind@^1.1.1:
 | 
			
		||||
  version "1.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
 | 
			
		||||
| 
						 | 
				
			
			@ -5402,11 +5383,6 @@ hsla-regex@^1.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/hsla-regex/-/hsla-regex-1.0.0.tgz#c1ce7a3168c8c6614033a4b5f7877f3b225f9c38"
 | 
			
		||||
  integrity sha1-wc56MWjIxmFAM6S194d/OyJfnDg=
 | 
			
		||||
 | 
			
		||||
html-comment-regex@^1.1.0:
 | 
			
		||||
  version "1.1.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/html-comment-regex/-/html-comment-regex-1.1.2.tgz#97d4688aeb5c81886a364faa0cad1dda14d433a7"
 | 
			
		||||
  integrity sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==
 | 
			
		||||
 | 
			
		||||
html-encoding-sniffer@^2.0.1:
 | 
			
		||||
  version "2.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz#42a6dc4fd33f00281176e8b23759ca4e4fa185f3"
 | 
			
		||||
| 
						 | 
				
			
			@ -6074,13 +6050,6 @@ is-string@^1.0.5:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.5.tgz#40493ed198ef3ff477b8c7f92f644ec82a5cd3a6"
 | 
			
		||||
  integrity sha512-buY6VNRjhQMiF1qWDouloZlQbRhDPCebwxSjxMjxgemYT46YMd2NR0/H+fBhEfWX4A/w9TBJ+ol+okqJKFE6vQ==
 | 
			
		||||
 | 
			
		||||
is-svg@^3.0.0:
 | 
			
		||||
  version "3.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/is-svg/-/is-svg-3.0.0.tgz#9321dbd29c212e5ca99c4fa9794c714bcafa2f75"
 | 
			
		||||
  integrity sha512-gi4iHK53LR2ujhLVVj+37Ykh9GLqYHX6JOVXbLAucaG/Cqw9xwdFOjDM2qeifLs1sF1npXXFvDu0r5HNgCMrzQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    html-comment-regex "^1.1.0"
 | 
			
		||||
 | 
			
		||||
is-symbol@^1.0.2:
 | 
			
		||||
  version "1.0.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.3.tgz#38e1014b9e6329be0de9d24a414fd7441ec61937"
 | 
			
		||||
| 
						 | 
				
			
			@ -6603,10 +6572,10 @@ js-yaml@^3.13.1, js-yaml@^3.4.6, js-yaml@^3.5.1, js-yaml@^3.5.4:
 | 
			
		|||
    argparse "^1.0.7"
 | 
			
		||||
    esprima "^4.0.0"
 | 
			
		||||
 | 
			
		||||
js-yaml@^4.0.0:
 | 
			
		||||
  version "4.0.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.0.0.tgz#f426bc0ff4b4051926cd588c71113183409a121f"
 | 
			
		||||
  integrity sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==
 | 
			
		||||
js-yaml@^4.1.0:
 | 
			
		||||
  version "4.1.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
 | 
			
		||||
  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    argparse "^2.0.1"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -6916,11 +6885,6 @@ lodash.defaults@^4.0.1:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
 | 
			
		||||
  integrity sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=
 | 
			
		||||
 | 
			
		||||
lodash.escaperegexp@^4.0:
 | 
			
		||||
  version "4.1.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347"
 | 
			
		||||
  integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c=
 | 
			
		||||
 | 
			
		||||
lodash.get@^4.0:
 | 
			
		||||
  version "4.4.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
 | 
			
		||||
| 
						 | 
				
			
			@ -7183,10 +7147,10 @@ min-indent@^1.0.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869"
 | 
			
		||||
  integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==
 | 
			
		||||
 | 
			
		||||
mini-css-extract-plugin@^1.4.0:
 | 
			
		||||
  version "1.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.4.0.tgz#c8e571c4b6d63afa56c47260343adf623349c473"
 | 
			
		||||
  integrity sha512-DyQr5DhXXARKZoc4kwvCvD95kh69dUupfuKOmBUqZ4kBTmRaRZcU32lYu3cLd6nEGXhQ1l7LzZ3F/CjItaY6VQ==
 | 
			
		||||
mini-css-extract-plugin@^1.5.0:
 | 
			
		||||
  version "1.5.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-1.5.0.tgz#69bee3b273d2d4ee8649a2eb409514b7df744a27"
 | 
			
		||||
  integrity sha512-SIbuLMv6jsk1FnLIU5OUG/+VMGUprEjM1+o2trOAx8i5KOKMrhyezb1dJ4Ugsykb8Jgq8/w5NEopy6escV9G7g==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    loader-utils "^2.0.0"
 | 
			
		||||
    schema-utils "^3.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -7346,10 +7310,10 @@ nan@^2.12.1:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
 | 
			
		||||
  integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
 | 
			
		||||
 | 
			
		||||
nanoid@^3.1.20:
 | 
			
		||||
  version "3.1.20"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.20.tgz#badc263c6b1dcf14b71efaa85f6ab4c1d6cfc788"
 | 
			
		||||
  integrity sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==
 | 
			
		||||
nanoid@^3.1.22:
 | 
			
		||||
  version "3.1.22"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.1.22.tgz#b35f8fb7d151990a8aebd5aa5015c03cf726f844"
 | 
			
		||||
  integrity sha512-/2ZUaJX2ANuLtTvqTlgqBQNJoQO398KyJgZloL0PZkC0dpysjncRUPsFe3DUPzz/y3h+u7C46np8RMuvF3jsSQ==
 | 
			
		||||
 | 
			
		||||
nanomatch@^1.2.9:
 | 
			
		||||
  version "1.2.13"
 | 
			
		||||
| 
						 | 
				
			
			@ -8485,12 +8449,11 @@ postcss-selector-parser@^6.0.4:
 | 
			
		|||
    uniq "^1.0.1"
 | 
			
		||||
    util-deprecate "^1.0.2"
 | 
			
		||||
 | 
			
		||||
postcss-svgo@^4.0.2:
 | 
			
		||||
  version "4.0.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.2.tgz#17b997bc711b333bab143aaed3b8d3d6e3d38258"
 | 
			
		||||
  integrity sha512-C6wyjo3VwFm0QgBy+Fu7gCYOkCmgmClghO+pjcxvrcBKtiKt0uCF+hvbMO1fyv5BMImRK90SMb+dwUnfbGd+jw==
 | 
			
		||||
postcss-svgo@^4.0.3:
 | 
			
		||||
  version "4.0.3"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-4.0.3.tgz#343a2cdbac9505d416243d496f724f38894c941e"
 | 
			
		||||
  integrity sha512-NoRbrcMWTtUghzuKSoIm6XV+sJdvZ7GZSc3wdBN0W19FTtp2ko8NqLsgoh/m9CzNhU3KLPvQmjIwtaNFkaFTvw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    is-svg "^3.0.0"
 | 
			
		||||
    postcss "^7.0.0"
 | 
			
		||||
    postcss-value-parser "^3.0.0"
 | 
			
		||||
    svgo "^1.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -8533,13 +8496,13 @@ postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.27, postcss@^7.0.32:
 | 
			
		|||
    source-map "^0.6.1"
 | 
			
		||||
    supports-color "^6.1.0"
 | 
			
		||||
 | 
			
		||||
postcss@^8.2.8:
 | 
			
		||||
  version "8.2.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.8.tgz#0b90f9382efda424c4f0f69a2ead6f6830d08ece"
 | 
			
		||||
  integrity sha512-1F0Xb2T21xET7oQV9eKuctbM9S7BC0fetoHCc4H13z0PT6haiRLP4T0ZY4XWh7iLP0usgqykT6p9B2RtOf4FPw==
 | 
			
		||||
postcss@^8.2.10:
 | 
			
		||||
  version "8.2.10"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.2.10.tgz#ca7a042aa8aff494b334d0ff3e9e77079f6f702b"
 | 
			
		||||
  integrity sha512-b/h7CPV7QEdrqIxtAf2j31U5ef05uBDuvoXv6L51Q4rcS1jdlXAVKJv+atCFdUXYl9dyTHGyoMzIepwowRJjFw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    colorette "^1.2.2"
 | 
			
		||||
    nanoid "^3.1.20"
 | 
			
		||||
    nanoid "^3.1.22"
 | 
			
		||||
    source-map "^0.6.1"
 | 
			
		||||
 | 
			
		||||
postgres-array@~2.0.0:
 | 
			
		||||
| 
						 | 
				
			
			@ -9150,10 +9113,10 @@ readdirp@^2.2.1:
 | 
			
		|||
    micromatch "^3.1.10"
 | 
			
		||||
    readable-stream "^2.0.2"
 | 
			
		||||
 | 
			
		||||
readdirp@~3.4.0:
 | 
			
		||||
  version "3.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.4.0.tgz#9fdccdf9e9155805449221ac645e8303ab5b9ada"
 | 
			
		||||
  integrity sha512-0xe001vZBnJEK+uKcj8qOhyAKPzIT+gStxWr3LCB0DwcXR5NZJ3IaC+yGnHCYzB/S7ov3m3EEbZI2zeNvX+hGQ==
 | 
			
		||||
readdirp@~3.5.0:
 | 
			
		||||
  version "3.5.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.5.0.tgz#9ba74c019b15d365278d2e91bb8c48d7b4d42c9e"
 | 
			
		||||
  integrity sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    picomatch "^2.2.1"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -9174,10 +9137,10 @@ redent@^3.0.0:
 | 
			
		|||
    indent-string "^4.0.0"
 | 
			
		||||
    strip-indent "^3.0.0"
 | 
			
		||||
 | 
			
		||||
redis-commands@^1.5.0:
 | 
			
		||||
  version "1.6.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.6.0.tgz#36d4ca42ae9ed29815cdb30ad9f97982eba1ce23"
 | 
			
		||||
  integrity sha512-2jnZ0IkjZxvguITjFTrGiLyzQZcTvaw8DAaCXxZq/dsHXz7KfMQ3OUJy7Tz9vnRtZRVz6VRCPDvruvU8Ts44wQ==
 | 
			
		||||
redis-commands@^1.7.0:
 | 
			
		||||
  version "1.7.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/redis-commands/-/redis-commands-1.7.0.tgz#15a6fea2d58281e27b1cd1acfb4b293e278c3a89"
 | 
			
		||||
  integrity sha512-nJWqw3bTFy21hX/CPKHth6sfhZbdiHP6bTawSgQBlKOVRG7EZkfHbbHwQJnrE4vsQf0CMNE+3gJ4Fmm16vdVlQ==
 | 
			
		||||
 | 
			
		||||
redis-errors@^1.0.0, redis-errors@^1.2.0:
 | 
			
		||||
  version "1.2.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -9191,13 +9154,13 @@ redis-parser@^3.0.0:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    redis-errors "^1.0.0"
 | 
			
		||||
 | 
			
		||||
redis@^3.0.2:
 | 
			
		||||
  version "3.0.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/redis/-/redis-3.0.2.tgz#bd47067b8a4a3e6a2e556e57f71cc82c7360150a"
 | 
			
		||||
  integrity sha512-PNhLCrjU6vKVuMOyFu7oSP296mwBkcE6lrAjruBYG5LgdSqtRBoVQIylrMyVZD/lkF24RSNNatzvYag6HRBHjQ==
 | 
			
		||||
redis@^3.1.1:
 | 
			
		||||
  version "3.1.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/redis/-/redis-3.1.1.tgz#a44bee7c072dcf685e139048d6a1a4d3b00f5d01"
 | 
			
		||||
  integrity sha512-QhkKhOuzhogR1NDJfBD34TQJz2ZJwDhhIC6ZmvpftlmfYShHHQXjjNspAJ+Z2HH5NwSBVYBVganbiZ8bgFMHjg==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    denque "^1.4.1"
 | 
			
		||||
    redis-commands "^1.5.0"
 | 
			
		||||
    denque "^1.5.0"
 | 
			
		||||
    redis-commands "^1.7.0"
 | 
			
		||||
    redis-errors "^1.2.0"
 | 
			
		||||
    redis-parser "^3.0.0"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -9633,12 +9596,12 @@ sass-loader@^10.1.1:
 | 
			
		|||
    schema-utils "^3.0.0"
 | 
			
		||||
    semver "^7.3.2"
 | 
			
		||||
 | 
			
		||||
sass@^1.32.8:
 | 
			
		||||
  version "1.32.8"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.8.tgz#f16a9abd8dc530add8834e506878a2808c037bdc"
 | 
			
		||||
  integrity sha512-Sl6mIeGpzjIUZqvKnKETfMf0iDAswD9TNlv13A7aAF3XZlRPMq4VvJWBC2N2DXbp94MQVdNSFG6LfF/iOXrPHQ==
 | 
			
		||||
sass@^1.32.10:
 | 
			
		||||
  version "1.32.10"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/sass/-/sass-1.32.10.tgz#d40da4e20031b450359ee1c7e69bc8cc89569241"
 | 
			
		||||
  integrity sha512-Nx0pcWoonAkn7CRp0aE/hket1UP97GiR1IFw3kcjV3pnenhWgZEWUf0ZcfPOV2fK52fnOcK3JdC/YYZ9E47DTQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    chokidar ">=2.0.0 <4.0.0"
 | 
			
		||||
    chokidar ">=3.0.0 <4.0.0"
 | 
			
		||||
 | 
			
		||||
sax@~1.2.4:
 | 
			
		||||
  version "1.2.4"
 | 
			
		||||
| 
						 | 
				
			
			@ -9722,10 +9685,10 @@ semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.3.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d"
 | 
			
		||||
  integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==
 | 
			
		||||
 | 
			
		||||
semver@^7.2.1, semver@^7.3.2, semver@^7.3.4:
 | 
			
		||||
  version "7.3.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97"
 | 
			
		||||
  integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==
 | 
			
		||||
semver@^7.2.1, semver@^7.3.2, semver@^7.3.5:
 | 
			
		||||
  version "7.3.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.5.tgz#0b621c879348d8998e4b0e4be94b3f12e6018ef7"
 | 
			
		||||
  integrity sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    lru-cache "^6.0.0"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -10118,9 +10081,9 @@ sshpk@^1.7.0:
 | 
			
		|||
    tweetnacl "~0.14.0"
 | 
			
		||||
 | 
			
		||||
ssri@^6.0.1:
 | 
			
		||||
  version "6.0.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.1.tgz#2a3c41b28dd45b62b63676ecb74001265ae9edd8"
 | 
			
		||||
  integrity sha512-3Wge10hNcT1Kur4PDFwEieXSCMCJs/7WvSACcrMYrNp+b8kDL1/0wJch5Ni2WrtwEa2IO8OsVfeKIciKCDx/QA==
 | 
			
		||||
  version "6.0.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ssri/-/ssri-6.0.2.tgz#157939134f20464e7301ddba3e90ffa8f7728ac5"
 | 
			
		||||
  integrity sha512-cepbSq/neFK7xB6A50KHN0xHDotYzq58wWCa5LeWqnPrHG8GzfEjO/4O8kpmcGW+oaxkvhEJCWgbgNk4/ZV93Q==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    figgy-pudding "^3.5.1"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -11213,15 +11176,14 @@ webidl-conversions@^6.1.0:
 | 
			
		|||
  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-6.1.0.tgz#9111b4d7ea80acd40f5270d666621afa78b69514"
 | 
			
		||||
  integrity sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==
 | 
			
		||||
 | 
			
		||||
webpack-assets-manifest@^4.0.2:
 | 
			
		||||
  version "4.0.2"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-assets-manifest/-/webpack-assets-manifest-4.0.2.tgz#ead6e6dbdcd1c2af45d11a382246fcc79a286372"
 | 
			
		||||
  integrity sha512-bBb9PvEGDOCFvW5/t6Yp9MEE0fymNJ0OvEud9nPvQegDbQEUZ/2WTeHnNoALwWMu1x3JHPyqHVYh8SwtYZ/dww==
 | 
			
		||||
webpack-assets-manifest@^4.0.5:
 | 
			
		||||
  version "4.0.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-assets-manifest/-/webpack-assets-manifest-4.0.5.tgz#802d45fd58203fc7a70ac557636a93605a218d3f"
 | 
			
		||||
  integrity sha512-cvvr0AtTHyi7D9otmLkv0Bv8j5KKwwD5Wwt6MNxLxgc3U3XmIZnNykw2PMChzUvPr9Ibiv9ceROIc0mS1C7MeA==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    chalk "^4.0"
 | 
			
		||||
    deepmerge "^4.0"
 | 
			
		||||
    lockfile "^1.0"
 | 
			
		||||
    lodash.escaperegexp "^4.0"
 | 
			
		||||
    lodash.get "^4.0"
 | 
			
		||||
    lodash.has "^4.0"
 | 
			
		||||
    mkdirp "^1.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -11229,10 +11191,10 @@ webpack-assets-manifest@^4.0.2:
 | 
			
		|||
    tapable "^1.0"
 | 
			
		||||
    webpack-sources "^1.0"
 | 
			
		||||
 | 
			
		||||
webpack-bundle-analyzer@^4.4.0:
 | 
			
		||||
  version "4.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.0.tgz#74013106e7e2b07cbd64f3a5ae847f7e814802c7"
 | 
			
		||||
  integrity sha512-9DhNa+aXpqdHk8LkLPTBU/dMfl84Y+WE2+KnfI6rSpNRNVKa0VGLjPd2pjFubDeqnWmulFggxmWBxhfJXZnR0g==
 | 
			
		||||
webpack-bundle-analyzer@^4.4.1:
 | 
			
		||||
  version "4.4.1"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.1.tgz#c71fb2eaffc10a4754d7303b224adb2342069da1"
 | 
			
		||||
  integrity sha512-j5m7WgytCkiVBoOGavzNokBOqxe6Mma13X1asfVYtKWM3wxBiRRu1u1iG0Iol5+qp9WgyhkMmBAcvjEfJ2bdDw==
 | 
			
		||||
  dependencies:
 | 
			
		||||
    acorn "^8.0.4"
 | 
			
		||||
    acorn-walk "^8.0.0"
 | 
			
		||||
| 
						 | 
				
			
			@ -11512,15 +11474,10 @@ ws@^6.2.1:
 | 
			
		|||
  dependencies:
 | 
			
		||||
    async-limiter "~1.0.0"
 | 
			
		||||
 | 
			
		||||
ws@^7.2.3, ws@^7.3.1:
 | 
			
		||||
  version "7.4.0"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.0.tgz#a5dd76a24197940d4a8bb9e0e152bb4503764da7"
 | 
			
		||||
  integrity sha512-kyFwXuV/5ymf+IXhS6f0+eAFvydbaBW3zjpT6hUdAh/hbVjTIB5EHBGi0bPoCLSK2wcuz3BrEkB9LrYv1Nm4NQ==
 | 
			
		||||
 | 
			
		||||
ws@^7.4.4:
 | 
			
		||||
  version "7.4.4"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59"
 | 
			
		||||
  integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==
 | 
			
		||||
ws@^7.2.3, ws@^7.3.1, ws@^7.4.5:
 | 
			
		||||
  version "7.4.5"
 | 
			
		||||
  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.5.tgz#a484dd851e9beb6fdb420027e3885e8ce48986c1"
 | 
			
		||||
  integrity sha512-xzyu3hFvomRfXKH8vOFMU3OguG6oOvhXMo3xsGy3xWExqaM2dxBbVxuD99O7m3ZUFMvvscsZDqxfgMaRr/Nr1g==
 | 
			
		||||
 | 
			
		||||
xml-name-validator@^3.0.0:
 | 
			
		||||
  version "3.0.0"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in a new issue