Merge branch 'master' into glitch-soc/merge-upstream
Conflicts: - `Gemfile.lock`: Not a real conflict, upstream updated dependencies that were too close to glitch-soc-only ones in the file. - `app/controllers/oauth/authorized_applications_controller.rb`: Upstream changed the logic surrounding suspended accounts. Minor conflict due to glitch-soc's theming system. Ported upstream changes. - `app/controllers/settings/base_controller.rb`: Upstream refactored and changed the logic surrounding suspended accounts. Minor conflict due to glitch-soc's theming system. Ported upstream changes. - `app/controllers/settings/sessions_controller.rb`: Upstream refactored and changed the logic surrounding suspended accounts. Minor conflict due to glitch-soc's theming system. Ported upstream changes. - `app/models/user.rb`: Upstream refactored and changed the logic surrounding suspended accounts. Minor conflict due to glitch-soc not preventing moved accounts from logging in. Ported upstream changes while keeping the ability for moved accounts to log in. - `app/policies/status_policy.rb`: Upstream refactored and changed the logic surrounding suspended accounts. Minor conflict due to glitch-soc's local-only toots. Ported upstream changes. - `app/serializers/rest/account_serializer.rb`: Upstream refactored and changed the logic surrounding suspended accounts. Minor conflict due to glitch-soc's ability to hide followers count. Ported upstream changes. - `app/services/process_mentions_service.rb`: Upstream refactored and changed the logic surrounding suspended accounts. Minor conflict due to glitch-soc's local-only toots. Ported upstream changes. - `package.json`: Not a real conflict, upstream updated dependencies that were too close to glitch-soc-only ones in the file.
This commit is contained in:
		
						commit
						a7aedebc31
					
				
							
								
								
									
										18
									
								
								Gemfile
								
								
								
								
							
							
						
						
									
										18
									
								
								Gemfile
								
								
								
								
							|  | @ -5,10 +5,10 @@ ruby '>= 2.5.0', '< 3.0.0' | ||||||
| 
 | 
 | ||||||
| gem 'pkg-config', '~> 1.4' | gem 'pkg-config', '~> 1.4' | ||||||
| 
 | 
 | ||||||
| gem 'puma', '~> 4.3' | gem 'puma', '~> 5.0' | ||||||
| gem 'rails', '~> 5.2.4.3' | gem 'rails', '~> 5.2.4.4' | ||||||
| gem 'sprockets', '~> 3.7.2' | gem 'sprockets', '~> 3.7.2' | ||||||
| gem 'thor', '~> 0.20' | gem 'thor', '~> 1.0' | ||||||
| gem 'rack', '~> 2.2.3' | gem 'rack', '~> 2.2.3' | ||||||
| 
 | 
 | ||||||
| gem 'thwait', '~> 0.2.0' | gem 'thwait', '~> 0.2.0' | ||||||
|  | @ -20,7 +20,7 @@ gem 'makara', '~> 0.4' | ||||||
| gem 'pghero', '~> 2.7' | gem 'pghero', '~> 2.7' | ||||||
| gem 'dotenv-rails', '~> 2.7' | gem 'dotenv-rails', '~> 2.7' | ||||||
| 
 | 
 | ||||||
| gem 'aws-sdk-s3', '~> 1.79', require: false | gem 'aws-sdk-s3', '~> 1.81', require: false | ||||||
| gem 'fog-core', '<= 2.1.0' | gem 'fog-core', '<= 2.1.0' | ||||||
| gem 'fog-openstack', '~> 0.3', require: false | gem 'fog-openstack', '~> 0.3', require: false | ||||||
| gem 'paperclip', '~> 6.0' | gem 'paperclip', '~> 6.0' | ||||||
|  | @ -123,26 +123,26 @@ end | ||||||
| group :test do | group :test do | ||||||
|   gem 'capybara', '~> 3.33' |   gem 'capybara', '~> 3.33' | ||||||
|   gem 'climate_control', '~> 0.2' |   gem 'climate_control', '~> 0.2' | ||||||
|   gem 'faker', '~> 2.13' |   gem 'faker', '~> 2.14' | ||||||
|   gem 'microformats', '~> 4.2' |   gem 'microformats', '~> 4.2' | ||||||
|   gem 'rails-controller-testing', '~> 1.0' |   gem 'rails-controller-testing', '~> 1.0' | ||||||
|   gem 'rspec-sidekiq', '~> 3.1' |   gem 'rspec-sidekiq', '~> 3.1' | ||||||
|   gem 'simplecov', '~> 0.19', require: false |   gem 'simplecov', '~> 0.19', require: false | ||||||
|   gem 'webmock', '~> 3.8' |   gem 'webmock', '~> 3.9' | ||||||
|   gem 'parallel_tests', '~> 3.2' |   gem 'parallel_tests', '~> 3.3' | ||||||
|   gem 'rspec_junit_formatter', '~> 0.4' |   gem 'rspec_junit_formatter', '~> 0.4' | ||||||
| end | end | ||||||
| 
 | 
 | ||||||
| group :development do | group :development do | ||||||
|   gem 'active_record_query_trace', '~> 1.7' |   gem 'active_record_query_trace', '~> 1.7' | ||||||
|   gem 'annotate', '~> 3.1' |   gem 'annotate', '~> 3.1' | ||||||
|   gem 'better_errors', '~> 2.7' |   gem 'better_errors', '~> 2.8' | ||||||
|   gem 'binding_of_caller', '~> 0.7' |   gem 'binding_of_caller', '~> 0.7' | ||||||
|   gem 'bullet', '~> 6.1' |   gem 'bullet', '~> 6.1' | ||||||
|   gem 'letter_opener', '~> 1.7' |   gem 'letter_opener', '~> 1.7' | ||||||
|   gem 'letter_opener_web', '~> 1.4' |   gem 'letter_opener_web', '~> 1.4' | ||||||
|   gem 'memory_profiler' |   gem 'memory_profiler' | ||||||
|   gem 'rubocop', '~> 0.90', require: false |   gem 'rubocop', '~> 0.91', require: false | ||||||
|   gem 'rubocop-rails', '~> 2.8', require: false |   gem 'rubocop-rails', '~> 2.8', require: false | ||||||
|   gem 'brakeman', '~> 4.9', require: false |   gem 'brakeman', '~> 4.9', require: false | ||||||
|   gem 'bundler-audit', '~> 0.7', require: false |   gem 'bundler-audit', '~> 0.7', require: false | ||||||
|  |  | ||||||
							
								
								
									
										144
									
								
								Gemfile.lock
								
								
								
								
							
							
						
						
									
										144
									
								
								Gemfile.lock
								
								
								
								
							|  | @ -16,25 +16,25 @@ GIT | ||||||
| GEM | GEM | ||||||
|   remote: https://rubygems.org/ |   remote: https://rubygems.org/ | ||||||
|   specs: |   specs: | ||||||
|     actioncable (5.2.4.3) |     actioncable (5.2.4.4) | ||||||
|       actionpack (= 5.2.4.3) |       actionpack (= 5.2.4.4) | ||||||
|       nio4r (~> 2.0) |       nio4r (~> 2.0) | ||||||
|       websocket-driver (>= 0.6.1) |       websocket-driver (>= 0.6.1) | ||||||
|     actionmailer (5.2.4.3) |     actionmailer (5.2.4.4) | ||||||
|       actionpack (= 5.2.4.3) |       actionpack (= 5.2.4.4) | ||||||
|       actionview (= 5.2.4.3) |       actionview (= 5.2.4.4) | ||||||
|       activejob (= 5.2.4.3) |       activejob (= 5.2.4.4) | ||||||
|       mail (~> 2.5, >= 2.5.4) |       mail (~> 2.5, >= 2.5.4) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|     actionpack (5.2.4.3) |     actionpack (5.2.4.4) | ||||||
|       actionview (= 5.2.4.3) |       actionview (= 5.2.4.4) | ||||||
|       activesupport (= 5.2.4.3) |       activesupport (= 5.2.4.4) | ||||||
|       rack (~> 2.0, >= 2.0.8) |       rack (~> 2.0, >= 2.0.8) | ||||||
|       rack-test (>= 0.6.3) |       rack-test (>= 0.6.3) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|       rails-html-sanitizer (~> 1.0, >= 1.0.2) |       rails-html-sanitizer (~> 1.0, >= 1.0.2) | ||||||
|     actionview (5.2.4.3) |     actionview (5.2.4.4) | ||||||
|       activesupport (= 5.2.4.3) |       activesupport (= 5.2.4.4) | ||||||
|       builder (~> 3.1) |       builder (~> 3.1) | ||||||
|       erubi (~> 1.4) |       erubi (~> 1.4) | ||||||
|       rails-dom-testing (~> 2.0) |       rails-dom-testing (~> 2.0) | ||||||
|  | @ -45,20 +45,20 @@ GEM | ||||||
|       case_transform (>= 0.2) |       case_transform (>= 0.2) | ||||||
|       jsonapi-renderer (>= 0.1.1.beta1, < 0.3) |       jsonapi-renderer (>= 0.1.1.beta1, < 0.3) | ||||||
|     active_record_query_trace (1.7) |     active_record_query_trace (1.7) | ||||||
|     activejob (5.2.4.3) |     activejob (5.2.4.4) | ||||||
|       activesupport (= 5.2.4.3) |       activesupport (= 5.2.4.4) | ||||||
|       globalid (>= 0.3.6) |       globalid (>= 0.3.6) | ||||||
|     activemodel (5.2.4.3) |     activemodel (5.2.4.4) | ||||||
|       activesupport (= 5.2.4.3) |       activesupport (= 5.2.4.4) | ||||||
|     activerecord (5.2.4.3) |     activerecord (5.2.4.4) | ||||||
|       activemodel (= 5.2.4.3) |       activemodel (= 5.2.4.4) | ||||||
|       activesupport (= 5.2.4.3) |       activesupport (= 5.2.4.4) | ||||||
|       arel (>= 9.0) |       arel (>= 9.0) | ||||||
|     activestorage (5.2.4.3) |     activestorage (5.2.4.4) | ||||||
|       actionpack (= 5.2.4.3) |       actionpack (= 5.2.4.4) | ||||||
|       activerecord (= 5.2.4.3) |       activerecord (= 5.2.4.4) | ||||||
|       marcel (~> 0.3.1) |       marcel (~> 0.3.1) | ||||||
|     activesupport (5.2.4.3) |     activesupport (5.2.4.4) | ||||||
|       concurrent-ruby (~> 1.0, >= 1.0.2) |       concurrent-ruby (~> 1.0, >= 1.0.2) | ||||||
|       i18n (>= 0.7, < 2) |       i18n (>= 0.7, < 2) | ||||||
|       minitest (~> 5.1) |       minitest (~> 5.1) | ||||||
|  | @ -79,23 +79,23 @@ GEM | ||||||
|       cocaine (~> 0.5.3) |       cocaine (~> 0.5.3) | ||||||
|     awrence (1.1.1) |     awrence (1.1.1) | ||||||
|     aws-eventstream (1.1.0) |     aws-eventstream (1.1.0) | ||||||
|     aws-partitions (1.365.0) |     aws-partitions (1.373.0) | ||||||
|     aws-sdk-core (3.105.0) |     aws-sdk-core (3.107.0) | ||||||
|       aws-eventstream (~> 1, >= 1.0.2) |       aws-eventstream (~> 1, >= 1.0.2) | ||||||
|       aws-partitions (~> 1, >= 1.239.0) |       aws-partitions (~> 1, >= 1.239.0) | ||||||
|       aws-sigv4 (~> 1.1) |       aws-sigv4 (~> 1.1) | ||||||
|       jmespath (~> 1.0) |       jmespath (~> 1.0) | ||||||
|     aws-sdk-kms (1.37.0) |     aws-sdk-kms (1.38.0) | ||||||
|       aws-sdk-core (~> 3, >= 3.99.0) |       aws-sdk-core (~> 3, >= 3.99.0) | ||||||
|       aws-sigv4 (~> 1.1) |       aws-sigv4 (~> 1.1) | ||||||
|     aws-sdk-s3 (1.79.1) |     aws-sdk-s3 (1.81.0) | ||||||
|       aws-sdk-core (~> 3, >= 3.104.3) |       aws-sdk-core (~> 3, >= 3.104.3) | ||||||
|       aws-sdk-kms (~> 1) |       aws-sdk-kms (~> 1) | ||||||
|       aws-sigv4 (~> 1.1) |       aws-sigv4 (~> 1.1) | ||||||
|     aws-sigv4 (1.2.2) |     aws-sigv4 (1.2.2) | ||||||
|       aws-eventstream (~> 1, >= 1.0.2) |       aws-eventstream (~> 1, >= 1.0.2) | ||||||
|     bcrypt (3.1.16) |     bcrypt (3.1.16) | ||||||
|     better_errors (2.7.1) |     better_errors (2.8.1) | ||||||
|       coderay (>= 1.0.0) |       coderay (>= 1.0.0) | ||||||
|       erubi (>= 1.0.0) |       erubi (>= 1.0.0) | ||||||
|       rack (>= 0.9.0) |       rack (>= 0.9.0) | ||||||
|  | @ -160,13 +160,12 @@ GEM | ||||||
|     cose (1.0.0) |     cose (1.0.0) | ||||||
|       cbor (~> 0.5.9) |       cbor (~> 0.5.9) | ||||||
|       openssl-signature_algorithm (~> 0.4.0) |       openssl-signature_algorithm (~> 0.4.0) | ||||||
|     crack (0.4.3) |     crack (0.4.4) | ||||||
|       safe_yaml (~> 1.0.0) |  | ||||||
|     crass (1.0.6) |     crass (1.0.6) | ||||||
|     css_parser (1.7.1) |     css_parser (1.7.1) | ||||||
|       addressable |       addressable | ||||||
|     debug_inspector (0.0.3) |     debug_inspector (0.0.3) | ||||||
|     devise (4.7.2) |     devise (4.7.3) | ||||||
|       bcrypt (~> 3.0) |       bcrypt (~> 3.0) | ||||||
|       orm_adapter (~> 0.1) |       orm_adapter (~> 0.1) | ||||||
|       railties (>= 4.1.0) |       railties (>= 4.1.0) | ||||||
|  | @ -210,7 +209,7 @@ GEM | ||||||
|       tzinfo |       tzinfo | ||||||
|     excon (0.76.0) |     excon (0.76.0) | ||||||
|     fabrication (2.21.1) |     fabrication (2.21.1) | ||||||
|     faker (2.13.0) |     faker (2.14.0) | ||||||
|       i18n (>= 1.6, < 2) |       i18n (>= 1.6, < 2) | ||||||
|     faraday (1.0.1) |     faraday (1.0.1) | ||||||
|       multipart-post (>= 1.2, < 3) |       multipart-post (>= 1.2, < 3) | ||||||
|  | @ -233,7 +232,7 @@ GEM | ||||||
|       fog-json (>= 1.0) |       fog-json (>= 1.0) | ||||||
|       ipaddress (>= 0.8) |       ipaddress (>= 0.8) | ||||||
|     formatador (0.2.5) |     formatador (0.2.5) | ||||||
|     fugit (1.3.8) |     fugit (1.3.9) | ||||||
|       et-orbi (~> 1.1, >= 1.1.8) |       et-orbi (~> 1.1, >= 1.1.8) | ||||||
|       raabro (~> 1.3) |       raabro (~> 1.3) | ||||||
|     fuubar (2.5.0) |     fuubar (2.5.0) | ||||||
|  | @ -363,7 +362,7 @@ GEM | ||||||
|     net-scp (3.0.0) |     net-scp (3.0.0) | ||||||
|       net-ssh (>= 2.6.5, < 7.0.0) |       net-ssh (>= 2.6.5, < 7.0.0) | ||||||
|     net-ssh (6.1.0) |     net-ssh (6.1.0) | ||||||
|     nio4r (2.5.3) |     nio4r (2.5.4) | ||||||
|     nokogiri (1.10.10) |     nokogiri (1.10.10) | ||||||
|       mini_portile2 (~> 2.4.0) |       mini_portile2 (~> 2.4.0) | ||||||
|     nokogumbo (2.0.2) |     nokogumbo (2.0.2) | ||||||
|  | @ -387,7 +386,7 @@ GEM | ||||||
|     openssl (2.2.0) |     openssl (2.2.0) | ||||||
|     openssl-signature_algorithm (0.4.0) |     openssl-signature_algorithm (0.4.0) | ||||||
|     orm_adapter (0.5.0) |     orm_adapter (0.5.0) | ||||||
|     ox (2.13.3) |     ox (2.13.4) | ||||||
|     paperclip (6.0.0) |     paperclip (6.0.0) | ||||||
|       activemodel (>= 4.2.0) |       activemodel (>= 4.2.0) | ||||||
|       activesupport (>= 4.2.0) |       activesupport (>= 4.2.0) | ||||||
|  | @ -398,7 +397,7 @@ GEM | ||||||
|       av (~> 0.9.0) |       av (~> 0.9.0) | ||||||
|       paperclip (>= 2.5.2) |       paperclip (>= 2.5.2) | ||||||
|     parallel (1.19.2) |     parallel (1.19.2) | ||||||
|     parallel_tests (3.2.0) |     parallel_tests (3.3.0) | ||||||
|       parallel |       parallel | ||||||
|     parser (2.7.1.4) |     parser (2.7.1.4) | ||||||
|       ast (~> 2.4.1) |       ast (~> 2.4.1) | ||||||
|  | @ -406,11 +405,11 @@ GEM | ||||||
|     pastel (0.8.0) |     pastel (0.8.0) | ||||||
|       tty-color (~> 0.5) |       tty-color (~> 0.5) | ||||||
|     pg (1.2.3) |     pg (1.2.3) | ||||||
|     pghero (2.7.0) |     pghero (2.7.2) | ||||||
|       activerecord (>= 5) |       activerecord (>= 5) | ||||||
|     pkg-config (1.4.2) |     pkg-config (1.4.3) | ||||||
|     posix-spawn (0.3.15) |     posix-spawn (0.3.15) | ||||||
|     premailer (1.13.1) |     premailer (1.14.2) | ||||||
|       addressable |       addressable | ||||||
|       css_parser (>= 1.6.0) |       css_parser (>= 1.6.0) | ||||||
|       htmlentities (>= 4.0.0) |       htmlentities (>= 4.0.0) | ||||||
|  | @ -427,7 +426,7 @@ GEM | ||||||
|     pry-rails (0.3.9) |     pry-rails (0.3.9) | ||||||
|       pry (>= 0.10.4) |       pry (>= 0.10.4) | ||||||
|     public_suffix (4.0.6) |     public_suffix (4.0.6) | ||||||
|     puma (4.3.6) |     puma (5.0.0) | ||||||
|       nio4r (~> 2.0) |       nio4r (~> 2.0) | ||||||
|     pundit (2.1.0) |     pundit (2.1.0) | ||||||
|       activesupport (>= 3.0.0) |       activesupport (>= 3.0.0) | ||||||
|  | @ -441,18 +440,18 @@ GEM | ||||||
|       rack |       rack | ||||||
|     rack-test (1.1.0) |     rack-test (1.1.0) | ||||||
|       rack (>= 1.0, < 3) |       rack (>= 1.0, < 3) | ||||||
|     rails (5.2.4.3) |     rails (5.2.4.4) | ||||||
|       actioncable (= 5.2.4.3) |       actioncable (= 5.2.4.4) | ||||||
|       actionmailer (= 5.2.4.3) |       actionmailer (= 5.2.4.4) | ||||||
|       actionpack (= 5.2.4.3) |       actionpack (= 5.2.4.4) | ||||||
|       actionview (= 5.2.4.3) |       actionview (= 5.2.4.4) | ||||||
|       activejob (= 5.2.4.3) |       activejob (= 5.2.4.4) | ||||||
|       activemodel (= 5.2.4.3) |       activemodel (= 5.2.4.4) | ||||||
|       activerecord (= 5.2.4.3) |       activerecord (= 5.2.4.4) | ||||||
|       activestorage (= 5.2.4.3) |       activestorage (= 5.2.4.4) | ||||||
|       activesupport (= 5.2.4.3) |       activesupport (= 5.2.4.4) | ||||||
|       bundler (>= 1.3.0) |       bundler (>= 1.3.0) | ||||||
|       railties (= 5.2.4.3) |       railties (= 5.2.4.4) | ||||||
|       sprockets-rails (>= 2.0.0) |       sprockets-rails (>= 2.0.0) | ||||||
|     rails-controller-testing (1.0.5) |     rails-controller-testing (1.0.5) | ||||||
|       actionpack (>= 5.0.1.rc1) |       actionpack (>= 5.0.1.rc1) | ||||||
|  | @ -468,9 +467,9 @@ GEM | ||||||
|       railties (>= 5.0, < 6) |       railties (>= 5.0, < 6) | ||||||
|     rails-settings-cached (0.6.6) |     rails-settings-cached (0.6.6) | ||||||
|       rails (>= 4.2.0) |       rails (>= 4.2.0) | ||||||
|     railties (5.2.4.3) |     railties (5.2.4.4) | ||||||
|       actionpack (= 5.2.4.3) |       actionpack (= 5.2.4.4) | ||||||
|       activesupport (= 5.2.4.3) |       activesupport (= 5.2.4.4) | ||||||
|       method_source |       method_source | ||||||
|       rake (>= 0.8.7) |       rake (>= 0.8.7) | ||||||
|       thor (>= 0.19.0, < 2.0) |       thor (>= 0.19.0, < 2.0) | ||||||
|  | @ -482,7 +481,7 @@ GEM | ||||||
|     rdf-normalize (0.4.0) |     rdf-normalize (0.4.0) | ||||||
|       rdf (~> 3.1) |       rdf (~> 3.1) | ||||||
|     redcarpet (3.5.0) |     redcarpet (3.5.0) | ||||||
|     redis (4.2.1) |     redis (4.2.2) | ||||||
|     redis-actionpack (5.2.0) |     redis-actionpack (5.2.0) | ||||||
|       actionpack (>= 5, < 7) |       actionpack (>= 5, < 7) | ||||||
|       redis-rack (>= 2.1.0, < 3) |       redis-rack (>= 2.1.0, < 3) | ||||||
|  | @ -501,7 +500,7 @@ GEM | ||||||
|       redis-store (>= 1.2, < 2) |       redis-store (>= 1.2, < 2) | ||||||
|     redis-store (1.9.0) |     redis-store (1.9.0) | ||||||
|       redis (>= 4, < 5) |       redis (>= 4, < 5) | ||||||
|     regexp_parser (1.7.1) |     regexp_parser (1.8.0) | ||||||
|     request_store (1.5.0) |     request_store (1.5.0) | ||||||
|       rack (>= 1.4) |       rack (>= 1.4) | ||||||
|     responders (3.0.1) |     responders (3.0.1) | ||||||
|  | @ -536,18 +535,18 @@ GEM | ||||||
|     rspec-support (3.9.3) |     rspec-support (3.9.3) | ||||||
|     rspec_junit_formatter (0.4.1) |     rspec_junit_formatter (0.4.1) | ||||||
|       rspec-core (>= 2, < 4, != 2.12.0) |       rspec-core (>= 2, < 4, != 2.12.0) | ||||||
|     rubocop (0.90.0) |     rubocop (0.91.0) | ||||||
|       parallel (~> 1.10) |       parallel (~> 1.10) | ||||||
|       parser (>= 2.7.1.1) |       parser (>= 2.7.1.1) | ||||||
|       rainbow (>= 2.2.2, < 4.0) |       rainbow (>= 2.2.2, < 4.0) | ||||||
|       regexp_parser (>= 1.7) |       regexp_parser (>= 1.7) | ||||||
|       rexml |       rexml | ||||||
|       rubocop-ast (>= 0.3.0, < 1.0) |       rubocop-ast (>= 0.4.0, < 1.0) | ||||||
|       ruby-progressbar (~> 1.7) |       ruby-progressbar (~> 1.7) | ||||||
|       unicode-display_width (>= 1.4.0, < 2.0) |       unicode-display_width (>= 1.4.0, < 2.0) | ||||||
|     rubocop-ast (0.3.0) |     rubocop-ast (0.4.2) | ||||||
|       parser (>= 2.7.1.4) |       parser (>= 2.7.1.4) | ||||||
|     rubocop-rails (2.8.0) |     rubocop-rails (2.8.1) | ||||||
|       activesupport (>= 4.2.0) |       activesupport (>= 4.2.0) | ||||||
|       rack (>= 1.1) |       rack (>= 1.1) | ||||||
|       rubocop (>= 0.87.0) |       rubocop (>= 0.87.0) | ||||||
|  | @ -556,7 +555,6 @@ GEM | ||||||
|       nokogiri (>= 1.5.10) |       nokogiri (>= 1.5.10) | ||||||
|     rufus-scheduler (3.6.0) |     rufus-scheduler (3.6.0) | ||||||
|       fugit (~> 1.1, >= 1.1.6) |       fugit (~> 1.1, >= 1.1.6) | ||||||
|     safe_yaml (1.0.5) |  | ||||||
|     safety_net_attestation (0.4.0) |     safety_net_attestation (0.4.0) | ||||||
|       jwt (~> 2.0) |       jwt (~> 2.0) | ||||||
|     sanitize (5.2.1) |     sanitize (5.2.1) | ||||||
|  | @ -565,7 +563,7 @@ GEM | ||||||
|       nokogumbo (~> 2.0) |       nokogumbo (~> 2.0) | ||||||
|     securecompare (1.0.0) |     securecompare (1.0.0) | ||||||
|     semantic_range (2.3.0) |     semantic_range (2.3.0) | ||||||
|     sidekiq (6.1.1) |     sidekiq (6.1.2) | ||||||
|       connection_pool (>= 2.2.2) |       connection_pool (>= 2.2.2) | ||||||
|       rack (~> 2.0) |       rack (~> 2.0) | ||||||
|       redis (>= 4.2.0) |       redis (>= 4.2.0) | ||||||
|  | @ -594,7 +592,7 @@ GEM | ||||||
|     sprockets (3.7.2) |     sprockets (3.7.2) | ||||||
|       concurrent-ruby (~> 1.0) |       concurrent-ruby (~> 1.0) | ||||||
|       rack (> 1, < 3) |       rack (> 1, < 3) | ||||||
|     sprockets-rails (3.2.1) |     sprockets-rails (3.2.2) | ||||||
|       actionpack (>= 4.0) |       actionpack (>= 4.0) | ||||||
|       activesupport (>= 4.0) |       activesupport (>= 4.0) | ||||||
|       sprockets (>= 3.0.0) |       sprockets (>= 3.0.0) | ||||||
|  | @ -613,7 +611,7 @@ GEM | ||||||
|       unicode-display_width (~> 1.1, >= 1.1.1) |       unicode-display_width (~> 1.1, >= 1.1.1) | ||||||
|     terrapin (0.6.0) |     terrapin (0.6.0) | ||||||
|       climate_control (>= 0.0.3, < 1.0) |       climate_control (>= 0.0.3, < 1.0) | ||||||
|     thor (0.20.3) |     thor (1.0.1) | ||||||
|     thread_safe (0.3.6) |     thread_safe (0.3.6) | ||||||
|     thwait (0.2.0) |     thwait (0.2.0) | ||||||
|       e2mmap |       e2mmap | ||||||
|  | @ -654,7 +652,7 @@ GEM | ||||||
|       safety_net_attestation (~> 0.4.0) |       safety_net_attestation (~> 0.4.0) | ||||||
|       securecompare (~> 1.0) |       securecompare (~> 1.0) | ||||||
|       tpm-key_attestation (~> 0.9.0) |       tpm-key_attestation (~> 0.9.0) | ||||||
|     webmock (3.8.3) |     webmock (3.9.1) | ||||||
|       addressable (>= 2.3.6) |       addressable (>= 2.3.6) | ||||||
|       crack (>= 0.3.2) |       crack (>= 0.3.2) | ||||||
|       hashdiff (>= 0.4.0, < 2.0.0) |       hashdiff (>= 0.4.0, < 2.0.0) | ||||||
|  | @ -681,8 +679,8 @@ DEPENDENCIES | ||||||
|   active_record_query_trace (~> 1.7) |   active_record_query_trace (~> 1.7) | ||||||
|   addressable (~> 2.7) |   addressable (~> 2.7) | ||||||
|   annotate (~> 3.1) |   annotate (~> 3.1) | ||||||
|   aws-sdk-s3 (~> 1.79) |   aws-sdk-s3 (~> 1.81) | ||||||
|   better_errors (~> 2.7) |   better_errors (~> 2.8) | ||||||
|   binding_of_caller (~> 0.7) |   binding_of_caller (~> 0.7) | ||||||
|   blurhash (~> 0.1) |   blurhash (~> 0.1) | ||||||
|   bootsnap (~> 1.4) |   bootsnap (~> 1.4) | ||||||
|  | @ -711,7 +709,7 @@ DEPENDENCIES | ||||||
|   e2mmap (~> 0.1.0) |   e2mmap (~> 0.1.0) | ||||||
|   ed25519 (~> 1.2) |   ed25519 (~> 1.2) | ||||||
|   fabrication (~> 2.21) |   fabrication (~> 2.21) | ||||||
|   faker (~> 2.13) |   faker (~> 2.14) | ||||||
|   fast_blank (~> 1.0) |   fast_blank (~> 1.0) | ||||||
|   fastimage |   fastimage | ||||||
|   fog-core (<= 2.1.0) |   fog-core (<= 2.1.0) | ||||||
|  | @ -752,7 +750,7 @@ DEPENDENCIES | ||||||
|   paperclip (~> 6.0) |   paperclip (~> 6.0) | ||||||
|   paperclip-av-transcoder (~> 0.6) |   paperclip-av-transcoder (~> 0.6) | ||||||
|   parallel (~> 1.19) |   parallel (~> 1.19) | ||||||
|   parallel_tests (~> 3.2) |   parallel_tests (~> 3.3) | ||||||
|   parslet |   parslet | ||||||
|   pg (~> 1.2) |   pg (~> 1.2) | ||||||
|   pghero (~> 2.7) |   pghero (~> 2.7) | ||||||
|  | @ -762,12 +760,12 @@ DEPENDENCIES | ||||||
|   private_address_check (~> 0.5) |   private_address_check (~> 0.5) | ||||||
|   pry-byebug (~> 3.9) |   pry-byebug (~> 3.9) | ||||||
|   pry-rails (~> 0.3) |   pry-rails (~> 0.3) | ||||||
|   puma (~> 4.3) |   puma (~> 5.0) | ||||||
|   pundit (~> 2.1) |   pundit (~> 2.1) | ||||||
|   rack (~> 2.2.3) |   rack (~> 2.2.3) | ||||||
|   rack-attack (~> 6.3) |   rack-attack (~> 6.3) | ||||||
|   rack-cors (~> 1.1) |   rack-cors (~> 1.1) | ||||||
|   rails (~> 5.2.4.3) |   rails (~> 5.2.4.4) | ||||||
|   rails-controller-testing (~> 1.0) |   rails-controller-testing (~> 1.0) | ||||||
|   rails-i18n (~> 5.1) |   rails-i18n (~> 5.1) | ||||||
|   rails-settings-cached (~> 0.6) |   rails-settings-cached (~> 0.6) | ||||||
|  | @ -780,7 +778,7 @@ DEPENDENCIES | ||||||
|   rspec-rails (~> 4.0) |   rspec-rails (~> 4.0) | ||||||
|   rspec-sidekiq (~> 3.1) |   rspec-sidekiq (~> 3.1) | ||||||
|   rspec_junit_formatter (~> 0.4) |   rspec_junit_formatter (~> 0.4) | ||||||
|   rubocop (~> 0.90) |   rubocop (~> 0.91) | ||||||
|   rubocop-rails (~> 2.8) |   rubocop-rails (~> 2.8) | ||||||
|   ruby-progressbar (~> 1.10) |   ruby-progressbar (~> 1.10) | ||||||
|   sanitize (~> 5.2) |   sanitize (~> 5.2) | ||||||
|  | @ -797,12 +795,12 @@ DEPENDENCIES | ||||||
|   stoplight (~> 2.2.1) |   stoplight (~> 2.2.1) | ||||||
|   streamio-ffmpeg (~> 3.0) |   streamio-ffmpeg (~> 3.0) | ||||||
|   strong_migrations (~> 0.7) |   strong_migrations (~> 0.7) | ||||||
|   thor (~> 0.20) |   thor (~> 1.0) | ||||||
|   thwait (~> 0.2.0) |   thwait (~> 0.2.0) | ||||||
|   tty-prompt (~> 0.22) |   tty-prompt (~> 0.22) | ||||||
|   twitter-text (~> 1.14) |   twitter-text (~> 1.14) | ||||||
|   tzinfo-data (~> 1.2020) |   tzinfo-data (~> 1.2020) | ||||||
|   webauthn (~> 3.0.0.alpha1) |   webauthn (~> 3.0.0.alpha1) | ||||||
|   webmock (~> 3.8) |   webmock (~> 3.9) | ||||||
|   webpacker (~> 5.2) |   webpacker (~> 5.2) | ||||||
|   webpush |   webpush | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ class AccountsController < ApplicationController | ||||||
|   include AccountControllerConcern |   include AccountControllerConcern | ||||||
|   include SignatureAuthentication |   include SignatureAuthentication | ||||||
| 
 | 
 | ||||||
|  |   before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? } | ||||||
|   before_action :set_cache_headers |   before_action :set_cache_headers | ||||||
|   before_action :set_body_classes |   before_action :set_body_classes | ||||||
| 
 | 
 | ||||||
|  | @ -49,7 +50,7 @@ class AccountsController < ApplicationController | ||||||
| 
 | 
 | ||||||
|       format.json do |       format.json do | ||||||
|         expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?) |         expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?) | ||||||
|         render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to |         render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  | @ -154,12 +155,4 @@ class AccountsController < ApplicationController | ||||||
|   def params_slice(*keys) |   def params_slice(*keys) | ||||||
|     params.slice(*keys).permit(*keys) |     params.slice(*keys).permit(*keys) | ||||||
|   end |   end | ||||||
| 
 |  | ||||||
|   def restrict_fields_to |  | ||||||
|     if signed_request_account.present? || public_fetch_mode? |  | ||||||
|       # Return all fields |  | ||||||
|     else |  | ||||||
|       %i(id type preferred_username inbox public_key endpoints) |  | ||||||
|     end |  | ||||||
|   end |  | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -57,9 +57,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController | ||||||
|   def set_statuses |   def set_statuses | ||||||
|     return unless page_requested? |     return unless page_requested? | ||||||
| 
 | 
 | ||||||
|     @statuses = @account.statuses.permitted_for(@account, signed_request_account) |  | ||||||
|     @statuses = cache_collection_paginated_by_id( |     @statuses = cache_collection_paginated_by_id( | ||||||
|       @statuses, |       @account.statuses.permitted_for(@account, signed_request_account), | ||||||
|       Status, |       Status, | ||||||
|       LIMIT, |       LIMIT, | ||||||
|       params_slice(:max_id, :min_id, :since_id) |       params_slice(:max_id, :min_id, :since_id) | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| 
 | 
 | ||||||
| module Admin | module Admin | ||||||
|   class AccountsController < BaseController |   class AccountsController < BaseController | ||||||
|     before_action :set_account, only: [:show, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] |     before_action :set_account, except: [:index] | ||||||
|     before_action :require_remote_account!, only: [:redownload] |     before_action :require_remote_account!, only: [:redownload] | ||||||
|     before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] |     before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] | ||||||
| 
 | 
 | ||||||
|  | @ -14,49 +14,58 @@ module Admin | ||||||
|     def show |     def show | ||||||
|       authorize @account, :show? |       authorize @account, :show? | ||||||
| 
 | 
 | ||||||
|  |       @deletion_request        = @account.deletion_request | ||||||
|       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) |       @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) | ||||||
|       @moderation_notes        = @account.targeted_moderation_notes.latest |       @moderation_notes        = @account.targeted_moderation_notes.latest | ||||||
|       @warnings                = @account.targeted_account_warnings.latest.custom |       @warnings                = @account.targeted_account_warnings.latest.custom | ||||||
|  |       @domain_block            = DomainBlock.rule_for(@account.domain) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def memorialize |     def memorialize | ||||||
|       authorize @account, :memorialize? |       authorize @account, :memorialize? | ||||||
|       @account.memorialize! |       @account.memorialize! | ||||||
|       log_action :memorialize, @account |       log_action :memorialize, @account | ||||||
|       redirect_to admin_account_path(@account.id) |       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.memorialized_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def enable |     def enable | ||||||
|       authorize @account.user, :enable? |       authorize @account.user, :enable? | ||||||
|       @account.user.enable! |       @account.user.enable! | ||||||
|       log_action :enable, @account.user |       log_action :enable, @account.user | ||||||
|       redirect_to admin_account_path(@account.id) |       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.enabled_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def approve |     def approve | ||||||
|       authorize @account.user, :approve? |       authorize @account.user, :approve? | ||||||
|       @account.user.approve! |       @account.user.approve! | ||||||
|       redirect_to admin_pending_accounts_path |       redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def reject |     def reject | ||||||
|       authorize @account.user, :reject? |       authorize @account.user, :reject? | ||||||
|       SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) |       DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false) | ||||||
|       redirect_to admin_pending_accounts_path |       redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct) | ||||||
|  |     end | ||||||
|  | 
 | ||||||
|  |     def destroy | ||||||
|  |       authorize @account, :destroy? | ||||||
|  |       Admin::AccountDeletionWorker.perform_async(@account.id) | ||||||
|  |       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def unsilence |     def unsilence | ||||||
|       authorize @account, :unsilence? |       authorize @account, :unsilence? | ||||||
|       @account.unsilence! |       @account.unsilence! | ||||||
|       log_action :unsilence, @account |       log_action :unsilence, @account | ||||||
|       redirect_to admin_account_path(@account.id) |       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsilenced_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def unsuspend |     def unsuspend | ||||||
|       authorize @account, :unsuspend? |       authorize @account, :unsuspend? | ||||||
|       @account.unsuspend! |       @account.unsuspend! | ||||||
|  |       Admin::UnsuspensionWorker.perform_async(@account.id) | ||||||
|       log_action :unsuspend, @account |       log_action :unsuspend, @account | ||||||
|       redirect_to admin_account_path(@account.id) |       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsuspended_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def redownload |     def redownload | ||||||
|  | @ -65,7 +74,7 @@ module Admin | ||||||
|       @account.update!(last_webfingered_at: nil) |       @account.update!(last_webfingered_at: nil) | ||||||
|       ResolveAccountService.new.call(@account) |       ResolveAccountService.new.call(@account) | ||||||
| 
 | 
 | ||||||
|       redirect_to admin_account_path(@account.id) |       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.redownloaded_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def remove_avatar |     def remove_avatar | ||||||
|  | @ -76,7 +85,7 @@ module Admin | ||||||
| 
 | 
 | ||||||
|       log_action :remove_avatar, @account.user |       log_action :remove_avatar, @account.user | ||||||
| 
 | 
 | ||||||
|       redirect_to admin_account_path(@account.id) |       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_avatar_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def remove_header |     def remove_header | ||||||
|  | @ -87,7 +96,7 @@ module Admin | ||||||
| 
 | 
 | ||||||
|       log_action :remove_header, @account.user |       log_action :remove_header, @account.user | ||||||
| 
 | 
 | ||||||
|       redirect_to admin_account_path(@account.id) |       redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct) | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     private |     private | ||||||
|  |  | ||||||
|  | @ -96,12 +96,12 @@ class Api::BaseController < ApplicationController | ||||||
|   def require_user! |   def require_user! | ||||||
|     if !current_user |     if !current_user | ||||||
|       render json: { error: 'This method requires an authenticated user' }, status: 422 |       render json: { error: 'This method requires an authenticated user' }, status: 422 | ||||||
|     elsif current_user.disabled? |  | ||||||
|       render json: { error: 'Your login is currently disabled' }, status: 403 |  | ||||||
|     elsif !current_user.confirmed? |     elsif !current_user.confirmed? | ||||||
|       render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 |       render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 | ||||||
|     elsif !current_user.approved? |     elsif !current_user.approved? | ||||||
|       render json: { error: 'Your login is currently pending approval' }, status: 403 |       render json: { error: 'Your login is currently pending approval' }, status: 403 | ||||||
|  |     elsif !current_user.functional? | ||||||
|  |       render json: { error: 'Your login is currently disabled' }, status: 403 | ||||||
|     else |     else | ||||||
|       set_user_activity |       set_user_activity | ||||||
|     end |     end | ||||||
|  |  | ||||||
|  | @ -17,6 +17,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def set_featured_tags |   def set_featured_tags | ||||||
|     @featured_tags = @account.featured_tags |     @featured_tags = @account.suspended? ? @account.featured_tags : [] | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -25,7 +25,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def hide_results? |   def hide_results? | ||||||
|     (@account.hides_followers? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) |     @account.suspended? || (@account.hides_followers? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def default_accounts |   def default_accounts | ||||||
|  |  | ||||||
|  | @ -25,7 +25,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def hide_results? |   def hide_results? | ||||||
|     (@account.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) |     @account.suspended? || (@account.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def default_accounts |   def default_accounts | ||||||
|  |  | ||||||
|  | @ -5,7 +5,7 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController | ||||||
|   before_action :set_account |   before_action :set_account | ||||||
| 
 | 
 | ||||||
|   def index |   def index | ||||||
|     @proofs = @account.identity_proofs.active |     @proofs = @account.suspended? ? [] : @account.identity_proofs.active | ||||||
|     render json: @proofs, each_serializer: REST::IdentityProofSerializer |     render json: @proofs, each_serializer: REST::IdentityProofSerializer | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ class Api::V1::Accounts::ListsController < Api::BaseController | ||||||
|   before_action :set_account |   before_action :set_account | ||||||
| 
 | 
 | ||||||
|   def index |   def index | ||||||
|     @lists = @account.lists.where(account: current_account) |     @lists = @account.suspended? ? [] : @account.lists.where(account: current_account) | ||||||
|     render json: @lists, each_serializer: REST::ListSerializer |     render json: @lists, each_serializer: REST::ListSerializer | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -5,7 +5,7 @@ class Api::V1::Accounts::RelationshipsController < Api::BaseController | ||||||
|   before_action :require_user! |   before_action :require_user! | ||||||
| 
 | 
 | ||||||
|   def index |   def index | ||||||
|     accounts = Account.where(id: account_ids).select('id') |     accounts = Account.without_suspended.where(id: account_ids).select('id') | ||||||
|     # .where doesn't guarantee that our results are in the same order |     # .where doesn't guarantee that our results are in the same order | ||||||
|     # we requested them, so return the "right" order to the requestor. |     # we requested them, so return the "right" order to the requestor. | ||||||
|     @accounts = accounts.index_by(&:id).values_at(*account_ids).compact |     @accounts = accounts.index_by(&:id).values_at(*account_ids).compact | ||||||
|  |  | ||||||
|  | @ -18,7 +18,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def load_statuses |   def load_statuses | ||||||
|     cached_account_statuses |     @account.suspended? ? [] : cached_account_statuses | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def cached_account_statuses |   def cached_account_statuses | ||||||
|  |  | ||||||
|  | @ -9,7 +9,6 @@ class Api::V1::AccountsController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   before_action :require_user!, except: [:show, :create] |   before_action :require_user!, except: [:show, :create] | ||||||
|   before_action :set_account, except: [:create] |   before_action :set_account, except: [:create] | ||||||
|   before_action :check_account_suspension, only: [:show] |  | ||||||
|   before_action :check_enabled_registrations, only: [:create] |   before_action :check_enabled_registrations, only: [:create] | ||||||
| 
 | 
 | ||||||
|   skip_before_action :require_authenticated_user!, only: :create |   skip_before_action :require_authenticated_user!, only: :create | ||||||
|  | @ -31,9 +30,8 @@ class Api::V1::AccountsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def follow |   def follow | ||||||
|     FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs), with_rate_limit: true) |     follow  = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true) | ||||||
| 
 |     options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } } | ||||||
|     options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } } |  | ||||||
| 
 | 
 | ||||||
|     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) |     render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) | ||||||
|   end |   end | ||||||
|  | @ -73,10 +71,6 @@ class Api::V1::AccountsController < Api::BaseController | ||||||
|     AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) |     AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def check_account_suspension |  | ||||||
|     gone if @account.suspended? |  | ||||||
|   end |  | ||||||
| 
 |  | ||||||
|   def account_params |   def account_params | ||||||
|     params.permit(:username, :email, :password, :agreement, :locale, :reason) |     params.permit(:username, :email, :password, :agreement, :locale, :reason) | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -58,7 +58,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def reject |   def reject | ||||||
|     authorize @account.user, :reject? |     authorize @account.user, :reject? | ||||||
|     SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) |     DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false) | ||||||
|  |     render json: @account, serializer: REST::Admin::AccountSerializer | ||||||
|  |   end | ||||||
|  | 
 | ||||||
|  |   def destroy | ||||||
|  |     authorize @account, :destroy? | ||||||
|  |     Admin::AccountDeletionWorker.perform_async(@account.id) | ||||||
|     render json: @account, serializer: REST::Admin::AccountSerializer |     render json: @account, serializer: REST::Admin::AccountSerializer | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | @ -72,6 +78,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController | ||||||
|   def unsuspend |   def unsuspend | ||||||
|     authorize @account, :unsuspend? |     authorize @account, :unsuspend? | ||||||
|     @account.unsuspend! |     @account.unsuspend! | ||||||
|  |     Admin::UnsuspensionWorker.perform_async(@account.id) | ||||||
|     log_action :unsuspend, @account |     log_action :unsuspend, @account | ||||||
|     render json: @account, serializer: REST::Admin::AccountSerializer |     render json: @account, serializer: REST::Admin::AccountSerializer | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -18,6 +18,8 @@ class Api::V1::BlocksController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def paginated_blocks |   def paginated_blocks | ||||||
|     @paginated_blocks ||= Block.eager_load(target_account: :account_stat) |     @paginated_blocks ||= Block.eager_load(target_account: :account_stat) | ||||||
|  |                                .joins(:target_account) | ||||||
|  |                                .merge(Account.without_suspended) | ||||||
|                                .where(account: current_account) |                                .where(account: current_account) | ||||||
|                                .paginate_by_max_id( |                                .paginate_by_max_id( | ||||||
|                                  limit_param(DEFAULT_ACCOUNTS_LIMIT), |                                  limit_param(DEFAULT_ACCOUNTS_LIMIT), | ||||||
|  |  | ||||||
|  | @ -25,7 +25,7 @@ class Api::V1::EndorsementsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def endorsed_accounts |   def endorsed_accounts | ||||||
|     current_account.endorsed_accounts.includes(:account_stat) |     current_account.endorsed_accounts.includes(:account_stat).without_suspended | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def insert_pagination_headers |   def insert_pagination_headers | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ class Api::V1::FollowRequestsController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def authorize |   def authorize | ||||||
|     AuthorizeFollowService.new.call(account, current_account) |     AuthorizeFollowService.new.call(account, current_account) | ||||||
|     NotifyService.new.call(current_account, Follow.find_by(account: account, target_account: current_account)) |     NotifyService.new.call(current_account, :follow, Follow.find_by(account: account, target_account: current_account)) | ||||||
|     render json: account, serializer: REST::RelationshipSerializer, relationships: relationships |     render json: account, serializer: REST::RelationshipSerializer, relationships: relationships | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | @ -37,7 +37,7 @@ class Api::V1::FollowRequestsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def default_accounts |   def default_accounts | ||||||
|     Account.includes(:follow_requests, :account_stat).references(:follow_requests) |     Account.without_suspended.includes(:follow_requests, :account_stat).references(:follow_requests) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def paginated_follow_requests |   def paginated_follow_requests | ||||||
|  |  | ||||||
|  | @ -37,9 +37,9 @@ class Api::V1::Lists::AccountsController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def load_accounts |   def load_accounts | ||||||
|     if unlimited? |     if unlimited? | ||||||
|       @list.accounts.includes(:account_stat).all |       @list.accounts.without_suspended.includes(:account_stat).all | ||||||
|     else |     else | ||||||
|       @list.accounts.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) |       @list.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -27,6 +27,8 @@ class Api::V1::MutesController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def paginated_mutes |   def paginated_mutes | ||||||
|     @paginated_mutes ||= Mute.eager_load(:target_account) |     @paginated_mutes ||= Mute.eager_load(:target_account) | ||||||
|  |                              .joins(:target_account) | ||||||
|  |                              .merge(Account.without_suspended) | ||||||
|                              .where(account: current_account) |                              .where(account: current_account) | ||||||
|                              .paginate_by_max_id( |                              .paginate_by_max_id( | ||||||
|                                limit_param(DEFAULT_ACCOUNTS_LIMIT), |                                limit_param(DEFAULT_ACCOUNTS_LIMIT), | ||||||
|  |  | ||||||
|  | @ -14,7 +14,7 @@ class Api::V1::NotificationsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|     @notification = current_account.notifications.find(params[:id]) |     @notification = current_account.notifications.without_suspended.find(params[:id]) | ||||||
|     render json: @notification, serializer: REST::NotificationSerializer |     render json: @notification, serializer: REST::NotificationSerializer | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  | @ -49,7 +49,7 @@ class Api::V1::NotificationsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def browserable_account_notifications |   def browserable_account_notifications | ||||||
|     current_account.notifications.browserable(exclude_types, from_account) |     current_account.notifications.without_suspended.browserable(exclude_types, from_account) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def target_statuses_from_notifications |   def target_statuses_from_notifications | ||||||
|  |  | ||||||
|  | @ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController | ||||||
|   def data_params |   def data_params | ||||||
|     return {} if params[:data].blank? |     return {} if params[:data].blank? | ||||||
| 
 | 
 | ||||||
|     params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) |     params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -22,6 +22,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController | ||||||
| 
 | 
 | ||||||
|   def default_accounts |   def default_accounts | ||||||
|     Account |     Account | ||||||
|  |       .without_suspended | ||||||
|       .includes(:favourites, :account_stat) |       .includes(:favourites, :account_stat) | ||||||
|       .references(:favourites) |       .references(:favourites) | ||||||
|       .where(favourites: { status_id: @status.id }) |       .where(favourites: { status_id: @status.id }) | ||||||
|  |  | ||||||
|  | @ -21,7 +21,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def default_accounts |   def default_accounts | ||||||
|     Account.includes(:statuses, :account_stat).references(:statuses) |     Account.without_suspended.includes(:statuses, :account_stat).references(:statuses) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def paginated_statuses |   def paginated_statuses | ||||||
|  |  | ||||||
|  | @ -22,6 +22,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController | ||||||
|         reblog: alerts_enabled, |         reblog: alerts_enabled, | ||||||
|         mention: alerts_enabled, |         mention: alerts_enabled, | ||||||
|         poll: alerts_enabled, |         poll: alerts_enabled, | ||||||
|  |         status: alerts_enabled, | ||||||
|       }, |       }, | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  | @ -57,6 +58,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def data_params |   def data_params | ||||||
|     @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) |     @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status]) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -5,7 +5,6 @@ module ExportControllerConcern | ||||||
| 
 | 
 | ||||||
|   included do |   included do | ||||||
|     before_action :authenticate_user! |     before_action :authenticate_user! | ||||||
|     before_action :require_not_suspended! |  | ||||||
|     before_action :load_export |     before_action :load_export | ||||||
| 
 | 
 | ||||||
|     skip_before_action :require_functional! |     skip_before_action :require_functional! | ||||||
|  | @ -30,8 +29,4 @@ module ExportControllerConcern | ||||||
|   def export_filename |   def export_filename | ||||||
|     "#{controller_name}.csv" |     "#{controller_name}.csv" | ||||||
|   end |   end | ||||||
| 
 |  | ||||||
|   def require_not_suspended! |  | ||||||
|     forbidden if current_account.suspended? |  | ||||||
|   end |  | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -6,6 +6,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio | ||||||
|   before_action :store_current_location |   before_action :store_current_location | ||||||
|   before_action :authenticate_resource_owner! |   before_action :authenticate_resource_owner! | ||||||
|   before_action :set_pack |   before_action :set_pack | ||||||
|  |   before_action :require_not_suspended!, only: :destroy | ||||||
|   before_action :set_body_classes |   before_action :set_body_classes | ||||||
| 
 | 
 | ||||||
|   skip_before_action :require_functional! |   skip_before_action :require_functional! | ||||||
|  | @ -30,4 +31,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio | ||||||
|   def set_pack |   def set_pack | ||||||
|     use_pack 'settings' |     use_pack 'settings' | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def require_not_suspended! | ||||||
|  |     forbidden if current_account.suspended? | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -1,9 +1,9 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::AliasesController < Settings::BaseController | class Settings::AliasesController < Settings::BaseController | ||||||
|   layout 'admin' |   skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|   before_action :authenticate_user! |   before_action :require_not_suspended! | ||||||
|   before_action :set_aliases, except: :destroy |   before_action :set_aliases, except: :destroy | ||||||
|   before_action :set_alias, only: :destroy |   before_action :set_alias, only: :destroy | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,9 +1,6 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::ApplicationsController < Settings::BaseController | class Settings::ApplicationsController < Settings::BaseController | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :set_application, only: [:show, :update, :destroy, :regenerate] |   before_action :set_application, only: [:show, :update, :destroy, :regenerate] | ||||||
|   before_action :prepare_scopes, only: [:create, :update] |   before_action :prepare_scopes, only: [:create, :update] | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -2,6 +2,9 @@ | ||||||
| 
 | 
 | ||||||
| class Settings::BaseController < ApplicationController | class Settings::BaseController < ApplicationController | ||||||
|   before_action :set_pack |   before_action :set_pack | ||||||
|  |   layout 'admin' | ||||||
|  | 
 | ||||||
|  |   before_action :authenticate_user! | ||||||
|   before_action :set_body_classes |   before_action :set_body_classes | ||||||
|   before_action :set_cache_headers |   before_action :set_cache_headers | ||||||
| 
 | 
 | ||||||
|  | @ -18,4 +21,8 @@ class Settings::BaseController < ApplicationController | ||||||
|   def set_cache_headers |   def set_cache_headers | ||||||
|     response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' |     response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' | ||||||
|   end |   end | ||||||
|  | 
 | ||||||
|  |   def require_not_suspended! | ||||||
|  |     forbidden if current_account.suspended? | ||||||
|  |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -1,14 +1,11 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::DeletesController < Settings::BaseController | class Settings::DeletesController < Settings::BaseController | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :check_enabled_deletion |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :require_not_suspended! |  | ||||||
| 
 |  | ||||||
|   skip_before_action :require_functional! |   skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|  |   before_action :require_not_suspended! | ||||||
|  |   before_action :check_enabled_deletion | ||||||
|  | 
 | ||||||
|   def show |   def show | ||||||
|     @confirmation = Form::DeleteConfirmation.new |     @confirmation = Form::DeleteConfirmation.new | ||||||
|   end |   end | ||||||
|  | @ -46,7 +43,7 @@ class Settings::DeletesController < Settings::BaseController | ||||||
| 
 | 
 | ||||||
|   def destroy_account! |   def destroy_account! | ||||||
|     current_account.suspend! |     current_account.suspend! | ||||||
|     Admin::SuspensionWorker.perform_async(current_user.account_id, true) |     AccountDeletionWorker.perform_async(current_user.account_id) | ||||||
|     sign_out |     sign_out | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| 
 | 
 | ||||||
| module Settings | module Settings | ||||||
|   module Exports |   module Exports | ||||||
|     class BlockedAccountsController < ApplicationController |     class BlockedAccountsController < BaseController | ||||||
|       include ExportControllerConcern |       include ExportControllerConcern | ||||||
| 
 | 
 | ||||||
|       def index |       def index | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| 
 | 
 | ||||||
| module Settings | module Settings | ||||||
|   module Exports |   module Exports | ||||||
|     class BlockedDomainsController < ApplicationController |     class BlockedDomainsController < BaseController | ||||||
|       include ExportControllerConcern |       include ExportControllerConcern | ||||||
| 
 | 
 | ||||||
|       def index |       def index | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| 
 | 
 | ||||||
| module Settings | module Settings | ||||||
|   module Exports |   module Exports | ||||||
|     class FollowingAccountsController < ApplicationController |     class FollowingAccountsController < BaseController | ||||||
|       include ExportControllerConcern |       include ExportControllerConcern | ||||||
| 
 | 
 | ||||||
|       def index |       def index | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| 
 | 
 | ||||||
| module Settings | module Settings | ||||||
|   module Exports |   module Exports | ||||||
|     class ListsController < ApplicationController |     class ListsController < BaseController | ||||||
|       include ExportControllerConcern |       include ExportControllerConcern | ||||||
| 
 | 
 | ||||||
|       def index |       def index | ||||||
|  |  | ||||||
|  | @ -2,7 +2,7 @@ | ||||||
| 
 | 
 | ||||||
| module Settings | module Settings | ||||||
|   module Exports |   module Exports | ||||||
|     class MutedAccountsController < ApplicationController |     class MutedAccountsController < BaseController | ||||||
|       include ExportControllerConcern |       include ExportControllerConcern | ||||||
| 
 | 
 | ||||||
|       def index |       def index | ||||||
|  |  | ||||||
|  | @ -3,11 +3,6 @@ | ||||||
| class Settings::ExportsController < Settings::BaseController | class Settings::ExportsController < Settings::BaseController | ||||||
|   include Authorization |   include Authorization | ||||||
| 
 | 
 | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :require_not_suspended! |  | ||||||
| 
 |  | ||||||
|   skip_before_action :require_functional! |   skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|  | @ -16,8 +11,6 @@ class Settings::ExportsController < Settings::BaseController | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def create |   def create | ||||||
|     raise Mastodon::NotPermittedError unless user_signed_in? |  | ||||||
| 
 |  | ||||||
|     backup = nil |     backup = nil | ||||||
| 
 | 
 | ||||||
|     RedisLock.acquire(lock_options) do |lock| |     RedisLock.acquire(lock_options) do |lock| | ||||||
|  | @ -37,8 +30,4 @@ class Settings::ExportsController < Settings::BaseController | ||||||
|   def lock_options |   def lock_options | ||||||
|     { redis: Redis.current, key: "backup:#{current_user.id}" } |     { redis: Redis.current, key: "backup:#{current_user.id}" } | ||||||
|   end |   end | ||||||
| 
 |  | ||||||
|   def require_not_suspended! |  | ||||||
|     forbidden if current_account.suspended? |  | ||||||
|   end |  | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -1,9 +1,6 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::FeaturedTagsController < Settings::BaseController | class Settings::FeaturedTagsController < Settings::BaseController | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :set_featured_tags, only: :index |   before_action :set_featured_tags, only: :index | ||||||
|   before_action :set_featured_tag, except: [:index, :create] |   before_action :set_featured_tag, except: [:index, :create] | ||||||
|   before_action :set_recently_used_tags, only: :index |   before_action :set_recently_used_tags, only: :index | ||||||
|  |  | ||||||
|  | @ -1,9 +1,6 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::IdentityProofsController < Settings::BaseController | class Settings::IdentityProofsController < Settings::BaseController | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :check_required_params, only: :new |   before_action :check_required_params, only: :new | ||||||
|   before_action :check_enabled, only: :new |   before_action :check_enabled, only: :new | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,9 +1,6 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::ImportsController < Settings::BaseController | class Settings::ImportsController < Settings::BaseController | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :set_account |   before_action :set_account | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|  |  | ||||||
|  | @ -1,13 +1,10 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::Migration::RedirectsController < Settings::BaseController | class Settings::Migration::RedirectsController < Settings::BaseController | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :require_not_suspended! |  | ||||||
| 
 |  | ||||||
|   skip_before_action :require_functional! |   skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|  |   before_action :require_not_suspended! | ||||||
|  | 
 | ||||||
|   def new |   def new | ||||||
|     @redirect = Form::Redirect.new |     @redirect = Form::Redirect.new | ||||||
|   end |   end | ||||||
|  | @ -38,8 +35,4 @@ class Settings::Migration::RedirectsController < Settings::BaseController | ||||||
|   def resource_params |   def resource_params | ||||||
|     params.require(:form_redirect).permit(:acct, :current_password, :current_username) |     params.require(:form_redirect).permit(:acct, :current_password, :current_username) | ||||||
|   end |   end | ||||||
| 
 |  | ||||||
|   def require_not_suspended! |  | ||||||
|     forbidden if current_account.suspended? |  | ||||||
|   end |  | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -1,15 +1,12 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::MigrationsController < Settings::BaseController | class Settings::MigrationsController < Settings::BaseController | ||||||
|   layout 'admin' |   skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :require_not_suspended! |   before_action :require_not_suspended! | ||||||
|   before_action :set_migrations |   before_action :set_migrations | ||||||
|   before_action :set_cooldown |   before_action :set_cooldown | ||||||
| 
 | 
 | ||||||
|   skip_before_action :require_functional! |  | ||||||
| 
 |  | ||||||
|   def show |   def show | ||||||
|     @migration = current_account.migrations.build |     @migration = current_account.migrations.build | ||||||
|   end |   end | ||||||
|  | @ -44,8 +41,4 @@ class Settings::MigrationsController < Settings::BaseController | ||||||
|   def on_cooldown? |   def on_cooldown? | ||||||
|     @cooldown.present? |     @cooldown.present? | ||||||
|   end |   end | ||||||
| 
 |  | ||||||
|   def require_not_suspended! |  | ||||||
|     forbidden if current_account.suspended? |  | ||||||
|   end |  | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -2,7 +2,6 @@ | ||||||
| 
 | 
 | ||||||
| module Settings | module Settings | ||||||
|   class PicturesController < BaseController |   class PicturesController < BaseController | ||||||
|     before_action :authenticate_user! |  | ||||||
|     before_action :set_account |     before_action :set_account | ||||||
|     before_action :set_picture |     before_action :set_picture | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,10 +1,6 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::PreferencesController < Settings::BaseController | class Settings::PreferencesController < Settings::BaseController | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :authenticate_user! |  | ||||||
| 
 |  | ||||||
|   def show; end |   def show; end | ||||||
| 
 | 
 | ||||||
|   def update |   def update | ||||||
|  |  | ||||||
|  | @ -1,9 +1,6 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| class Settings::ProfilesController < Settings::BaseController | class Settings::ProfilesController < Settings::BaseController | ||||||
|   layout 'admin' |  | ||||||
| 
 |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :set_account |   before_action :set_account | ||||||
| 
 | 
 | ||||||
|   def show |   def show | ||||||
|  |  | ||||||
|  | @ -1,12 +1,11 @@ | ||||||
| # frozen_string_literal: true | # frozen_string_literal: true | ||||||
| 
 | 
 | ||||||
| #  Intentionally does not inherit from BaseController | class Settings::SessionsController < Settings::BaseController | ||||||
| class Settings::SessionsController < ApplicationController |  | ||||||
|   before_action :authenticate_user! |  | ||||||
|   before_action :set_session, only: :destroy |  | ||||||
| 
 |  | ||||||
|   skip_before_action :require_functional! |   skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|  |   before_action :require_not_suspended! | ||||||
|  |   before_action :set_session, only: :destroy | ||||||
|  | 
 | ||||||
|   def destroy |   def destroy | ||||||
|     @session.destroy! |     @session.destroy! | ||||||
|     flash[:notice] = I18n.t('sessions.revoke_success') |     flash[:notice] = I18n.t('sessions.revoke_success') | ||||||
|  |  | ||||||
|  | @ -5,14 +5,11 @@ module Settings | ||||||
|     class ConfirmationsController < BaseController |     class ConfirmationsController < BaseController | ||||||
|       include ChallengableConcern |       include ChallengableConcern | ||||||
| 
 | 
 | ||||||
|       layout 'admin' |       skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|       before_action :authenticate_user! |  | ||||||
|       before_action :require_challenge! |       before_action :require_challenge! | ||||||
|       before_action :ensure_otp_secret |       before_action :ensure_otp_secret | ||||||
| 
 | 
 | ||||||
|       skip_before_action :require_functional! |  | ||||||
| 
 |  | ||||||
|       def new |       def new | ||||||
|         prepare_two_factor_form |         prepare_two_factor_form | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  | @ -5,14 +5,11 @@ module Settings | ||||||
|     class OtpAuthenticationController < BaseController |     class OtpAuthenticationController < BaseController | ||||||
|       include ChallengableConcern |       include ChallengableConcern | ||||||
| 
 | 
 | ||||||
|       layout 'admin' |       skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|       before_action :authenticate_user! |  | ||||||
|       before_action :verify_otp_not_enabled, only: [:show] |       before_action :verify_otp_not_enabled, only: [:show] | ||||||
|       before_action :require_challenge!, only: [:create] |       before_action :require_challenge!, only: [:create] | ||||||
| 
 | 
 | ||||||
|       skip_before_action :require_functional! |  | ||||||
| 
 |  | ||||||
|       def show |       def show | ||||||
|         @confirmation = Form::TwoFactorConfirmation.new |         @confirmation = Form::TwoFactorConfirmation.new | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  | @ -5,13 +5,10 @@ module Settings | ||||||
|     class RecoveryCodesController < BaseController |     class RecoveryCodesController < BaseController | ||||||
|       include ChallengableConcern |       include ChallengableConcern | ||||||
| 
 | 
 | ||||||
|       layout 'admin' |  | ||||||
| 
 |  | ||||||
|       before_action :authenticate_user! |  | ||||||
|       before_action :require_challenge!, on: :create |  | ||||||
| 
 |  | ||||||
|       skip_before_action :require_functional! |       skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|  |       before_action :require_challenge!, on: :create | ||||||
|  | 
 | ||||||
|       def create |       def create | ||||||
|         @recovery_codes = current_user.generate_otp_backup_codes! |         @recovery_codes = current_user.generate_otp_backup_codes! | ||||||
|         current_user.save! |         current_user.save! | ||||||
|  |  | ||||||
|  | @ -3,9 +3,8 @@ | ||||||
| module Settings | module Settings | ||||||
|   module TwoFactorAuthentication |   module TwoFactorAuthentication | ||||||
|     class WebauthnCredentialsController < BaseController |     class WebauthnCredentialsController < BaseController | ||||||
|       layout 'admin' |       skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|       before_action :authenticate_user! |  | ||||||
|       before_action :require_otp_enabled |       before_action :require_otp_enabled | ||||||
|       before_action :require_webauthn_enabled, only: [:index, :destroy] |       before_action :require_webauthn_enabled, only: [:index, :destroy] | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -4,14 +4,11 @@ module Settings | ||||||
|   class TwoFactorAuthenticationMethodsController < BaseController |   class TwoFactorAuthenticationMethodsController < BaseController | ||||||
|     include ChallengableConcern |     include ChallengableConcern | ||||||
| 
 | 
 | ||||||
|     layout 'admin' |     skip_before_action :require_functional! | ||||||
| 
 | 
 | ||||||
|     before_action :authenticate_user! |  | ||||||
|     before_action :require_challenge!, only: :disable |     before_action :require_challenge!, only: :disable | ||||||
|     before_action :require_otp_enabled |     before_action :require_otp_enabled | ||||||
| 
 | 
 | ||||||
|     skip_before_action :require_functional! |  | ||||||
| 
 |  | ||||||
|     def index; end |     def index; end | ||||||
| 
 | 
 | ||||||
|     def disable |     def disable | ||||||
|  |  | ||||||
|  | @ -109,14 +109,14 @@ export function fetchAccountFail(id, error) { | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export function followAccount(id, reblogs = true) { | export function followAccount(id, options = { reblogs: true }) { | ||||||
|   return (dispatch, getState) => { |   return (dispatch, getState) => { | ||||||
|     const alreadyFollowing = getState().getIn(['relationships', id, 'following']); |     const alreadyFollowing = getState().getIn(['relationships', id, 'following']); | ||||||
|     const locked = getState().getIn(['accounts', id, 'locked'], false); |     const locked = getState().getIn(['accounts', id, 'locked'], false); | ||||||
| 
 | 
 | ||||||
|     dispatch(followAccountRequest(id, locked)); |     dispatch(followAccountRequest(id, locked)); | ||||||
| 
 | 
 | ||||||
|     api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { |     api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => { | ||||||
|       dispatch(followAccountSuccess(response.data, alreadyFollowing)); |       dispatch(followAccountSuccess(response.data, alreadyFollowing)); | ||||||
|     }).catch(error => { |     }).catch(error => { | ||||||
|       dispatch(followAccountFail(error, locked)); |       dispatch(followAccountFail(error, locked)); | ||||||
|  |  | ||||||
|  | @ -3,6 +3,9 @@ import { debounce } from 'lodash'; | ||||||
| import compareId from '../compare_id'; | import compareId from '../compare_id'; | ||||||
| import { showAlertForError } from './alerts'; | import { showAlertForError } from './alerts'; | ||||||
| 
 | 
 | ||||||
|  | export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST'; | ||||||
|  | export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS'; | ||||||
|  | export const MARKERS_FETCH_FAIL    = 'MARKERS_FETCH_FAIL'; | ||||||
| export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; | export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; | ||||||
| 
 | 
 | ||||||
| export const synchronouslySubmitMarkers = () => (dispatch, getState) => { | export const synchronouslySubmitMarkers = () => (dispatch, getState) => { | ||||||
|  | @ -57,8 +60,8 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => { | ||||||
| const _buildParams = (state) => { | const _buildParams = (state) => { | ||||||
|   const params = {}; |   const params = {}; | ||||||
| 
 | 
 | ||||||
|   const lastHomeId         = state.getIn(['timelines', 'home', 'items', 0]); |   const lastHomeId         = state.getIn(['timelines', 'home', 'items']).find(item => item !== null); | ||||||
|   const lastNotificationId = state.getIn(['notifications', 'items', 0, 'id']); |   const lastNotificationId = state.getIn(['notifications', 'lastReadId']); | ||||||
| 
 | 
 | ||||||
|   if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { |   if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { | ||||||
|     params.home = { |     params.home = { | ||||||
|  | @ -100,3 +103,39 @@ export function submitMarkersSuccess({ home, notifications }) { | ||||||
| export function submitMarkers() { | export function submitMarkers() { | ||||||
|   return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); |   return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); | ||||||
| }; | }; | ||||||
|  | 
 | ||||||
|  | export const fetchMarkers = () => (dispatch, getState) => { | ||||||
|  |   const params = { timeline: ['notifications'] }; | ||||||
|  | 
 | ||||||
|  |   dispatch(fetchMarkersRequest()); | ||||||
|  | 
 | ||||||
|  |   api(getState).get('/api/v1/markers', { params }).then(response => { | ||||||
|  |     dispatch(fetchMarkersSuccess(response.data)); | ||||||
|  |   }).catch(error => { | ||||||
|  |     dispatch(fetchMarkersFail(error)); | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export function fetchMarkersRequest() { | ||||||
|  |   return { | ||||||
|  |     type: MARKERS_FETCH_REQUEST, | ||||||
|  |     skipLoading: true, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export function fetchMarkersSuccess(markers) { | ||||||
|  |   return { | ||||||
|  |     type: MARKERS_FETCH_SUCCESS, | ||||||
|  |     markers, | ||||||
|  |     skipLoading: true, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export function fetchMarkersFail(error) { | ||||||
|  |   return { | ||||||
|  |     type: MARKERS_FETCH_FAIL, | ||||||
|  |     error, | ||||||
|  |     skipLoading: true, | ||||||
|  |     skipAlert: true, | ||||||
|  |   }; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | @ -33,6 +33,8 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING'; | ||||||
| export const NOTIFICATIONS_MOUNT   = 'NOTIFICATIONS_MOUNT'; | export const NOTIFICATIONS_MOUNT   = 'NOTIFICATIONS_MOUNT'; | ||||||
| export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; | export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; | ||||||
| 
 | 
 | ||||||
|  | export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ'; | ||||||
|  | 
 | ||||||
| defineMessages({ | defineMessages({ | ||||||
|   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, |   mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, | ||||||
|   group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, |   group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, | ||||||
|  | @ -59,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) { | ||||||
| 
 | 
 | ||||||
|     let filtered = false; |     let filtered = false; | ||||||
| 
 | 
 | ||||||
|     if (notification.type === 'mention') { |     if (['mention', 'status'].includes(notification.type)) { | ||||||
|       const dropRegex   = filters[0]; |       const dropRegex   = filters[0]; | ||||||
|       const regex       = filters[1]; |       const regex       = filters[1]; | ||||||
|       const searchIndex = searchTextFromRawStatus(notification.status); |       const searchIndex = searchTextFromRawStatus(notification.status); | ||||||
|  |  | ||||||
|  | @ -0,0 +1,38 @@ | ||||||
|  | // @ts-check
 | ||||||
|  | 
 | ||||||
|  | export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY'; | ||||||
|  | export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE'; | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * @typedef MediaProps | ||||||
|  |  * @property {string} src | ||||||
|  |  * @property {boolean} muted | ||||||
|  |  * @property {number} volume | ||||||
|  |  * @property {number} currentTime | ||||||
|  |  * @property {string} poster | ||||||
|  |  * @property {string} backgroundColor | ||||||
|  |  * @property {string} foregroundColor | ||||||
|  |  * @property {string} accentColor | ||||||
|  |  */ | ||||||
|  | 
 | ||||||
|  | /** | ||||||
|  |  * @param {string} statusId | ||||||
|  |  * @param {string} accountId | ||||||
|  |  * @param {string} playerType | ||||||
|  |  * @param {MediaProps} props | ||||||
|  |  * @return {object} | ||||||
|  |  */ | ||||||
|  | export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({ | ||||||
|  |   type: PICTURE_IN_PICTURE_DEPLOY, | ||||||
|  |   statusId, | ||||||
|  |   accountId, | ||||||
|  |   playerType, | ||||||
|  |   props, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | /* | ||||||
|  |  * @return {object} | ||||||
|  |  */ | ||||||
|  | export const removePictureInPicture = () => ({ | ||||||
|  |   type: PICTURE_IN_PICTURE_REMOVE, | ||||||
|  | }); | ||||||
|  | @ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion'; | ||||||
| import spring from 'react-motion/lib/spring'; | import spring from 'react-motion/lib/spring'; | ||||||
| import { reduceMotion } from 'mastodon/initial_state'; | import { reduceMotion } from 'mastodon/initial_state'; | ||||||
| 
 | 
 | ||||||
|  | const obfuscatedCount = count => { | ||||||
|  |   if (count < 0) { | ||||||
|  |     return 0; | ||||||
|  |   } else if (count <= 1) { | ||||||
|  |     return count; | ||||||
|  |   } else { | ||||||
|  |     return '1+'; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export default class AnimatedNumber extends React.PureComponent { | export default class AnimatedNumber extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|   static propTypes = { |   static propTypes = { | ||||||
|     value: PropTypes.number.isRequired, |     value: PropTypes.number.isRequired, | ||||||
|  |     obfuscate: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { value } = this.props; |     const { value, obfuscate } = this.props; | ||||||
|     const { direction } = this.state; |     const { direction } = this.state; | ||||||
| 
 | 
 | ||||||
|     if (reduceMotion) { |     if (reduceMotion) { | ||||||
|       return <FormattedNumber value={value} />; |       return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     const styles = [{ |     const styles = [{ | ||||||
|  | @ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent { | ||||||
|         {items => ( |         {items => ( | ||||||
|           <span className='animated-number'> |           <span className='animated-number'> | ||||||
|             {items.map(({ key, data, style }) => ( |             {items.map(({ key, data, style }) => ( | ||||||
|               <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span> |               <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span> | ||||||
|             ))} |             ))} | ||||||
|           </span> |           </span> | ||||||
|         )} |         )} | ||||||
|  |  | ||||||
|  | @ -66,17 +66,31 @@ export default class ErrorBoundary extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render() { |   render() { | ||||||
|     const { hasError, copied } = this.state; |     const { hasError, copied, errorMessage } = this.state; | ||||||
| 
 | 
 | ||||||
|     if (!hasError) { |     if (!hasError) { | ||||||
|       return this.props.children; |       return this.props.children; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError'); | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div className='error-boundary'> |       <div className='error-boundary'> | ||||||
|         <div> |         <div> | ||||||
|           <p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p> |           <p className='error-boundary__error'> | ||||||
|           <p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p> |             { likelyBrowserAddonIssue ? ( | ||||||
|  |               <FormattedMessage id='error.unexpected_crash.explanation_addons' defaultMessage='This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.' /> | ||||||
|  |             ) : ( | ||||||
|  |               <FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /> | ||||||
|  |             )} | ||||||
|  |           </p> | ||||||
|  |           <p> | ||||||
|  |             { likelyBrowserAddonIssue ? ( | ||||||
|  |               <FormattedMessage id='error.unexpected_crash.next_steps_addons' defaultMessage='Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /> | ||||||
|  |             ) : ( | ||||||
|  |               <FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /> | ||||||
|  |             )} | ||||||
|  |           </p> | ||||||
|           <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p> |           <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|  | @ -2,6 +2,7 @@ import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import Icon from 'mastodon/components/icon'; | import Icon from 'mastodon/components/icon'; | ||||||
|  | import AnimatedNumber from 'mastodon/components/animated_number'; | ||||||
| 
 | 
 | ||||||
| export default class IconButton extends React.PureComponent { | export default class IconButton extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|  | @ -24,6 +25,8 @@ export default class IconButton extends React.PureComponent { | ||||||
|     animate: PropTypes.bool, |     animate: PropTypes.bool, | ||||||
|     overlay: PropTypes.bool, |     overlay: PropTypes.bool, | ||||||
|     tabIndex: PropTypes.string, |     tabIndex: PropTypes.string, | ||||||
|  |     counter: PropTypes.number, | ||||||
|  |     obfuscateCount: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|  | @ -97,6 +100,8 @@ export default class IconButton extends React.PureComponent { | ||||||
|       pressed, |       pressed, | ||||||
|       tabIndex, |       tabIndex, | ||||||
|       title, |       title, | ||||||
|  |       counter, | ||||||
|  |       obfuscateCount, | ||||||
|     } = this.props; |     } = this.props; | ||||||
| 
 | 
 | ||||||
|     const { |     const { | ||||||
|  | @ -113,6 +118,10 @@ export default class IconButton extends React.PureComponent { | ||||||
|       overlayed: overlay, |       overlayed: overlay, | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     if (typeof counter !== 'undefined') { | ||||||
|  |       style.width = 'auto'; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|       <button |       <button | ||||||
|         aria-label={title} |         aria-label={title} | ||||||
|  | @ -128,7 +137,7 @@ export default class IconButton extends React.PureComponent { | ||||||
|         tabIndex={tabIndex} |         tabIndex={tabIndex} | ||||||
|         disabled={disabled} |         disabled={disabled} | ||||||
|       > |       > | ||||||
|         <Icon id={icon} fixedWidth aria-hidden='true' /> |         <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>} | ||||||
|       </button> |       </button> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -2,10 +2,7 @@ import React from 'react'; | ||||||
| import PropTypes from 'prop-types'; | import PropTypes from 'prop-types'; | ||||||
| import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; | import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; | ||||||
| import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; | import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; | ||||||
| import { is } from 'immutable'; |  | ||||||
| 
 | 
 | ||||||
| // Diff these props in the "rendered" state
 |  | ||||||
| const updateOnPropsForRendered = ['id', 'index', 'listLength']; |  | ||||||
| // Diff these props in the "unrendered" state
 | // Diff these props in the "unrendered" state
 | ||||||
| const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; | const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; | ||||||
| 
 | 
 | ||||||
|  | @ -33,9 +30,12 @@ export default class IntersectionObserverArticle extends React.Component { | ||||||
|       // If we're going from rendered to unrendered (or vice versa) then update
 |       // If we're going from rendered to unrendered (or vice versa) then update
 | ||||||
|       return true; |       return true; | ||||||
|     } |     } | ||||||
|     // Otherwise, diff based on props
 |     // If we are and remain hidden, diff based on props
 | ||||||
|     const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered; |     if (isUnrendered) { | ||||||
|     return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop])); |       return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]); | ||||||
|  |     } | ||||||
|  |     // Else, assume the children have changed
 | ||||||
|  |     return true; | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentDidMount () { |   componentDidMount () { | ||||||
|  |  | ||||||
|  | @ -0,0 +1,69 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import Icon from 'mastodon/components/icon'; | ||||||
|  | import { removePictureInPicture } from 'mastodon/actions/picture_in_picture'; | ||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | import { debounce } from 'lodash'; | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
|  | 
 | ||||||
|  | export default @connect() | ||||||
|  | class PictureInPicturePlaceholder extends React.PureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     width: PropTypes.number, | ||||||
|  |     dispatch: PropTypes.func.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   state = { | ||||||
|  |     width: this.props.width, | ||||||
|  |     height: this.props.width && (this.props.width / (16/9)), | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleClick = () => { | ||||||
|  |     const { dispatch } = this.props; | ||||||
|  |     dispatch(removePictureInPicture()); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   setRef = c => { | ||||||
|  |     this.node = c; | ||||||
|  | 
 | ||||||
|  |     if (this.node) { | ||||||
|  |       this._setDimensions(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   _setDimensions () { | ||||||
|  |     const width  = this.node.offsetWidth; | ||||||
|  |     const height = width / (16/9); | ||||||
|  | 
 | ||||||
|  |     this.setState({ width, height }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentDidMount () { | ||||||
|  |     window.addEventListener('resize', this.handleResize, { passive: true }); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   componentWillUnmount () { | ||||||
|  |     window.removeEventListener('resize', this.handleResize); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleResize = debounce(() => { | ||||||
|  |     if (this.node) { | ||||||
|  |       this._setDimensions(); | ||||||
|  |     } | ||||||
|  |   }, 250, { | ||||||
|  |     trailing: true, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { height } = this.state; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}> | ||||||
|  |         <Icon id='window-restore' /> | ||||||
|  |         <FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -17,6 +17,7 @@ import { HotKeys } from 'react-hotkeys'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import Icon from 'mastodon/components/icon'; | import Icon from 'mastodon/components/icon'; | ||||||
| import { displayMedia } from '../initial_state'; | import { displayMedia } from '../initial_state'; | ||||||
|  | import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; | ||||||
| 
 | 
 | ||||||
| // We use the component (and not the container) since we do not want
 | // We use the component (and not the container) since we do not want
 | ||||||
| // to use the progress bar to show download progress
 | // to use the progress bar to show download progress
 | ||||||
|  | @ -95,6 +96,8 @@ class Status extends ImmutablePureComponent { | ||||||
|     cacheMediaWidth: PropTypes.func, |     cacheMediaWidth: PropTypes.func, | ||||||
|     cachedMediaWidth: PropTypes.number, |     cachedMediaWidth: PropTypes.number, | ||||||
|     scrollKey: PropTypes.string, |     scrollKey: PropTypes.string, | ||||||
|  |     deployPictureInPicture: PropTypes.func, | ||||||
|  |     usingPiP: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   // Avoid checking props that are functions (and whose equality will always
 |   // Avoid checking props that are functions (and whose equality will always
 | ||||||
|  | @ -104,6 +107,8 @@ class Status extends ImmutablePureComponent { | ||||||
|     'account', |     'account', | ||||||
|     'muted', |     'muted', | ||||||
|     'hidden', |     'hidden', | ||||||
|  |     'unread', | ||||||
|  |     'usingPiP', | ||||||
|   ]; |   ]; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -205,6 +210,13 @@ class Status extends ImmutablePureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   handleDeployPictureInPicture = (type, mediaProps) => { | ||||||
|  |     const { deployPictureInPicture } = this.props; | ||||||
|  |     const status = this._properStatus(); | ||||||
|  | 
 | ||||||
|  |     deployPictureInPicture(status, type, mediaProps); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   handleHotkeyReply = e => { |   handleHotkeyReply = e => { | ||||||
|     e.preventDefault(); |     e.preventDefault(); | ||||||
|     this.props.onReply(this._properStatus(), this.context.router.history); |     this.props.onReply(this._properStatus(), this.context.router.history); | ||||||
|  | @ -265,7 +277,7 @@ class Status extends ImmutablePureComponent { | ||||||
|     let media = null; |     let media = null; | ||||||
|     let statusAvatar, prepend, rebloggedByText; |     let statusAvatar, prepend, rebloggedByText; | ||||||
| 
 | 
 | ||||||
|     const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey } = this.props; |     const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props; | ||||||
| 
 | 
 | ||||||
|     let { status, account, ...other } = this.props; |     let { status, account, ...other } = this.props; | ||||||
| 
 | 
 | ||||||
|  | @ -336,7 +348,9 @@ class Status extends ImmutablePureComponent { | ||||||
|       status  = status.get('reblog'); |       status  = status.get('reblog'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (status.get('media_attachments').size > 0) { |     if (usingPiP) { | ||||||
|  |       media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />; | ||||||
|  |     } else if (status.get('media_attachments').size > 0) { | ||||||
|       if (this.props.muted) { |       if (this.props.muted) { | ||||||
|         media = ( |         media = ( | ||||||
|           <AttachmentList |           <AttachmentList | ||||||
|  | @ -361,6 +375,7 @@ class Status extends ImmutablePureComponent { | ||||||
|                 width={this.props.cachedMediaWidth} |                 width={this.props.cachedMediaWidth} | ||||||
|                 height={110} |                 height={110} | ||||||
|                 cacheWidth={this.props.cacheMediaWidth} |                 cacheWidth={this.props.cacheMediaWidth} | ||||||
|  |                 deployPictureInPicture={this.handleDeployPictureInPicture} | ||||||
|               /> |               /> | ||||||
|             )} |             )} | ||||||
|           </Bundle> |           </Bundle> | ||||||
|  | @ -382,6 +397,7 @@ class Status extends ImmutablePureComponent { | ||||||
|                 sensitive={status.get('sensitive')} |                 sensitive={status.get('sensitive')} | ||||||
|                 onOpenVideo={this.handleOpenVideo} |                 onOpenVideo={this.handleOpenVideo} | ||||||
|                 cacheWidth={this.props.cacheMediaWidth} |                 cacheWidth={this.props.cacheMediaWidth} | ||||||
|  |                 deployPictureInPicture={this.handleDeployPictureInPicture} | ||||||
|                 visible={this.state.showMedia} |                 visible={this.state.showMedia} | ||||||
|                 onToggleVisibility={this.handleToggleMediaVisibility} |                 onToggleVisibility={this.handleToggleMediaVisibility} | ||||||
|               /> |               /> | ||||||
|  | @ -438,10 +454,10 @@ class Status extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <HotKeys handlers={handlers}> |       <HotKeys handlers={handlers}> | ||||||
|         <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}> |         <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}> | ||||||
|           {prepend} |           {prepend} | ||||||
| 
 | 
 | ||||||
|           <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}> |           <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}> | ||||||
|             <div className='status__expand' onClick={this.handleExpandClick} role='presentation' /> |             <div className='status__expand' onClick={this.handleExpandClick} role='presentation' /> | ||||||
|             <div className='status__info'> |             <div className='status__info'> | ||||||
|               <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a> |               <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a> | ||||||
|  |  | ||||||
|  | @ -43,16 +43,6 @@ const messages = defineMessages({ | ||||||
|   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, |   unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const obfuscatedCount = count => { |  | ||||||
|   if (count < 0) { |  | ||||||
|     return 0; |  | ||||||
|   } else if (count <= 1) { |  | ||||||
|     return count; |  | ||||||
|   } else { |  | ||||||
|     return '1+'; |  | ||||||
|   } |  | ||||||
| }; |  | ||||||
| 
 |  | ||||||
| const mapStateToProps = (state, { status }) => ({ | const mapStateToProps = (state, { status }) => ({ | ||||||
|   relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), |   relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), | ||||||
| }); | }); | ||||||
|  | @ -329,9 +319,10 @@ class StatusActionBar extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div className='status__action-bar'> |       <div className='status__action-bar'> | ||||||
|         <div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div> |         <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> | ||||||
|         <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /> |         <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /> | ||||||
|         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> |         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> | ||||||
|  | 
 | ||||||
|         {shareButton} |         {shareButton} | ||||||
| 
 | 
 | ||||||
|         <div className='status__action-bar-dropdown'> |         <div className='status__action-bar-dropdown'> | ||||||
|  |  | ||||||
|  | @ -37,6 +37,7 @@ import { initMuteModal } from '../actions/mutes'; | ||||||
| import { initBlockModal } from '../actions/blocks'; | import { initBlockModal } from '../actions/blocks'; | ||||||
| import { initReport } from '../actions/reports'; | import { initReport } from '../actions/reports'; | ||||||
| import { openModal } from '../actions/modal'; | import { openModal } from '../actions/modal'; | ||||||
|  | import { deployPictureInPicture } from '../actions/picture_in_picture'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
| import { boostModal, deleteModal } from '../initial_state'; | import { boostModal, deleteModal } from '../initial_state'; | ||||||
| import { showAlertForError } from '../actions/alerts'; | import { showAlertForError } from '../actions/alerts'; | ||||||
|  | @ -56,6 +57,7 @@ const makeMapStateToProps = () => { | ||||||
| 
 | 
 | ||||||
|   const mapStateToProps = (state, props) => ({ |   const mapStateToProps = (state, props) => ({ | ||||||
|     status: getStatus(state, props), |     status: getStatus(state, props), | ||||||
|  |     usingPiP: state.get('picture_in_picture').statusId === props.id, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
|   return mapStateToProps; |   return mapStateToProps; | ||||||
|  | @ -207,6 +209,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
|     dispatch(unblockDomain(domain)); |     dispatch(unblockDomain(domain)); | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|  |   deployPictureInPicture (status, type, mediaProps) { | ||||||
|  |     dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps)); | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); | ||||||
|  |  | ||||||
|  | @ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
| import { autoPlayGif, me, isStaff } from 'mastodon/initial_state'; | import { autoPlayGif, me, isStaff } from 'mastodon/initial_state'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import Icon from 'mastodon/components/icon'; | import Icon from 'mastodon/components/icon'; | ||||||
|  | import IconButton from 'mastodon/components/icon_button'; | ||||||
| import Avatar from 'mastodon/components/avatar'; | import Avatar from 'mastodon/components/avatar'; | ||||||
| import { counterRenderer } from 'mastodon/components/common_counter'; | import { counterRenderer } from 'mastodon/components/common_counter'; | ||||||
| import ShortNumber from 'mastodon/components/short_number'; | import ShortNumber from 'mastodon/components/short_number'; | ||||||
|  | @ -35,6 +36,8 @@ const messages = defineMessages({ | ||||||
|   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, |   unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, | ||||||
|   hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, |   hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, | ||||||
|   showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, |   showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, | ||||||
|  |   enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' }, | ||||||
|  |   disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' }, | ||||||
|   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, |   pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, | ||||||
|   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, |   preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, | ||||||
|   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, |   follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, | ||||||
|  | @ -68,8 +71,9 @@ class Header extends ImmutablePureComponent { | ||||||
|     onBlock: PropTypes.func.isRequired, |     onBlock: PropTypes.func.isRequired, | ||||||
|     onMention: PropTypes.func.isRequired, |     onMention: PropTypes.func.isRequired, | ||||||
|     onDirect: PropTypes.func.isRequired, |     onDirect: PropTypes.func.isRequired, | ||||||
|     onReport: PropTypes.func.isRequired, |  | ||||||
|     onReblogToggle: PropTypes.func.isRequired, |     onReblogToggle: PropTypes.func.isRequired, | ||||||
|  |     onNotifyToggle: PropTypes.func.isRequired, | ||||||
|  |     onReport: PropTypes.func.isRequired, | ||||||
|     onMute: PropTypes.func.isRequired, |     onMute: PropTypes.func.isRequired, | ||||||
|     onBlockDomain: PropTypes.func.isRequired, |     onBlockDomain: PropTypes.func.isRequired, | ||||||
|     onUnblockDomain: PropTypes.func.isRequired, |     onUnblockDomain: PropTypes.func.isRequired, | ||||||
|  | @ -140,8 +144,11 @@ class Header extends ImmutablePureComponent { | ||||||
|       return null; |       return null; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     const suspended = account.get('suspended'); | ||||||
|  | 
 | ||||||
|     let info        = []; |     let info        = []; | ||||||
|     let actionBtn   = ''; |     let actionBtn   = ''; | ||||||
|  |     let bellBtn     = ''; | ||||||
|     let lockedIcon  = ''; |     let lockedIcon  = ''; | ||||||
|     let menu        = []; |     let menu        = []; | ||||||
| 
 | 
 | ||||||
|  | @ -171,6 +178,10 @@ class Header extends ImmutablePureComponent { | ||||||
|       actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; |       actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) { | ||||||
|  |       bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     if (account.get('moved') && !account.getIn(['relationship', 'following'])) { |     if (account.get('moved') && !account.getIn(['relationship', 'following'])) { | ||||||
|       actionBtn = ''; |       actionBtn = ''; | ||||||
|     } |     } | ||||||
|  | @ -268,7 +279,7 @@ class Header extends ImmutablePureComponent { | ||||||
|       <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}> |       <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}> | ||||||
|         <div className='account__header__image'> |         <div className='account__header__image'> | ||||||
|           <div className='account__header__info'> |           <div className='account__header__info'> | ||||||
|             {info} |             {!suspended && info} | ||||||
|           </div> |           </div> | ||||||
| 
 | 
 | ||||||
|           <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' /> |           <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' /> | ||||||
|  | @ -282,11 +293,14 @@ class Header extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|             <div className='spacer' /> |             <div className='spacer' /> | ||||||
| 
 | 
 | ||||||
|  |             {!suspended && ( | ||||||
|               <div className='account__header__tabs__buttons'> |               <div className='account__header__tabs__buttons'> | ||||||
|                 {actionBtn} |                 {actionBtn} | ||||||
|  |                 {bellBtn} | ||||||
| 
 | 
 | ||||||
|                 <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> |                 <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> | ||||||
|               </div> |               </div> | ||||||
|  |             )} | ||||||
|           </div> |           </div> | ||||||
| 
 | 
 | ||||||
|           <div className='account__header__tabs__name'> |           <div className='account__header__tabs__name'> | ||||||
|  | @ -324,11 +338,12 @@ class Header extends ImmutablePureComponent { | ||||||
|                 </div> |                 </div> | ||||||
|               )} |               )} | ||||||
| 
 | 
 | ||||||
|               {account.get('id') !== me && <AccountNoteContainer account={account} />} |               {account.get('id') !== me && !suspended && <AccountNoteContainer account={account} />} | ||||||
| 
 | 
 | ||||||
|               {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />} |               {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />} | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|  |             {!suspended && ( | ||||||
|               <div className='account__header__extra__links'> |               <div className='account__header__extra__links'> | ||||||
|                 <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}> |                 <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}> | ||||||
|                   <ShortNumber |                   <ShortNumber | ||||||
|  | @ -351,6 +366,7 @@ class Header extends ImmutablePureComponent { | ||||||
|                   /> |                   /> | ||||||
|                 </NavLink> |                 </NavLink> | ||||||
|               </div> |               </div> | ||||||
|  |             )} | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
|  |  | ||||||
|  | @ -15,12 +15,15 @@ import { ScrollContainer } from 'react-router-scroll-4'; | ||||||
| import LoadMore from 'mastodon/components/load_more'; | import LoadMore from 'mastodon/components/load_more'; | ||||||
| import MissingIndicator from 'mastodon/components/missing_indicator'; | import MissingIndicator from 'mastodon/components/missing_indicator'; | ||||||
| import { openModal } from 'mastodon/actions/modal'; | import { openModal } from 'mastodon/actions/modal'; | ||||||
|  | import { FormattedMessage } from 'react-intl'; | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = (state, props) => ({ | const mapStateToProps = (state, props) => ({ | ||||||
|   isAccount: !!state.getIn(['accounts', props.params.accountId]), |   isAccount: !!state.getIn(['accounts', props.params.accountId]), | ||||||
|   attachments: getAccountGallery(state, props.params.accountId), |   attachments: getAccountGallery(state, props.params.accountId), | ||||||
|   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), |   isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), | ||||||
|   hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), |   hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), | ||||||
|  |   suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false), | ||||||
|  |   blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| class LoadMoreMedia extends ImmutablePureComponent { | class LoadMoreMedia extends ImmutablePureComponent { | ||||||
|  | @ -56,6 +59,8 @@ class AccountGallery extends ImmutablePureComponent { | ||||||
|     isLoading: PropTypes.bool, |     isLoading: PropTypes.bool, | ||||||
|     hasMore: PropTypes.bool, |     hasMore: PropTypes.bool, | ||||||
|     isAccount: PropTypes.bool, |     isAccount: PropTypes.bool, | ||||||
|  |     blockedBy: PropTypes.bool, | ||||||
|  |     suspended: PropTypes.bool, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  | @ -119,7 +124,7 @@ class AccountGallery extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn } = this.props; |     const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props; | ||||||
|     const { width } = this.state; |     const { width } = this.state; | ||||||
| 
 | 
 | ||||||
|     if (!isAccount) { |     if (!isAccount) { | ||||||
|  | @ -152,6 +157,11 @@ class AccountGallery extends ImmutablePureComponent { | ||||||
|           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> |           <div className='scrollable scrollable--flex' onScroll={this.handleScroll}> | ||||||
|             <HeaderContainer accountId={this.props.params.accountId} /> |             <HeaderContainer accountId={this.props.params.accountId} /> | ||||||
| 
 | 
 | ||||||
|  |             {(suspended || blockedBy) ? ( | ||||||
|  |               <div className='empty-column-indicator'> | ||||||
|  |                 <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' /> | ||||||
|  |               </div> | ||||||
|  |             ) : ( | ||||||
|               <div role='feed' className='account-gallery__container' ref={this.handleRef}> |               <div role='feed' className='account-gallery__container' ref={this.handleRef}> | ||||||
|                 {attachments.map((attachment, index) => attachment === null ? ( |                 {attachments.map((attachment, index) => attachment === null ? ( | ||||||
|                   <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> |                   <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> | ||||||
|  | @ -161,6 +171,7 @@ class AccountGallery extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|                 {loadOlder} |                 {loadOlder} | ||||||
|               </div> |               </div> | ||||||
|  |             )} | ||||||
| 
 | 
 | ||||||
|             {isLoading && attachments.size === 0 && ( |             {isLoading && attachments.size === 0 && ( | ||||||
|               <div className='scrollable__append'> |               <div className='scrollable__append'> | ||||||
|  |  | ||||||
|  | @ -55,6 +55,10 @@ export default class Header extends ImmutablePureComponent { | ||||||
|     this.props.onReblogToggle(this.props.account); |     this.props.onReblogToggle(this.props.account); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   handleNotifyToggle = () => { | ||||||
|  |     this.props.onNotifyToggle(this.props.account); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   handleMute = () => { |   handleMute = () => { | ||||||
|     this.props.onMute(this.props.account); |     this.props.onMute(this.props.account); | ||||||
|   } |   } | ||||||
|  | @ -106,6 +110,7 @@ export default class Header extends ImmutablePureComponent { | ||||||
|           onMention={this.handleMention} |           onMention={this.handleMention} | ||||||
|           onDirect={this.handleDirect} |           onDirect={this.handleDirect} | ||||||
|           onReblogToggle={this.handleReblogToggle} |           onReblogToggle={this.handleReblogToggle} | ||||||
|  |           onNotifyToggle={this.handleNotifyToggle} | ||||||
|           onReport={this.handleReport} |           onReport={this.handleReport} | ||||||
|           onMute={this.handleMute} |           onMute={this.handleMute} | ||||||
|           onBlockDomain={this.handleBlockDomain} |           onBlockDomain={this.handleBlockDomain} | ||||||
|  |  | ||||||
|  | @ -76,9 +76,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
| 
 | 
 | ||||||
|   onReblogToggle (account) { |   onReblogToggle (account) { | ||||||
|     if (account.getIn(['relationship', 'showing_reblogs'])) { |     if (account.getIn(['relationship', 'showing_reblogs'])) { | ||||||
|       dispatch(followAccount(account.get('id'), false)); |       dispatch(followAccount(account.get('id'), { reblogs: false })); | ||||||
|     } else { |     } else { | ||||||
|       dispatch(followAccount(account.get('id'), true)); |       dispatch(followAccount(account.get('id'), { reblogs: true })); | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|  | @ -90,6 +90,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({ | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|  |   onNotifyToggle (account) { | ||||||
|  |     if (account.getIn(['relationship', 'notifying'])) { | ||||||
|  |       dispatch(followAccount(account.get('id'), { notify: false })); | ||||||
|  |     } else { | ||||||
|  |       dispatch(followAccount(account.get('id'), { notify: true })); | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  | 
 | ||||||
|   onReport (account) { |   onReport (account) { | ||||||
|     dispatch(initReport(account)); |     dispatch(initReport(account)); | ||||||
|   }, |   }, | ||||||
|  |  | ||||||
|  | @ -31,6 +31,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false }) | ||||||
|     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList), |     featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList), | ||||||
|     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), |     isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), | ||||||
|     hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), |     hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), | ||||||
|  |     suspended: state.getIn(['accounts', accountId, 'suspended'], false), | ||||||
|     blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), |     blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), | ||||||
|   }; |   }; | ||||||
| }; | }; | ||||||
|  | @ -57,6 +58,7 @@ class AccountTimeline extends ImmutablePureComponent { | ||||||
|     withReplies: PropTypes.bool, |     withReplies: PropTypes.bool, | ||||||
|     blockedBy: PropTypes.bool, |     blockedBy: PropTypes.bool, | ||||||
|     isAccount: PropTypes.bool, |     isAccount: PropTypes.bool, | ||||||
|  |     suspended: PropTypes.bool, | ||||||
|     remote: PropTypes.bool, |     remote: PropTypes.bool, | ||||||
|     remoteUrl: PropTypes.string, |     remoteUrl: PropTypes.string, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|  | @ -113,7 +115,7 @@ class AccountTimeline extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn, remote, remoteUrl } = this.props; |     const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props; | ||||||
| 
 | 
 | ||||||
|     if (!isAccount) { |     if (!isAccount) { | ||||||
|       return ( |       return ( | ||||||
|  | @ -134,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|     let emptyMessage; |     let emptyMessage; | ||||||
| 
 | 
 | ||||||
|     if (blockedBy) { |     if (suspended || blockedBy) { | ||||||
|       emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; |       emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; | ||||||
|     } else if (remote && statusIds.isEmpty()) { |     } else if (remote && statusIds.isEmpty()) { | ||||||
|       emptyMessage = <RemoteHint url={remoteUrl} />; |       emptyMessage = <RemoteHint url={remoteUrl} />; | ||||||
|  | @ -153,7 +155,7 @@ class AccountTimeline extends ImmutablePureComponent { | ||||||
|           alwaysPrepend |           alwaysPrepend | ||||||
|           append={remoteMessage} |           append={remoteMessage} | ||||||
|           scrollKey='account_timeline' |           scrollKey='account_timeline' | ||||||
|           statusIds={blockedBy ? emptyList : statusIds} |           statusIds={(suspended || blockedBy) ? emptyList : statusIds} | ||||||
|           featuredStatusIds={featuredStatusIds} |           featuredStatusIds={featuredStatusIds} | ||||||
|           isLoading={isLoading} |           isLoading={isLoading} | ||||||
|           hasMore={hasMore} |           hasMore={hasMore} | ||||||
|  |  | ||||||
|  | @ -37,7 +37,11 @@ class Audio extends React.PureComponent { | ||||||
|     backgroundColor: PropTypes.string, |     backgroundColor: PropTypes.string, | ||||||
|     foregroundColor: PropTypes.string, |     foregroundColor: PropTypes.string, | ||||||
|     accentColor: PropTypes.string, |     accentColor: PropTypes.string, | ||||||
|  |     currentTime: PropTypes.number, | ||||||
|     autoPlay: PropTypes.bool, |     autoPlay: PropTypes.bool, | ||||||
|  |     volume: PropTypes.number, | ||||||
|  |     muted: PropTypes.bool, | ||||||
|  |     deployPictureInPicture: PropTypes.func, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -64,6 +68,19 @@ class Audio extends React.PureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   _pack() { | ||||||
|  |     return { | ||||||
|  |       src: this.props.src, | ||||||
|  |       volume: this.audio.volume, | ||||||
|  |       muted: this.audio.muted, | ||||||
|  |       currentTime: this.audio.currentTime, | ||||||
|  |       poster: this.props.poster, | ||||||
|  |       backgroundColor: this.props.backgroundColor, | ||||||
|  |       foregroundColor: this.props.foregroundColor, | ||||||
|  |       accentColor: this.props.accentColor, | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   _setDimensions () { |   _setDimensions () { | ||||||
|     const width  = this.player.offsetWidth; |     const width  = this.player.offsetWidth; | ||||||
|     const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); |     const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); | ||||||
|  | @ -112,6 +129,10 @@ class Audio extends React.PureComponent { | ||||||
|   componentWillUnmount () { |   componentWillUnmount () { | ||||||
|     window.removeEventListener('scroll', this.handleScroll); |     window.removeEventListener('scroll', this.handleScroll); | ||||||
|     window.removeEventListener('resize', this.handleResize); |     window.removeEventListener('resize', this.handleResize); | ||||||
|  | 
 | ||||||
|  |     if (!this.state.paused && this.audio && this.props.deployPictureInPicture) { | ||||||
|  |       this.props.deployPictureInPicture('audio', this._pack()); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   togglePlay = () => { |   togglePlay = () => { | ||||||
|  | @ -248,7 +269,13 @@ class Audio extends React.PureComponent { | ||||||
|     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); |     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); | ||||||
| 
 | 
 | ||||||
|     if (!this.state.paused && !inView) { |     if (!this.state.paused && !inView) { | ||||||
|       this.setState({ paused: true }, () => this.audio.pause()); |       this.audio.pause(); | ||||||
|  | 
 | ||||||
|  |       if (this.props.deployPictureInPicture) { | ||||||
|  |         this.props.deployPictureInPicture('audio', this._pack()); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       this.setState({ paused: true }); | ||||||
|     } |     } | ||||||
|   }, 150, { trailing: true }); |   }, 150, { trailing: true }); | ||||||
| 
 | 
 | ||||||
|  | @ -261,10 +288,22 @@ class Audio extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadedData = () => { |   handleLoadedData = () => { | ||||||
|     const { autoPlay } = this.props; |     const { autoPlay, currentTime, volume, muted } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (currentTime) { | ||||||
|  |       this.audio.currentTime = currentTime; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (volume !== undefined) { | ||||||
|  |       this.audio.volume = volume; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (muted !== undefined) { | ||||||
|  |       this.audio.muted = muted; | ||||||
|  |     } | ||||||
| 
 | 
 | ||||||
|     if (autoPlay) { |     if (autoPlay) { | ||||||
|       this.audio.play(); |       this.togglePlay(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | @ -350,7 +389,7 @@ class Audio extends React.PureComponent { | ||||||
|   render () { |   render () { | ||||||
|     const { src, intl, alt, editable, autoPlay } = this.props; |     const { src, intl, alt, editable, autoPlay } = this.props; | ||||||
|     const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; |     const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; | ||||||
|     const progress = (currentTime / duration) * 100; |     const progress = Math.min((currentTime / duration) * 100, 100); | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> |       <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> | ||||||
|  |  | ||||||
|  | @ -12,7 +12,7 @@ const emojiFilenames = (emojis) => { | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| // Emoji requiring extra borders depending on theme
 | // Emoji requiring extra borders depending on theme
 | ||||||
| const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞']); | const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺']); | ||||||
| const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); | const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); | ||||||
| 
 | 
 | ||||||
| const emojiFilename = (filename) => { | const emojiFilename = (filename) => { | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ const tooltips = defineMessages({ | ||||||
|   boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, |   boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, | ||||||
|   polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, |   polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, | ||||||
|   follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, |   follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, | ||||||
|  |   statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default @injectIntl | export default @injectIntl | ||||||
|  | @ -87,6 +88,13 @@ class FilterBar extends React.PureComponent { | ||||||
|         > |         > | ||||||
|           <Icon id='tasks' fixedWidth /> |           <Icon id='tasks' fixedWidth /> | ||||||
|         </button> |         </button> | ||||||
|  |         <button | ||||||
|  |           className={selectedFilter === 'status' ? 'active' : ''} | ||||||
|  |           onClick={this.onClick('status')} | ||||||
|  |           title={intl.formatMessage(tooltips.statuses)} | ||||||
|  |         > | ||||||
|  |           <Icon id='home' fixedWidth /> | ||||||
|  |         </button> | ||||||
|         <button |         <button | ||||||
|           className={selectedFilter === 'follow' ? 'active' : ''} |           className={selectedFilter === 'follow' ? 'active' : ''} | ||||||
|           onClick={this.onClick('follow')} |           onClick={this.onClick('follow')} | ||||||
|  |  | ||||||
|  | @ -10,6 +10,7 @@ import AccountContainer from 'mastodon/containers/account_container'; | ||||||
| import FollowRequestContainer from '../containers/follow_request_container'; | import FollowRequestContainer from '../containers/follow_request_container'; | ||||||
| import Icon from 'mastodon/components/icon'; | import Icon from 'mastodon/components/icon'; | ||||||
| import Permalink from 'mastodon/components/permalink'; | import Permalink from 'mastodon/components/permalink'; | ||||||
|  | import classNames from 'classnames'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, |   favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, | ||||||
|  | @ -17,6 +18,7 @@ const messages = defineMessages({ | ||||||
|   ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' }, |   ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' }, | ||||||
|   poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }, |   poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }, | ||||||
|   reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, |   reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, | ||||||
|  |   status: { id: 'notification.status', defaultMessage: '{name} just posted' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const notificationForScreenReader = (intl, message, timestamp) => { | const notificationForScreenReader = (intl, message, timestamp) => { | ||||||
|  | @ -49,6 +51,7 @@ class Notification extends ImmutablePureComponent { | ||||||
|     updateScrollBottom: PropTypes.func, |     updateScrollBottom: PropTypes.func, | ||||||
|     cacheMediaWidth: PropTypes.func, |     cacheMediaWidth: PropTypes.func, | ||||||
|     cachedMediaWidth: PropTypes.number, |     cachedMediaWidth: PropTypes.number, | ||||||
|  |     unread: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   handleMoveUp = () => { |   handleMoveUp = () => { | ||||||
|  | @ -113,11 +116,11 @@ class Notification extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   renderFollow (notification, account, link) { |   renderFollow (notification, account, link) { | ||||||
|     const { intl } = this.props; |     const { intl, unread } = this.props; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <HotKeys handlers={this.getHandlers()}> |       <HotKeys handlers={this.getHandlers()}> | ||||||
|         <div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}> |         <div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}> | ||||||
|           <div className='notification__message'> |           <div className='notification__message'> | ||||||
|             <div className='notification__favourite-icon-wrapper'> |             <div className='notification__favourite-icon-wrapper'> | ||||||
|               <Icon id='user-plus' fixedWidth /> |               <Icon id='user-plus' fixedWidth /> | ||||||
|  | @ -135,11 +138,11 @@ class Notification extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   renderFollowRequest (notification, account, link) { |   renderFollowRequest (notification, account, link) { | ||||||
|     const { intl } = this.props; |     const { intl, unread } = this.props; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <HotKeys handlers={this.getHandlers()}> |       <HotKeys handlers={this.getHandlers()}> | ||||||
|         <div className='notification notification-follow-request focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}> |         <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}> | ||||||
|           <div className='notification__message'> |           <div className='notification__message'> | ||||||
|             <div className='notification__favourite-icon-wrapper'> |             <div className='notification__favourite-icon-wrapper'> | ||||||
|               <Icon id='user' fixedWidth /> |               <Icon id='user' fixedWidth /> | ||||||
|  | @ -169,16 +172,17 @@ class Notification extends ImmutablePureComponent { | ||||||
|         updateScrollBottom={this.props.updateScrollBottom} |         updateScrollBottom={this.props.updateScrollBottom} | ||||||
|         cachedMediaWidth={this.props.cachedMediaWidth} |         cachedMediaWidth={this.props.cachedMediaWidth} | ||||||
|         cacheMediaWidth={this.props.cacheMediaWidth} |         cacheMediaWidth={this.props.cacheMediaWidth} | ||||||
|  |         unread={this.props.unread} | ||||||
|       /> |       /> | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   renderFavourite (notification, link) { |   renderFavourite (notification, link) { | ||||||
|     const { intl } = this.props; |     const { intl, unread } = this.props; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <HotKeys handlers={this.getHandlers()}> |       <HotKeys handlers={this.getHandlers()}> | ||||||
|         <div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> |         <div className={classNames('notification notification-favourite focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> | ||||||
|           <div className='notification__message'> |           <div className='notification__message'> | ||||||
|             <div className='notification__favourite-icon-wrapper'> |             <div className='notification__favourite-icon-wrapper'> | ||||||
|               <Icon id='star' className='star-icon' fixedWidth /> |               <Icon id='star' className='star-icon' fixedWidth /> | ||||||
|  | @ -206,11 +210,11 @@ class Notification extends ImmutablePureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   renderReblog (notification, link) { |   renderReblog (notification, link) { | ||||||
|     const { intl } = this.props; |     const { intl, unread } = this.props; | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <HotKeys handlers={this.getHandlers()}> |       <HotKeys handlers={this.getHandlers()}> | ||||||
|         <div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> |         <div className={classNames('notification notification-reblog focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> | ||||||
|           <div className='notification__message'> |           <div className='notification__message'> | ||||||
|             <div className='notification__favourite-icon-wrapper'> |             <div className='notification__favourite-icon-wrapper'> | ||||||
|               <Icon id='retweet' fixedWidth /> |               <Icon id='retweet' fixedWidth /> | ||||||
|  | @ -237,14 +241,46 @@ class Notification extends ImmutablePureComponent { | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   renderStatus (notification, link) { | ||||||
|  |     const { intl, unread } = this.props; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <HotKeys handlers={this.getHandlers()}> | ||||||
|  |         <div className={classNames('notification notification-status focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> | ||||||
|  |           <div className='notification__message'> | ||||||
|  |             <div className='notification__favourite-icon-wrapper'> | ||||||
|  |               <Icon id='home' fixedWidth /> | ||||||
|  |             </div> | ||||||
|  | 
 | ||||||
|  |             <span title={notification.get('created_at')}> | ||||||
|  |               <FormattedMessage id='notification.status' defaultMessage='{name} just posted' values={{ name: link }} /> | ||||||
|  |             </span> | ||||||
|  |           </div> | ||||||
|  | 
 | ||||||
|  |           <StatusContainer | ||||||
|  |             id={notification.get('status')} | ||||||
|  |             account={notification.get('account')} | ||||||
|  |             muted | ||||||
|  |             withDismiss | ||||||
|  |             hidden={this.props.hidden} | ||||||
|  |             getScrollPosition={this.props.getScrollPosition} | ||||||
|  |             updateScrollBottom={this.props.updateScrollBottom} | ||||||
|  |             cachedMediaWidth={this.props.cachedMediaWidth} | ||||||
|  |             cacheMediaWidth={this.props.cacheMediaWidth} | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </HotKeys> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   renderPoll (notification, account) { |   renderPoll (notification, account) { | ||||||
|     const { intl } = this.props; |     const { intl, unread } = this.props; | ||||||
|     const ownPoll  = me === account.get('id'); |     const ownPoll  = me === account.get('id'); | ||||||
|     const message  = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll); |     const message  = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll); | ||||||
| 
 | 
 | ||||||
|     return ( |     return ( | ||||||
|       <HotKeys handlers={this.getHandlers()}> |       <HotKeys handlers={this.getHandlers()}> | ||||||
|         <div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}> |         <div className={classNames('notification notification-poll focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}> | ||||||
|           <div className='notification__message'> |           <div className='notification__message'> | ||||||
|             <div className='notification__favourite-icon-wrapper'> |             <div className='notification__favourite-icon-wrapper'> | ||||||
|               <Icon id='tasks' fixedWidth /> |               <Icon id='tasks' fixedWidth /> | ||||||
|  | @ -292,6 +328,8 @@ class Notification extends ImmutablePureComponent { | ||||||
|       return this.renderFavourite(notification, link); |       return this.renderFavourite(notification, link); | ||||||
|     case 'reblog': |     case 'reblog': | ||||||
|       return this.renderReblog(notification, link); |       return this.renderReblog(notification, link); | ||||||
|  |     case 'status': | ||||||
|  |       return this.renderStatus(notification, link); | ||||||
|     case 'poll': |     case 'poll': | ||||||
|       return this.renderPoll(notification, account); |       return this.renderPoll(notification, account); | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  | @ -4,7 +4,14 @@ import PropTypes from 'prop-types'; | ||||||
| import ImmutablePropTypes from 'react-immutable-proptypes'; | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
| import Column from '../../components/column'; | import Column from '../../components/column'; | ||||||
| import ColumnHeader from '../../components/column_header'; | import ColumnHeader from '../../components/column_header'; | ||||||
| import { expandNotifications, scrollTopNotifications, loadPending, mountNotifications, unmountNotifications } from '../../actions/notifications'; | import { | ||||||
|  |   expandNotifications, | ||||||
|  |   scrollTopNotifications, | ||||||
|  |   loadPending, | ||||||
|  |   mountNotifications, | ||||||
|  |   unmountNotifications, | ||||||
|  |   markNotificationsAsRead, | ||||||
|  | } from '../../actions/notifications'; | ||||||
| import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; | ||||||
| import NotificationContainer from './containers/notification_container'; | import NotificationContainer from './containers/notification_container'; | ||||||
| import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; | ||||||
|  | @ -15,9 +22,12 @@ import { List as ImmutableList } from 'immutable'; | ||||||
| import { debounce } from 'lodash'; | import { debounce } from 'lodash'; | ||||||
| import ScrollableList from '../../components/scrollable_list'; | import ScrollableList from '../../components/scrollable_list'; | ||||||
| import LoadGap from '../../components/load_gap'; | import LoadGap from '../../components/load_gap'; | ||||||
|  | import Icon from 'mastodon/components/icon'; | ||||||
|  | import compareId from 'mastodon/compare_id'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   title: { id: 'column.notifications', defaultMessage: 'Notifications' }, |   title: { id: 'column.notifications', defaultMessage: 'Notifications' }, | ||||||
|  |   markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' }, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const getNotifications = createSelector([ | const getNotifications = createSelector([ | ||||||
|  | @ -32,7 +42,7 @@ const getNotifications = createSelector([ | ||||||
|     // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
 |     // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
 | ||||||
|     return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); |     return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); | ||||||
|   } |   } | ||||||
|   return notifications.filter(item => item !== null && allowedType === item.get('type')); |   return notifications.filter(item => item === null || allowedType === item.get('type')); | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| const mapStateToProps = state => ({ | const mapStateToProps = state => ({ | ||||||
|  | @ -42,6 +52,8 @@ const mapStateToProps = state => ({ | ||||||
|   isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0, |   isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0, | ||||||
|   hasMore: state.getIn(['notifications', 'hasMore']), |   hasMore: state.getIn(['notifications', 'hasMore']), | ||||||
|   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, |   numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, | ||||||
|  |   lastReadId: state.getIn(['notifications', 'readMarkerId']), | ||||||
|  |   canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0), | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
| export default @connect(mapStateToProps) | export default @connect(mapStateToProps) | ||||||
|  | @ -60,6 +72,8 @@ class Notifications extends React.PureComponent { | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|     hasMore: PropTypes.bool, |     hasMore: PropTypes.bool, | ||||||
|     numPending: PropTypes.number, |     numPending: PropTypes.number, | ||||||
|  |     lastReadId: PropTypes.string, | ||||||
|  |     canMarkAsRead: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   static defaultProps = { |   static defaultProps = { | ||||||
|  | @ -146,8 +160,12 @@ class Notifications extends React.PureComponent { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   handleMarkAsRead = () => { | ||||||
|  |     this.props.dispatch(markNotificationsAsRead()); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; |     const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props; | ||||||
|     const pinned = !!columnId; |     const pinned = !!columnId; | ||||||
|     const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; |     const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; | ||||||
| 
 | 
 | ||||||
|  | @ -174,6 +192,7 @@ class Notifications extends React.PureComponent { | ||||||
|           accountId={item.get('account')} |           accountId={item.get('account')} | ||||||
|           onMoveUp={this.handleMoveUp} |           onMoveUp={this.handleMoveUp} | ||||||
|           onMoveDown={this.handleMoveDown} |           onMoveDown={this.handleMoveDown} | ||||||
|  |           unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0} | ||||||
|         /> |         /> | ||||||
|       )); |       )); | ||||||
|     } else { |     } else { | ||||||
|  | @ -202,6 +221,21 @@ class Notifications extends React.PureComponent { | ||||||
|       </ScrollableList> |       </ScrollableList> | ||||||
|     ); |     ); | ||||||
| 
 | 
 | ||||||
|  |     let extraButton = null; | ||||||
|  | 
 | ||||||
|  |     if (canMarkAsRead) { | ||||||
|  |       extraButton = ( | ||||||
|  |         <button | ||||||
|  |           aria-label={intl.formatMessage(messages.markAsRead)} | ||||||
|  |           title={intl.formatMessage(messages.markAsRead)} | ||||||
|  |           onClick={this.handleMarkAsRead} | ||||||
|  |           className='column-header__button' | ||||||
|  |         > | ||||||
|  |           <Icon id='check' /> | ||||||
|  |         </button> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     return ( |     return ( | ||||||
|       <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}> |       <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}> | ||||||
|         <ColumnHeader |         <ColumnHeader | ||||||
|  | @ -213,6 +247,7 @@ class Notifications extends React.PureComponent { | ||||||
|           onClick={this.handleHeaderClick} |           onClick={this.handleHeaderClick} | ||||||
|           pinned={pinned} |           pinned={pinned} | ||||||
|           multiColumn={multiColumn} |           multiColumn={multiColumn} | ||||||
|  |           extraButton={extraButton} | ||||||
|         > |         > | ||||||
|           <ColumnSettingsContainer /> |           <ColumnSettingsContainer /> | ||||||
|         </ColumnHeader> |         </ColumnHeader> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,137 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
|  | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import IconButton from 'mastodon/components/icon_button'; | ||||||
|  | import classNames from 'classnames'; | ||||||
|  | import { me, boostModal } from 'mastodon/initial_state'; | ||||||
|  | import { defineMessages, injectIntl } from 'react-intl'; | ||||||
|  | import { replyCompose } from 'mastodon/actions/compose'; | ||||||
|  | import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions'; | ||||||
|  | import { makeGetStatus } from 'mastodon/selectors'; | ||||||
|  | import { openModal } from 'mastodon/actions/modal'; | ||||||
|  | 
 | ||||||
|  | const messages = defineMessages({ | ||||||
|  |   reply: { id: 'status.reply', defaultMessage: 'Reply' }, | ||||||
|  |   replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' }, | ||||||
|  |   reblog: { id: 'status.reblog', defaultMessage: 'Boost' }, | ||||||
|  |   reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' }, | ||||||
|  |   cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' }, | ||||||
|  |   cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' }, | ||||||
|  |   favourite: { id: 'status.favourite', defaultMessage: 'Favourite' }, | ||||||
|  |   replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' }, | ||||||
|  |   replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' }, | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | const makeMapStateToProps = () => { | ||||||
|  |   const getStatus = makeGetStatus(); | ||||||
|  | 
 | ||||||
|  |   const mapStateToProps = (state, { statusId }) => ({ | ||||||
|  |     status: getStatus(state, { id: statusId }), | ||||||
|  |     askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, | ||||||
|  |   }); | ||||||
|  | 
 | ||||||
|  |   return mapStateToProps; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default @connect(makeMapStateToProps) | ||||||
|  | @injectIntl | ||||||
|  | class Footer extends ImmutablePureComponent { | ||||||
|  | 
 | ||||||
|  |   static contextTypes = { | ||||||
|  |     router: PropTypes.object, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     statusId: PropTypes.string.isRequired, | ||||||
|  |     status: ImmutablePropTypes.map.isRequired, | ||||||
|  |     intl: PropTypes.object.isRequired, | ||||||
|  |     dispatch: PropTypes.func.isRequired, | ||||||
|  |     askReplyConfirmation: PropTypes.bool, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   _performReply = () => { | ||||||
|  |     const { dispatch, status } = this.props; | ||||||
|  |     dispatch(replyCompose(status, this.context.router.history)); | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleReplyClick = () => { | ||||||
|  |     const { dispatch, askReplyConfirmation, intl } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (askReplyConfirmation) { | ||||||
|  |       dispatch(openModal('CONFIRM', { | ||||||
|  |         message: intl.formatMessage(messages.replyMessage), | ||||||
|  |         confirm: intl.formatMessage(messages.replyConfirm), | ||||||
|  |         onConfirm: this._performReply, | ||||||
|  |       })); | ||||||
|  |     } else { | ||||||
|  |       this._performReply(); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleFavouriteClick = () => { | ||||||
|  |     const { dispatch, status } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (status.get('favourited')) { | ||||||
|  |       dispatch(unfavourite(status)); | ||||||
|  |     } else { | ||||||
|  |       dispatch(favourite(status)); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   _performReblog = () => { | ||||||
|  |     const { dispatch, status } = this.props; | ||||||
|  |     dispatch(reblog(status)); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   handleReblogClick = e => { | ||||||
|  |     const { dispatch, status } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (status.get('reblogged')) { | ||||||
|  |       dispatch(unreblog(status)); | ||||||
|  |     } else if ((e && e.shiftKey) || !boostModal) { | ||||||
|  |       this._performReblog(); | ||||||
|  |     } else { | ||||||
|  |       dispatch(openModal('BOOST', { status, onReblog: this._performReblog })); | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { status, intl } = this.props; | ||||||
|  | 
 | ||||||
|  |     const publicStatus  = ['public', 'unlisted'].includes(status.get('visibility')); | ||||||
|  |     const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private'; | ||||||
|  | 
 | ||||||
|  |     let replyIcon, replyTitle; | ||||||
|  | 
 | ||||||
|  |     if (status.get('in_reply_to_id', null) === null) { | ||||||
|  |       replyIcon = 'reply'; | ||||||
|  |       replyTitle = intl.formatMessage(messages.reply); | ||||||
|  |     } else { | ||||||
|  |       replyIcon = 'reply-all'; | ||||||
|  |       replyTitle = intl.formatMessage(messages.replyAll); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let reblogTitle = ''; | ||||||
|  | 
 | ||||||
|  |     if (status.get('reblogged')) { | ||||||
|  |       reblogTitle = intl.formatMessage(messages.cancel_reblog_private); | ||||||
|  |     } else if (publicStatus) { | ||||||
|  |       reblogTitle = intl.formatMessage(messages.reblog); | ||||||
|  |     } else if (reblogPrivate) { | ||||||
|  |       reblogTitle = intl.formatMessage(messages.reblog_private); | ||||||
|  |     } else { | ||||||
|  |       reblogTitle = intl.formatMessage(messages.cannot_reblog); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <div className='picture-in-picture__footer'> | ||||||
|  |         <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount /> | ||||||
|  |         <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate}  active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} /> | ||||||
|  |         <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -0,0 +1,40 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | import ImmutablePureComponent from 'react-immutable-pure-component'; | ||||||
|  | import ImmutablePropTypes from 'react-immutable-proptypes'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import IconButton from 'mastodon/components/icon_button'; | ||||||
|  | import { Link } from 'react-router-dom'; | ||||||
|  | import Avatar from 'mastodon/components/avatar'; | ||||||
|  | import DisplayName from 'mastodon/components/display_name'; | ||||||
|  | 
 | ||||||
|  | const mapStateToProps = (state, { accountId }) => ({ | ||||||
|  |   account: state.getIn(['accounts', accountId]), | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export default @connect(mapStateToProps) | ||||||
|  | class Header extends ImmutablePureComponent { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     accountId: PropTypes.string.isRequired, | ||||||
|  |     statusId: PropTypes.string.isRequired, | ||||||
|  |     account: ImmutablePropTypes.map.isRequired, | ||||||
|  |     onClose: PropTypes.func.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { account, statusId, onClose } = this.props; | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <div className='picture-in-picture__header'> | ||||||
|  |         <Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'> | ||||||
|  |           <Avatar account={account} size={36} /> | ||||||
|  |           <DisplayName account={account} /> | ||||||
|  |         </Link> | ||||||
|  | 
 | ||||||
|  |         <IconButton icon='times' onClick={onClose} title='Close' /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -0,0 +1,85 @@ | ||||||
|  | import React from 'react'; | ||||||
|  | import { connect } from 'react-redux'; | ||||||
|  | import PropTypes from 'prop-types'; | ||||||
|  | import Video from 'mastodon/features/video'; | ||||||
|  | import Audio from 'mastodon/features/audio'; | ||||||
|  | import { removePictureInPicture } from 'mastodon/actions/picture_in_picture'; | ||||||
|  | import Header from './components/header'; | ||||||
|  | import Footer from './components/footer'; | ||||||
|  | 
 | ||||||
|  | const mapStateToProps = state => ({ | ||||||
|  |   ...state.get('picture_in_picture'), | ||||||
|  | }); | ||||||
|  | 
 | ||||||
|  | export default @connect(mapStateToProps) | ||||||
|  | class PictureInPicture extends React.Component { | ||||||
|  | 
 | ||||||
|  |   static propTypes = { | ||||||
|  |     statusId: PropTypes.string, | ||||||
|  |     accountId: PropTypes.string, | ||||||
|  |     type: PropTypes.string, | ||||||
|  |     src: PropTypes.string, | ||||||
|  |     muted: PropTypes.bool, | ||||||
|  |     volume: PropTypes.number, | ||||||
|  |     currentTime: PropTypes.number, | ||||||
|  |     poster: PropTypes.string, | ||||||
|  |     backgroundColor: PropTypes.string, | ||||||
|  |     foregroundColor: PropTypes.string, | ||||||
|  |     accentColor: PropTypes.string, | ||||||
|  |     dispatch: PropTypes.func.isRequired, | ||||||
|  |   }; | ||||||
|  | 
 | ||||||
|  |   handleClose = () => { | ||||||
|  |     const { dispatch } = this.props; | ||||||
|  |     dispatch(removePictureInPicture()); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   render () { | ||||||
|  |     const { type, src, currentTime, accountId, statusId } = this.props; | ||||||
|  | 
 | ||||||
|  |     if (!currentTime) { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     let player; | ||||||
|  | 
 | ||||||
|  |     if (type === 'video') { | ||||||
|  |       player = ( | ||||||
|  |         <Video | ||||||
|  |           src={src} | ||||||
|  |           currentTime={this.props.currentTime} | ||||||
|  |           volume={this.props.volume} | ||||||
|  |           muted={this.props.muted} | ||||||
|  |           autoPlay | ||||||
|  |           inline | ||||||
|  |           alwaysVisible | ||||||
|  |         /> | ||||||
|  |       ); | ||||||
|  |     } else if (type === 'audio') { | ||||||
|  |       player = ( | ||||||
|  |         <Audio | ||||||
|  |           src={src} | ||||||
|  |           currentTime={this.props.currentTime} | ||||||
|  |           volume={this.props.volume} | ||||||
|  |           muted={this.props.muted} | ||||||
|  |           poster={this.props.poster} | ||||||
|  |           backgroundColor={this.props.backgroundColor} | ||||||
|  |           foregroundColor={this.props.foregroundColor} | ||||||
|  |           accentColor={this.props.accentColor} | ||||||
|  |           autoPlay | ||||||
|  |         /> | ||||||
|  |       ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |       <div className='picture-in-picture'> | ||||||
|  |         <Header accountId={accountId} statusId={statusId} onClose={this.handleClose} /> | ||||||
|  | 
 | ||||||
|  |         {player} | ||||||
|  | 
 | ||||||
|  |         <Footer statusId={statusId} /> | ||||||
|  |       </div> | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  | } | ||||||
|  | @ -15,6 +15,7 @@ import scheduleIdleTask from '../../ui/util/schedule_idle_task'; | ||||||
| import classNames from 'classnames'; | import classNames from 'classnames'; | ||||||
| import Icon from 'mastodon/components/icon'; | import Icon from 'mastodon/components/icon'; | ||||||
| import AnimatedNumber from 'mastodon/components/animated_number'; | import AnimatedNumber from 'mastodon/components/animated_number'; | ||||||
|  | import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder'; | ||||||
| 
 | 
 | ||||||
| const messages = defineMessages({ | const messages = defineMessages({ | ||||||
|   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, |   public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, | ||||||
|  | @ -40,6 +41,7 @@ class DetailedStatus extends ImmutablePureComponent { | ||||||
|     domain: PropTypes.string.isRequired, |     domain: PropTypes.string.isRequired, | ||||||
|     compact: PropTypes.bool, |     compact: PropTypes.bool, | ||||||
|     showMedia: PropTypes.bool, |     showMedia: PropTypes.bool, | ||||||
|  |     usingPiP: PropTypes.bool, | ||||||
|     onToggleMediaVisibility: PropTypes.func, |     onToggleMediaVisibility: PropTypes.func, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  | @ -100,7 +102,7 @@ class DetailedStatus extends ImmutablePureComponent { | ||||||
|   render () { |   render () { | ||||||
|     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; |     const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; | ||||||
|     const outerStyle = { boxSizing: 'border-box' }; |     const outerStyle = { boxSizing: 'border-box' }; | ||||||
|     const { intl, compact } = this.props; |     const { intl, compact, usingPiP } = this.props; | ||||||
| 
 | 
 | ||||||
|     if (!status) { |     if (!status) { | ||||||
|       return null; |       return null; | ||||||
|  | @ -116,7 +118,9 @@ class DetailedStatus extends ImmutablePureComponent { | ||||||
|       outerStyle.height = `${this.state.height}px`; |       outerStyle.height = `${this.state.height}px`; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (status.get('media_attachments').size > 0) { |     if (usingPiP) { | ||||||
|  |       media = <PictureInPicturePlaceholder />; | ||||||
|  |     } else if (status.get('media_attachments').size > 0) { | ||||||
|       if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { |       if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { | ||||||
|         const attachment = status.getIn(['media_attachments', 0]); |         const attachment = status.getIn(['media_attachments', 0]); | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -143,6 +143,7 @@ const makeMapStateToProps = () => { | ||||||
|       descendantsIds, |       descendantsIds, | ||||||
|       askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, |       askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, | ||||||
|       domain: state.getIn(['meta', 'domain']), |       domain: state.getIn(['meta', 'domain']), | ||||||
|  |       usingPiP: state.get('picture_in_picture').statusId === props.params.statusId, | ||||||
|     }; |     }; | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|  | @ -167,6 +168,7 @@ class Status extends ImmutablePureComponent { | ||||||
|     askReplyConfirmation: PropTypes.bool, |     askReplyConfirmation: PropTypes.bool, | ||||||
|     multiColumn: PropTypes.bool, |     multiColumn: PropTypes.bool, | ||||||
|     domain: PropTypes.string.isRequired, |     domain: PropTypes.string.isRequired, | ||||||
|  |     usingPiP: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -492,7 +494,7 @@ class Status extends ImmutablePureComponent { | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     let ancestors, descendants; |     let ancestors, descendants; | ||||||
|     const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props; |     const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props; | ||||||
|     const { fullscreen } = this.state; |     const { fullscreen } = this.state; | ||||||
| 
 | 
 | ||||||
|     if (status === null) { |     if (status === null) { | ||||||
|  | @ -550,6 +552,7 @@ class Status extends ImmutablePureComponent { | ||||||
|                   domain={domain} |                   domain={domain} | ||||||
|                   showMedia={this.state.showMedia} |                   showMedia={this.state.showMedia} | ||||||
|                   onToggleMediaVisibility={this.handleToggleMediaVisibility} |                   onToggleMediaVisibility={this.handleToggleMediaVisibility} | ||||||
|  |                   usingPiP={usingPiP} | ||||||
|                 /> |                 /> | ||||||
| 
 | 
 | ||||||
|                 <ActionBar |                 <ActionBar | ||||||
|  |  | ||||||
|  | @ -160,7 +160,7 @@ class MediaModal extends ImmutablePureComponent { | ||||||
|             src={image.get('url')} |             src={image.get('url')} | ||||||
|             width={image.get('width')} |             width={image.get('width')} | ||||||
|             height={image.get('height')} |             height={image.get('height')} | ||||||
|             startTime={time || 0} |             currentTime={time || 0} | ||||||
|             onCloseVideo={onClose} |             onCloseVideo={onClose} | ||||||
|             detailed |             detailed | ||||||
|             alt={image.get('description')} |             alt={image.get('description')} | ||||||
|  |  | ||||||
|  | @ -66,9 +66,9 @@ export default class VideoModal extends ImmutablePureComponent { | ||||||
|             preview={media.get('preview_url')} |             preview={media.get('preview_url')} | ||||||
|             blurhash={media.get('blurhash')} |             blurhash={media.get('blurhash')} | ||||||
|             src={media.get('url')} |             src={media.get('url')} | ||||||
|             startTime={options.startTime} |             currentTime={options.startTime} | ||||||
|             autoPlay={options.autoPlay} |             autoPlay={options.autoPlay} | ||||||
|             defaultVolume={options.defaultVolume} |             volume={options.defaultVolume} | ||||||
|             onCloseVideo={onClose} |             onCloseVideo={onClose} | ||||||
|             detailed |             detailed | ||||||
|             alt={media.get('description')} |             alt={media.get('description')} | ||||||
|  |  | ||||||
|  | @ -16,11 +16,12 @@ import { expandNotifications } from '../../actions/notifications'; | ||||||
| import { fetchFilters } from '../../actions/filters'; | import { fetchFilters } from '../../actions/filters'; | ||||||
| import { clearHeight } from '../../actions/height_cache'; | import { clearHeight } from '../../actions/height_cache'; | ||||||
| import { focusApp, unfocusApp } from 'mastodon/actions/app'; | import { focusApp, unfocusApp } from 'mastodon/actions/app'; | ||||||
| import { synchronouslySubmitMarkers } from 'mastodon/actions/markers'; | import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers'; | ||||||
| import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; | ||||||
| import UploadArea from './components/upload_area'; | import UploadArea from './components/upload_area'; | ||||||
| import ColumnsAreaContainer from './containers/columns_area_container'; | import ColumnsAreaContainer from './containers/columns_area_container'; | ||||||
| import DocumentTitle from './components/document_title'; | import DocumentTitle from './components/document_title'; | ||||||
|  | import PictureInPicture from 'mastodon/features/picture_in_picture'; | ||||||
| import { | import { | ||||||
|   Compose, |   Compose, | ||||||
|   Status, |   Status, | ||||||
|  | @ -265,6 +266,7 @@ class UI extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|   handleWindowFocus = () => { |   handleWindowFocus = () => { | ||||||
|     this.props.dispatch(focusApp()); |     this.props.dispatch(focusApp()); | ||||||
|  |     this.props.dispatch(submitMarkers()); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleWindowBlur = () => { |   handleWindowBlur = () => { | ||||||
|  | @ -368,6 +370,7 @@ class UI extends React.PureComponent { | ||||||
|       window.setTimeout(() => Notification.requestPermission(), 120 * 1000); |       window.setTimeout(() => Notification.requestPermission(), 120 * 1000); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     this.props.dispatch(fetchMarkers()); | ||||||
|     this.props.dispatch(expandHomeTimeline()); |     this.props.dispatch(expandHomeTimeline()); | ||||||
|     this.props.dispatch(expandNotifications()); |     this.props.dispatch(expandNotifications()); | ||||||
| 
 | 
 | ||||||
|  | @ -545,6 +548,7 @@ class UI extends React.PureComponent { | ||||||
|             {children} |             {children} | ||||||
|           </SwitchingColumnsArea> |           </SwitchingColumnsArea> | ||||||
| 
 | 
 | ||||||
|  |           <PictureInPicture /> | ||||||
|           <NotificationsContainer /> |           <NotificationsContainer /> | ||||||
|           <LoadingBarContainer className='loading-bar' /> |           <LoadingBarContainer className='loading-bar' /> | ||||||
|           <ModalContainer /> |           <ModalContainer /> | ||||||
|  |  | ||||||
|  | @ -104,20 +104,23 @@ class Video extends React.PureComponent { | ||||||
|     width: PropTypes.number, |     width: PropTypes.number, | ||||||
|     height: PropTypes.number, |     height: PropTypes.number, | ||||||
|     sensitive: PropTypes.bool, |     sensitive: PropTypes.bool, | ||||||
|     startTime: PropTypes.number, |     currentTime: PropTypes.number, | ||||||
|     onOpenVideo: PropTypes.func, |     onOpenVideo: PropTypes.func, | ||||||
|     onCloseVideo: PropTypes.func, |     onCloseVideo: PropTypes.func, | ||||||
|     detailed: PropTypes.bool, |     detailed: PropTypes.bool, | ||||||
|     inline: PropTypes.bool, |     inline: PropTypes.bool, | ||||||
|     editable: PropTypes.bool, |     editable: PropTypes.bool, | ||||||
|  |     alwaysVisible: PropTypes.bool, | ||||||
|     cacheWidth: PropTypes.func, |     cacheWidth: PropTypes.func, | ||||||
|     visible: PropTypes.bool, |     visible: PropTypes.bool, | ||||||
|     onToggleVisibility: PropTypes.func, |     onToggleVisibility: PropTypes.func, | ||||||
|  |     deployPictureInPicture: PropTypes.func, | ||||||
|     intl: PropTypes.object.isRequired, |     intl: PropTypes.object.isRequired, | ||||||
|     blurhash: PropTypes.string, |     blurhash: PropTypes.string, | ||||||
|     link: PropTypes.node, |     link: PropTypes.node, | ||||||
|     autoPlay: PropTypes.bool, |     autoPlay: PropTypes.bool, | ||||||
|     defaultVolume: PropTypes.number, |     volume: PropTypes.number, | ||||||
|  |     muted: PropTypes.bool, | ||||||
|   }; |   }; | ||||||
| 
 | 
 | ||||||
|   state = { |   state = { | ||||||
|  | @ -297,6 +300,15 @@ class Video extends React.PureComponent { | ||||||
|     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); |     document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true); | ||||||
|     document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); |     document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true); | ||||||
|     document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); |     document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true); | ||||||
|  | 
 | ||||||
|  |     if (!this.state.paused && this.video && this.props.deployPictureInPicture) { | ||||||
|  |       this.props.deployPictureInPicture('video', { | ||||||
|  |         src: this.props.src, | ||||||
|  |         currentTime: this.video.currentTime, | ||||||
|  |         muted: this.video.muted, | ||||||
|  |         volume: this.video.volume, | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   componentWillReceiveProps (nextProps) { |   componentWillReceiveProps (nextProps) { | ||||||
|  | @ -328,7 +340,18 @@ class Video extends React.PureComponent { | ||||||
|     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); |     const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); | ||||||
| 
 | 
 | ||||||
|     if (!this.state.paused && !inView) { |     if (!this.state.paused && !inView) { | ||||||
|       this.setState({ paused: true }, () => this.video.pause()); |       this.video.pause(); | ||||||
|  | 
 | ||||||
|  |       if (this.props.deployPictureInPicture) { | ||||||
|  |         this.props.deployPictureInPicture('video', { | ||||||
|  |           src: this.props.src, | ||||||
|  |           currentTime: this.video.currentTime, | ||||||
|  |           muted: this.video.muted, | ||||||
|  |           volume: this.video.volume, | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       this.setState({ paused: true }); | ||||||
|     } |     } | ||||||
|   }, 150, { trailing: true }) |   }, 150, { trailing: true }) | ||||||
| 
 | 
 | ||||||
|  | @ -361,15 +384,21 @@ class Video extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   handleLoadedData = () => { |   handleLoadedData = () => { | ||||||
|     if (this.props.startTime) { |     const { currentTime, volume, muted, autoPlay } = this.props; | ||||||
|       this.video.currentTime = this.props.startTime; | 
 | ||||||
|  |     if (currentTime) { | ||||||
|  |       this.video.currentTime = currentTime; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (this.props.defaultVolume !== undefined) { |     if (volume !== undefined) { | ||||||
|       this.video.volume = this.props.defaultVolume; |       this.video.volume = volume; | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     if (this.props.autoPlay) { |     if (muted !== undefined) { | ||||||
|  |       this.video.muted = muted; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (autoPlay) { | ||||||
|       this.video.play(); |       this.video.play(); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | @ -414,9 +443,9 @@ class Video extends React.PureComponent { | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   render () { |   render () { | ||||||
|     const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props; |     const { preview, src, inline, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable, blurhash } = this.props; | ||||||
|     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; |     const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state; | ||||||
|     const progress = (currentTime / duration) * 100; |     const progress = Math.min((currentTime / duration) * 100, 100); | ||||||
|     const playerStyle = {}; |     const playerStyle = {}; | ||||||
| 
 | 
 | ||||||
|     let { width, height } = this.props; |     let { width, height } = this.props; | ||||||
|  | @ -430,7 +459,7 @@ class Video extends React.PureComponent { | ||||||
| 
 | 
 | ||||||
|     let preload; |     let preload; | ||||||
| 
 | 
 | ||||||
|     if (startTime || fullscreen || dragging) { |     if (this.props.currentTime || fullscreen || dragging) { | ||||||
|       preload = 'auto'; |       preload = 'auto'; | ||||||
|     } else if (detailed) { |     } else if (detailed) { | ||||||
|       preload = 'metadata'; |       preload = 'metadata'; | ||||||
|  | @ -530,7 +559,7 @@ class Video extends React.PureComponent { | ||||||
|             </div> |             </div> | ||||||
| 
 | 
 | ||||||
|             <div className='video-player__buttons right'> |             <div className='video-player__buttons right'> | ||||||
|               {(!onCloseVideo && !editable && !fullscreen) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} |               {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>} | ||||||
|               {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} |               {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>} | ||||||
|               {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} |               {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>} | ||||||
|               <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> |               <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button> | ||||||
|  |  | ||||||
|  | @ -36,6 +36,7 @@ import trends from './trends'; | ||||||
| import missed_updates from './missed_updates'; | import missed_updates from './missed_updates'; | ||||||
| import announcements from './announcements'; | import announcements from './announcements'; | ||||||
| import markers from './markers'; | import markers from './markers'; | ||||||
|  | import picture_in_picture from './picture_in_picture'; | ||||||
| 
 | 
 | ||||||
| const reducers = { | const reducers = { | ||||||
|   announcements, |   announcements, | ||||||
|  | @ -75,6 +76,7 @@ const reducers = { | ||||||
|   trends, |   trends, | ||||||
|   missed_updates, |   missed_updates, | ||||||
|   markers, |   markers, | ||||||
|  |   picture_in_picture, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| export default combineReducers(reducers); | export default combineReducers(reducers); | ||||||
|  |  | ||||||
|  | @ -9,6 +9,7 @@ import { | ||||||
|   NOTIFICATIONS_LOAD_PENDING, |   NOTIFICATIONS_LOAD_PENDING, | ||||||
|   NOTIFICATIONS_MOUNT, |   NOTIFICATIONS_MOUNT, | ||||||
|   NOTIFICATIONS_UNMOUNT, |   NOTIFICATIONS_UNMOUNT, | ||||||
|  |   NOTIFICATIONS_MARK_AS_READ, | ||||||
| } from '../actions/notifications'; | } from '../actions/notifications'; | ||||||
| import { | import { | ||||||
|   ACCOUNT_BLOCK_SUCCESS, |   ACCOUNT_BLOCK_SUCCESS, | ||||||
|  | @ -16,6 +17,13 @@ import { | ||||||
|   FOLLOW_REQUEST_AUTHORIZE_SUCCESS, |   FOLLOW_REQUEST_AUTHORIZE_SUCCESS, | ||||||
|   FOLLOW_REQUEST_REJECT_SUCCESS, |   FOLLOW_REQUEST_REJECT_SUCCESS, | ||||||
| } from '../actions/accounts'; | } from '../actions/accounts'; | ||||||
|  | import { | ||||||
|  |   MARKERS_FETCH_SUCCESS, | ||||||
|  | } from '../actions/markers'; | ||||||
|  | import { | ||||||
|  |   APP_FOCUS, | ||||||
|  |   APP_UNFOCUS, | ||||||
|  | } from '../actions/app'; | ||||||
| import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; | import { DOMAIN_BLOCK_SUCCESS } from 'mastodon/actions/domain_blocks'; | ||||||
| import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines'; | import { TIMELINE_DELETE, TIMELINE_DISCONNECT } from '../actions/timelines'; | ||||||
| import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; | ||||||
|  | @ -26,8 +34,11 @@ const initialState = ImmutableMap({ | ||||||
|   items: ImmutableList(), |   items: ImmutableList(), | ||||||
|   hasMore: true, |   hasMore: true, | ||||||
|   top: false, |   top: false, | ||||||
|   mounted: false, |   mounted: 0, | ||||||
|   unread: 0, |   unread: 0, | ||||||
|  |   lastReadId: '0', | ||||||
|  |   readMarkerId: '0', | ||||||
|  |   isTabVisible: true, | ||||||
|   isLoading: false, |   isLoading: false, | ||||||
| }); | }); | ||||||
| 
 | 
 | ||||||
|  | @ -46,8 +57,10 @@ const normalizeNotification = (state, notification, usePendingItems) => { | ||||||
|     return state.update('pendingItems', list => list.unshift(notificationToMap(notification))).update('unread', unread => unread + 1); |     return state.update('pendingItems', list => list.unshift(notificationToMap(notification))).update('unread', unread => unread + 1); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   if (!top) { |   if (shouldCountUnreadNotifications(state)) { | ||||||
|     state = state.update('unread', unread => unread + 1); |     state = state.update('unread', unread => unread + 1); | ||||||
|  |   } else { | ||||||
|  |     state = state.set('lastReadId', notification.id); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return state.update('items', list => { |   return state.update('items', list => { | ||||||
|  | @ -60,6 +73,7 @@ const normalizeNotification = (state, notification, usePendingItems) => { | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => { | const expandNormalizedNotifications = (state, notifications, next, isLoadingRecent, usePendingItems) => { | ||||||
|  |   const lastReadId = state.get('lastReadId'); | ||||||
|   let items = ImmutableList(); |   let items = ImmutableList(); | ||||||
| 
 | 
 | ||||||
|   notifications.forEach((n, i) => { |   notifications.forEach((n, i) => { | ||||||
|  | @ -87,6 +101,15 @@ const expandNormalizedNotifications = (state, notifications, next, isLoadingRece | ||||||
|       mutable.set('hasMore', false); |       mutable.set('hasMore', false); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|  |     if (shouldCountUnreadNotifications(state)) { | ||||||
|  |       mutable.update('unread', unread => unread + items.count(item => compareId(item.get('id'), lastReadId) > 0)); | ||||||
|  |     } else { | ||||||
|  |       const mostRecent = items.find(item => item !== null); | ||||||
|  |       if (mostRecent && compareId(lastReadId, mostRecent.get('id')) < 0) { | ||||||
|  |         mutable.set('lastReadId', mostRecent.get('id')); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|     mutable.set('isLoading', false); |     mutable.set('isLoading', false); | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
|  | @ -96,21 +119,92 @@ const filterNotifications = (state, accountIds, type) => { | ||||||
|   return state.update('items', helper).update('pendingItems', helper); |   return state.update('items', helper).update('pendingItems', helper); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const updateTop = (state, top) => { | const clearUnread = (state) => { | ||||||
|   if (top) { |  | ||||||
|   state = state.set('unread', state.get('pendingItems').size); |   state = state.set('unread', state.get('pendingItems').size); | ||||||
|  |   const lastNotification = state.get('items').find(item => item !== null); | ||||||
|  |   return state.set('lastReadId', lastNotification ? lastNotification.get('id') : '0'); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const updateTop = (state, top) => { | ||||||
|  |   state = state.set('top', top); | ||||||
|  | 
 | ||||||
|  |   if (!shouldCountUnreadNotifications(state)) { | ||||||
|  |     state = clearUnread(state); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   return state.set('top', top); |   return state; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const deleteByStatus = (state, statusId) => { | const deleteByStatus = (state, statusId) => { | ||||||
|  |   const lastReadId = state.get('lastReadId'); | ||||||
|  | 
 | ||||||
|  |   if (shouldCountUnreadNotifications(state)) { | ||||||
|  |     const deletedUnread = state.get('items').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); | ||||||
|  |     state = state.update('unread', unread => unread - deletedUnread.size); | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); |   const helper = list => list.filterNot(item => item !== null && item.get('status') === statusId); | ||||||
|  |   const deletedUnread = state.get('pendingItems').filter(item => item !== null && item.get('status') === statusId && compareId(item.get('id'), lastReadId) > 0); | ||||||
|  |   state = state.update('unread', unread => unread - deletedUnread.size); | ||||||
|   return state.update('items', helper).update('pendingItems', helper); |   return state.update('items', helper).update('pendingItems', helper); | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | const updateMounted = (state) => { | ||||||
|  |   state = state.update('mounted', count => count + 1); | ||||||
|  |   if (!shouldCountUnreadNotifications(state)) { | ||||||
|  |     state = state.set('readMarkerId', state.get('lastReadId')); | ||||||
|  |     state = clearUnread(state); | ||||||
|  |   } | ||||||
|  |   return state; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const updateVisibility = (state, visibility) => { | ||||||
|  |   state = state.set('isTabVisible', visibility); | ||||||
|  |   if (!shouldCountUnreadNotifications(state)) { | ||||||
|  |     state = state.set('readMarkerId', state.get('lastReadId')); | ||||||
|  |     state = clearUnread(state); | ||||||
|  |   } | ||||||
|  |   return state; | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const shouldCountUnreadNotifications = (state) => { | ||||||
|  |   const isTabVisible   = state.get('isTabVisible'); | ||||||
|  |   const isOnTop        = state.get('top'); | ||||||
|  |   const isMounted      = state.get('mounted') > 0; | ||||||
|  |   const lastReadId     = state.get('lastReadId'); | ||||||
|  |   const lastItemReached = !state.get('hasMore') || lastReadId === '0' || (!state.get('items').isEmpty() && compareId(state.get('items').last().get('id'), lastReadId) <= 0); | ||||||
|  | 
 | ||||||
|  |   return !(isTabVisible && isOnTop && isMounted && lastItemReached); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const recountUnread = (state, last_read_id) => { | ||||||
|  |   return state.withMutations(mutable => { | ||||||
|  |     if (compareId(last_read_id, mutable.get('lastReadId')) > 0) { | ||||||
|  |       mutable.set('lastReadId', last_read_id); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (compareId(last_read_id, mutable.get('readMarkerId')) > 0) { | ||||||
|  |       mutable.set('readMarkerId', last_read_id); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     if (state.get('unread') > 0 || shouldCountUnreadNotifications(state)) { | ||||||
|  |       mutable.set('unread', mutable.get('pendingItems').count(item => item !== null) + mutable.get('items').count(item => item && compareId(item.get('id'), last_read_id) > 0)); | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  | 
 | ||||||
| export default function notifications(state = initialState, action) { | export default function notifications(state = initialState, action) { | ||||||
|   switch(action.type) { |   switch(action.type) { | ||||||
|  |   case MARKERS_FETCH_SUCCESS: | ||||||
|  |     return action.markers.notifications ? recountUnread(state, action.markers.notifications.last_read_id) : state; | ||||||
|  |   case NOTIFICATIONS_MOUNT: | ||||||
|  |     return updateMounted(state); | ||||||
|  |   case NOTIFICATIONS_UNMOUNT: | ||||||
|  |     return state.update('mounted', count => count - 1); | ||||||
|  |   case APP_FOCUS: | ||||||
|  |     return updateVisibility(state, true); | ||||||
|  |   case APP_UNFOCUS: | ||||||
|  |     return updateVisibility(state, false); | ||||||
|   case NOTIFICATIONS_LOAD_PENDING: |   case NOTIFICATIONS_LOAD_PENDING: | ||||||
|     return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); |     return state.update('items', list => state.get('pendingItems').concat(list.take(40))).set('pendingItems', ImmutableList()).set('unread', 0); | ||||||
|   case NOTIFICATIONS_EXPAND_REQUEST: |   case NOTIFICATIONS_EXPAND_REQUEST: | ||||||
|  | @ -144,10 +238,9 @@ export default function notifications(state = initialState, action) { | ||||||
|     return action.timeline === 'home' ? |     return action.timeline === 'home' ? | ||||||
|       state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : |       state.update(action.usePendingItems ? 'pendingItems' : 'items', items => items.first() ? items.unshift(null) : items) : | ||||||
|       state; |       state; | ||||||
|   case NOTIFICATIONS_MOUNT: |   case NOTIFICATIONS_MARK_AS_READ: | ||||||
|     return state.set('mounted', true); |     const lastNotification = state.get('items').find(item => item !== null); | ||||||
|   case NOTIFICATIONS_UNMOUNT: |     return lastNotification ? recountUnread(state, lastNotification.get('id')) : state; | ||||||
|     return state.set('mounted', false); |  | ||||||
|   default: |   default: | ||||||
|     return state; |     return state; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  | @ -0,0 +1,22 @@ | ||||||
|  | import { PICTURE_IN_PICTURE_DEPLOY, PICTURE_IN_PICTURE_REMOVE } from 'mastodon/actions/picture_in_picture'; | ||||||
|  | 
 | ||||||
|  | const initialState = { | ||||||
|  |   statusId: null, | ||||||
|  |   accountId: null, | ||||||
|  |   type: null, | ||||||
|  |   src: null, | ||||||
|  |   muted: false, | ||||||
|  |   volume: 0, | ||||||
|  |   currentTime: 0, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | export default function pictureInPicture(state = initialState, action) { | ||||||
|  |   switch(action.type) { | ||||||
|  |   case PICTURE_IN_PICTURE_DEPLOY: | ||||||
|  |     return { statusId: action.statusId, accountId: action.accountId, type: action.playerType, ...action.props }; | ||||||
|  |   case PICTURE_IN_PICTURE_REMOVE: | ||||||
|  |     return { ...initialState }; | ||||||
|  |   default: | ||||||
|  |     return state; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | @ -75,3 +75,8 @@ | ||||||
| .public-layout .public-account-header__tabs__tabs .counter.active::after { | .public-layout .public-account-header__tabs__tabs .counter.active::after { | ||||||
|   border-bottom: 4px solid $ui-highlight-color; |   border-bottom: 4px solid $ui-highlight-color; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .compose-form .autosuggest-textarea__textarea::placeholder, | ||||||
|  | .compose-form .spoiler-input__input::placeholder { | ||||||
|  |   color: $inverted-text-color; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -163,7 +163,8 @@ | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .icon-button { | .icon-button { | ||||||
|   display: inline-block; |   display: inline-flex; | ||||||
|  |   align-items: center; | ||||||
|   padding: 0; |   padding: 0; | ||||||
|   color: $action-button-color; |   color: $action-button-color; | ||||||
|   border: 0; |   border: 0; | ||||||
|  | @ -245,6 +246,14 @@ | ||||||
|       background: rgba($base-overlay-background, 0.9); |       background: rgba($base-overlay-background, 0.9); | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
|  | 
 | ||||||
|  |   &__counter { | ||||||
|  |     display: inline-block; | ||||||
|  |     width: 14px; | ||||||
|  |     margin-left: 4px; | ||||||
|  |     font-size: 12px; | ||||||
|  |     font-weight: 500; | ||||||
|  |   } | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .text-icon-button { | .text-icon-button { | ||||||
|  | @ -1139,24 +1148,6 @@ | ||||||
|   align-items: center; |   align-items: center; | ||||||
|   display: flex; |   display: flex; | ||||||
|   margin-top: 8px; |   margin-top: 8px; | ||||||
| 
 |  | ||||||
|   &__counter { |  | ||||||
|     display: inline-flex; |  | ||||||
|     margin-right: 11px; |  | ||||||
|     align-items: center; |  | ||||||
| 
 |  | ||||||
|     .status__action-bar-button { |  | ||||||
|       margin-right: 4px; |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     &__label { |  | ||||||
|       display: inline-block; |  | ||||||
|       width: 14px; |  | ||||||
|       font-size: 12px; |  | ||||||
|       font-weight: 500; |  | ||||||
|       color: $action-button-color; |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| .status__action-bar-button { | .status__action-bar-button { | ||||||
|  | @ -6502,6 +6493,10 @@ noscript { | ||||||
|         padding: 2px; |         padding: 2px; | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|  |       & > .icon-button { | ||||||
|  |         margin-right: 8px; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|       .button { |       .button { | ||||||
|         margin: 0 8px; |         margin: 0 8px; | ||||||
|       } |       } | ||||||
|  | @ -7011,3 +7006,119 @@ noscript { | ||||||
|     } |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .notification, | ||||||
|  | .status__wrapper { | ||||||
|  |   position: relative; | ||||||
|  | 
 | ||||||
|  |   &.unread { | ||||||
|  |     &::before { | ||||||
|  |       content: ""; | ||||||
|  |       position: absolute; | ||||||
|  |       top: 0; | ||||||
|  |       left: 0; | ||||||
|  |       pointer-events: 0; | ||||||
|  |       width: 100%; | ||||||
|  |       height: 100%; | ||||||
|  |       border-left: 2px solid $highlight-text-color; | ||||||
|  |       pointer-events: none; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .picture-in-picture { | ||||||
|  |   position: fixed; | ||||||
|  |   bottom: 20px; | ||||||
|  |   right: 20px; | ||||||
|  |   width: 300px; | ||||||
|  | 
 | ||||||
|  |   &__footer { | ||||||
|  |     border-radius: 0 0 4px 4px; | ||||||
|  |     background: lighten($ui-base-color, 4%); | ||||||
|  |     padding: 10px; | ||||||
|  |     padding-top: 12px; | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &__header { | ||||||
|  |     border-radius: 4px 4px 0 0; | ||||||
|  |     background: lighten($ui-base-color, 4%); | ||||||
|  |     padding: 10px; | ||||||
|  |     display: flex; | ||||||
|  |     justify-content: space-between; | ||||||
|  | 
 | ||||||
|  |     &__account { | ||||||
|  |       display: flex; | ||||||
|  |       text-decoration: none; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .account__avatar { | ||||||
|  |       margin-right: 10px; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .display-name { | ||||||
|  |       color: $primary-text-color; | ||||||
|  |       text-decoration: none; | ||||||
|  | 
 | ||||||
|  |       strong, | ||||||
|  |       span { | ||||||
|  |         display: block; | ||||||
|  |         text-overflow: ellipsis; | ||||||
|  |         overflow: hidden; | ||||||
|  |       } | ||||||
|  | 
 | ||||||
|  |       span { | ||||||
|  |         color: $darker-text-color; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   .video-player, | ||||||
|  |   .audio-player { | ||||||
|  |     border-radius: 0; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   @media screen and (max-width: 415px) { | ||||||
|  |     width: 210px; | ||||||
|  |     bottom: 10px; | ||||||
|  |     right: 10px; | ||||||
|  | 
 | ||||||
|  |     &__footer { | ||||||
|  |       display: none; | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     .video-player, | ||||||
|  |     .audio-player { | ||||||
|  |       border-radius: 0 0 4px 4px; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | .picture-in-picture-placeholder { | ||||||
|  |   box-sizing: border-box; | ||||||
|  |   border: 2px dashed lighten($ui-base-color, 8%); | ||||||
|  |   background: $base-shadow-color; | ||||||
|  |   display: flex; | ||||||
|  |   flex-direction: column; | ||||||
|  |   align-items: center; | ||||||
|  |   justify-content: center; | ||||||
|  |   margin-top: 10px; | ||||||
|  |   font-size: 16px; | ||||||
|  |   font-weight: 500; | ||||||
|  |   cursor: pointer; | ||||||
|  |   color: $darker-text-color; | ||||||
|  | 
 | ||||||
|  |   i { | ||||||
|  |     display: block; | ||||||
|  |     font-size: 24px; | ||||||
|  |     font-weight: 400; | ||||||
|  |     margin-bottom: 10px; | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   &:hover, | ||||||
|  |   &:focus, | ||||||
|  |   &:active { | ||||||
|  |     border-color: lighten($ui-base-color, 12%); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -118,13 +118,13 @@ class ActivityPub::Activity | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def notify_about_reblog(status) |   def notify_about_reblog(status) | ||||||
|     NotifyService.new.call(status.reblog.account, status) |     NotifyService.new.call(status.reblog.account, :reblog, status) | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def notify_about_mentions(status) |   def notify_about_mentions(status) | ||||||
|     status.active_mentions.includes(:account).each do |mention| |     status.active_mentions.includes(:account).each do |mention| | ||||||
|       next unless mention.account.local? && audience_includes?(mention.account) |       next unless mention.account.local? && audience_includes?(mention.account) | ||||||
|       NotifyService.new.call(mention.account, mention) |       NotifyService.new.call(mention.account, :mention, mention) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -13,7 +13,7 @@ class ActivityPub::Activity::Delete < ActivityPub::Activity | ||||||
| 
 | 
 | ||||||
|   def delete_person |   def delete_person | ||||||
|     lock_or_return("delete_in_progress:#{@account.id}") do |     lock_or_return("delete_in_progress:#{@account.id}") do | ||||||
|       SuspendAccountService.new.call(@account, reserve_username: false) |       DeleteAccountService.new.call(@account, reserve_username: false) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -22,10 +22,10 @@ class ActivityPub::Activity::Follow < ActivityPub::Activity | ||||||
|     follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id']) |     follow_request = FollowRequest.create!(account: @account, target_account: target_account, uri: @json['id']) | ||||||
| 
 | 
 | ||||||
|     if target_account.locked? || @account.silenced? |     if target_account.locked? || @account.silenced? | ||||||
|       NotifyService.new.call(target_account, follow_request) |       NotifyService.new.call(target_account, :follow_request, follow_request) | ||||||
|     else |     else | ||||||
|       AuthorizeFollowService.new.call(@account, target_account) |       AuthorizeFollowService.new.call(@account, target_account) | ||||||
|       NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account)) |       NotifyService.new.call(target_account, :follow, ::Follow.find_by(account: @account, target_account: target_account)) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -7,6 +7,6 @@ class ActivityPub::Activity::Like < ActivityPub::Activity | ||||||
|     return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status) |     return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status) | ||||||
| 
 | 
 | ||||||
|     favourite = original_status.favourites.create!(account: @account) |     favourite = original_status.favourites.create!(account: @account) | ||||||
|     NotifyService.new.call(original_status.account, favourite) |     NotifyService.new.call(original_status.account, :favourite, favourite) | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -253,7 +253,15 @@ class Request | ||||||
|       alias new open |       alias new open | ||||||
| 
 | 
 | ||||||
|       def check_private_address(address) |       def check_private_address(address) | ||||||
|         raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(IPAddr.new(address.to_s)) |         addr = IPAddr.new(address.to_s) | ||||||
|  |         return if private_address_exceptions.any? { |range| range.include?(addr) } | ||||||
|  |         raise Mastodon::HostValidationError if PrivateAddressCheck.private_address?(addr) | ||||||
|  |       end | ||||||
|  | 
 | ||||||
|  |       def private_address_exceptions | ||||||
|  |         @private_address_exceptions = begin | ||||||
|  |           (ENV['ALLOWED_PRIVATE_ADDRESSES'] || '').split(',').map { |addr| IPAddr.new(addr) } | ||||||
|  |         end | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -10,7 +10,7 @@ class NotificationMailer < ApplicationMailer | ||||||
|     @me     = recipient |     @me     = recipient | ||||||
|     @status = notification.target_status |     @status = notification.target_status | ||||||
| 
 | 
 | ||||||
|     return if @me.user.disabled? || @status.nil? |     return unless @me.user.functional? && @status.present? | ||||||
| 
 | 
 | ||||||
|     locale_for_account(@me) do |     locale_for_account(@me) do | ||||||
|       thread_by_conversation(@status.conversation) |       thread_by_conversation(@status.conversation) | ||||||
|  | @ -22,7 +22,7 @@ class NotificationMailer < ApplicationMailer | ||||||
|     @me      = recipient |     @me      = recipient | ||||||
|     @account = notification.from_account |     @account = notification.from_account | ||||||
| 
 | 
 | ||||||
|     return if @me.user.disabled? |     return unless @me.user.functional? | ||||||
| 
 | 
 | ||||||
|     locale_for_account(@me) do |     locale_for_account(@me) do | ||||||
|       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) |       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow.subject', name: @account.acct) | ||||||
|  | @ -34,7 +34,7 @@ class NotificationMailer < ApplicationMailer | ||||||
|     @account = notification.from_account |     @account = notification.from_account | ||||||
|     @status  = notification.target_status |     @status  = notification.target_status | ||||||
| 
 | 
 | ||||||
|     return if @me.user.disabled? || @status.nil? |     return unless @me.user.functional? && @status.present? | ||||||
| 
 | 
 | ||||||
|     locale_for_account(@me) do |     locale_for_account(@me) do | ||||||
|       thread_by_conversation(@status.conversation) |       thread_by_conversation(@status.conversation) | ||||||
|  | @ -47,7 +47,7 @@ class NotificationMailer < ApplicationMailer | ||||||
|     @account = notification.from_account |     @account = notification.from_account | ||||||
|     @status  = notification.target_status |     @status  = notification.target_status | ||||||
| 
 | 
 | ||||||
|     return if @me.user.disabled? || @status.nil? |     return unless @me.user.functional? && @status.present? | ||||||
| 
 | 
 | ||||||
|     locale_for_account(@me) do |     locale_for_account(@me) do | ||||||
|       thread_by_conversation(@status.conversation) |       thread_by_conversation(@status.conversation) | ||||||
|  | @ -59,7 +59,7 @@ class NotificationMailer < ApplicationMailer | ||||||
|     @me      = recipient |     @me      = recipient | ||||||
|     @account = notification.from_account |     @account = notification.from_account | ||||||
| 
 | 
 | ||||||
|     return if @me.user.disabled? |     return unless @me.user.functional? | ||||||
| 
 | 
 | ||||||
|     locale_for_account(@me) do |     locale_for_account(@me) do | ||||||
|       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) |       mail to: @me.user.email, subject: I18n.t('notification_mailer.follow_request.subject', name: @account.acct) | ||||||
|  | @ -67,7 +67,7 @@ class NotificationMailer < ApplicationMailer | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def digest(recipient, **opts) |   def digest(recipient, **opts) | ||||||
|     return if recipient.user.disabled? |     return unless recipient.user.functional? | ||||||
| 
 | 
 | ||||||
|     @me                  = recipient |     @me                  = recipient | ||||||
|     @since               = opts[:since] || [@me.user.last_emailed_at, (@me.user.current_sign_in_at + 1.day)].compact.max |     @since               = opts[:since] || [@me.user.last_emailed_at, (@me.user.current_sign_in_at + 1.day)].compact.max | ||||||
|  | @ -88,7 +88,9 @@ class NotificationMailer < ApplicationMailer | ||||||
| 
 | 
 | ||||||
|   def thread_by_conversation(conversation) |   def thread_by_conversation(conversation) | ||||||
|     return if conversation.nil? |     return if conversation.nil? | ||||||
|  | 
 | ||||||
|     msg_id = "<conversation-#{conversation.id}.#{conversation.created_at.strftime('%Y-%m-%d')}@#{Rails.configuration.x.local_domain}>" |     msg_id = "<conversation-#{conversation.id}.#{conversation.created_at.strftime('%Y-%m-%d')}@#{Rails.configuration.x.local_domain}>" | ||||||
|  | 
 | ||||||
|     headers['In-Reply-To'] = msg_id |     headers['In-Reply-To'] = msg_id | ||||||
|     headers['References']  = msg_id |     headers['References']  = msg_id | ||||||
|   end |   end | ||||||
|  |  | ||||||
|  | @ -15,7 +15,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @token    = token |     @token    = token | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.unconfirmed_email.presence || @resource.email, |       mail to: @resource.unconfirmed_email.presence || @resource.email, | ||||||
|  | @ -29,7 +29,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @token    = token |     @token    = token | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.reset_password_instructions.subject') | ||||||
|  | @ -40,7 +40,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @resource = user |     @resource = user | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.password_change.subject') | ||||||
|  | @ -51,7 +51,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @resource = user |     @resource = user | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.email_changed.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.email_changed.subject') | ||||||
|  | @ -62,7 +62,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @resource = user |     @resource = user | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_enabled.subject') | ||||||
|  | @ -73,7 +73,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @resource = user |     @resource = user | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_disabled.subject') | ||||||
|  | @ -84,7 +84,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @resource = user |     @resource = user | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.two_factor_recovery_codes_changed.subject') | ||||||
|  | @ -95,7 +95,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @resource = user |     @resource = user | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_enabled.subject') | ||||||
|  | @ -106,7 +106,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @resource = user |     @resource = user | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_disabled.subject') | ||||||
|  | @ -118,7 +118,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
|     @webauthn_credential = webauthn_credential |     @webauthn_credential = webauthn_credential | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.added.subject') | ||||||
|  | @ -130,7 +130,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
|     @webauthn_credential = webauthn_credential |     @webauthn_credential = webauthn_credential | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject') |       mail to: @resource.email, subject: I18n.t('devise.mailer.webauthn_credential.deleted.subject') | ||||||
|  | @ -141,7 +141,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @resource = user |     @resource = user | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject') |       mail to: @resource.email, subject: I18n.t('user_mailer.welcome.subject') | ||||||
|  | @ -153,7 +153,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @instance = Rails.configuration.x.local_domain |     @instance = Rails.configuration.x.local_domain | ||||||
|     @backup   = backup |     @backup   = backup | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject') |       mail to: @resource.email, subject: I18n.t('user_mailer.backup_ready.subject') | ||||||
|  | @ -181,7 +181,7 @@ class UserMailer < Devise::Mailer | ||||||
|     @detection  = Browser.new(user_agent) |     @detection  = Browser.new(user_agent) | ||||||
|     @timestamp  = timestamp.to_time.utc |     @timestamp  = timestamp.to_time.utc | ||||||
| 
 | 
 | ||||||
|     return if @resource.disabled? |     return unless @resource.active_for_authentication? | ||||||
| 
 | 
 | ||||||
|     I18n.with_locale(@resource.locale || I18n.default_locale) do |     I18n.with_locale(@resource.locale || I18n.default_locale) do | ||||||
|       mail to: @resource.email, |       mail to: @resource.email, | ||||||
|  |  | ||||||
|  | @ -226,24 +226,21 @@ class Account < ApplicationRecord | ||||||
| 
 | 
 | ||||||
|   def suspend!(date = Time.now.utc) |   def suspend!(date = Time.now.utc) | ||||||
|     transaction do |     transaction do | ||||||
|       user&.disable! if local? |       create_deletion_request! | ||||||
|       update!(suspended_at: date) |       update!(suspended_at: date) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def unsuspend! |   def unsuspend! | ||||||
|     transaction do |     transaction do | ||||||
|       user&.enable! if local? |       deletion_request&.destroy! | ||||||
|       update!(suspended_at: nil) |       update!(suspended_at: nil) | ||||||
|     end |     end | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def memorialize! |   def memorialize! | ||||||
|     transaction do |  | ||||||
|       user&.disable! if local? |  | ||||||
|     update!(memorial: true) |     update!(memorial: true) | ||||||
|   end |   end | ||||||
|   end |  | ||||||
| 
 | 
 | ||||||
|   def sign? |   def sign? | ||||||
|     true |     true | ||||||
|  |  | ||||||
|  | @ -38,15 +38,16 @@ class AccountConversation < ApplicationRecord | ||||||
|   class << self |   class << self | ||||||
|     def to_a_paginated_by_id(limit, options = {}) |     def to_a_paginated_by_id(limit, options = {}) | ||||||
|       if options[:min_id] |       if options[:min_id] | ||||||
|         paginate_by_min_id(limit, options[:min_id]).reverse |         paginate_by_min_id(limit, options[:min_id], options[:max_id]).reverse | ||||||
|       else |       else | ||||||
|         paginate_by_max_id(limit, options[:max_id], options[:since_id]).to_a |         paginate_by_max_id(limit, options[:max_id], options[:since_id]).to_a | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|     def paginate_by_min_id(limit, min_id = nil) |     def paginate_by_min_id(limit, min_id = nil, max_id = nil) | ||||||
|       query = order(arel_table[:last_status_id].asc).limit(limit) |       query = order(arel_table[:last_status_id].asc).limit(limit) | ||||||
|       query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present? |       query = query.where(arel_table[:last_status_id].gt(min_id)) if min_id.present? | ||||||
|  |       query = query.where(arel_table[:last_status_id].lt(max_id)) if max_id.present? | ||||||
|       query |       query | ||||||
|     end |     end | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -0,0 +1,20 @@ | ||||||
|  | # frozen_string_literal: true | ||||||
|  | 
 | ||||||
|  | # == Schema Information | ||||||
|  | # | ||||||
|  | # Table name: account_deletion_requests | ||||||
|  | # | ||||||
|  | #  id         :bigint(8)        not null, primary key | ||||||
|  | #  account_id :bigint(8) | ||||||
|  | #  created_at :datetime         not null | ||||||
|  | #  updated_at :datetime         not null | ||||||
|  | # | ||||||
|  | class AccountDeletionRequest < ApplicationRecord | ||||||
|  |   DELAY_TO_DELETION = 30.days.freeze | ||||||
|  | 
 | ||||||
|  |   belongs_to :account | ||||||
|  | 
 | ||||||
|  |   def due_at | ||||||
|  |     created_at + DELAY_TO_DELETION | ||||||
|  |   end | ||||||
|  | end | ||||||
|  | @ -134,7 +134,7 @@ class Admin::AccountAction | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def process_email! |   def process_email! | ||||||
|     UserMailer.warning(target_account.user, warning, status_ids).deliver_now! if warnable? |     UserMailer.warning(target_account.user, warning, status_ids).deliver_later! if warnable? | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def warnable? |   def warnable? | ||||||
|  | @ -142,7 +142,7 @@ class Admin::AccountAction | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def status_ids |   def status_ids | ||||||
|     @report.status_ids if @report && include_statuses |     report.status_ids if report && include_statuses | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def reports |   def reports | ||||||
|  |  | ||||||
|  | @ -60,5 +60,8 @@ module AccountAssociations | ||||||
|     # Hashtags |     # Hashtags | ||||||
|     has_and_belongs_to_many :tags |     has_and_belongs_to_many :tags | ||||||
|     has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account |     has_many :featured_tags, -> { includes(:tag) }, dependent: :destroy, inverse_of: :account | ||||||
|  | 
 | ||||||
|  |     # Account deletion requests | ||||||
|  |     has_one :deletion_request, class_name: 'AccountDeletionRequest', inverse_of: :account, dependent: :destroy | ||||||
|   end |   end | ||||||
| end | end | ||||||
|  |  | ||||||
|  | @ -8,6 +8,7 @@ module AccountInteractions | ||||||
|       Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping| |       Follow.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow, mapping| | ||||||
|         mapping[follow.target_account_id] = { |         mapping[follow.target_account_id] = { | ||||||
|           reblogs: follow.show_reblogs?, |           reblogs: follow.show_reblogs?, | ||||||
|  |           notify: follow.notify?, | ||||||
|         } |         } | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  | @ -36,6 +37,7 @@ module AccountInteractions | ||||||
|       FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping| |       FollowRequest.where(target_account_id: target_account_ids, account_id: account_id).each_with_object({}) do |follow_request, mapping| | ||||||
|         mapping[follow_request.target_account_id] = { |         mapping[follow_request.target_account_id] = { | ||||||
|           reblogs: follow_request.show_reblogs?, |           reblogs: follow_request.show_reblogs?, | ||||||
|  |           notify: follow_request.notify?, | ||||||
|         } |         } | ||||||
|       end |       end | ||||||
|     end |     end | ||||||
|  | @ -95,25 +97,29 @@ module AccountInteractions | ||||||
|     has_many :announcement_mutes, dependent: :destroy |     has_many :announcement_mutes, dependent: :destroy | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def follow!(other_account, reblogs: nil, uri: nil, rate_limit: false) |   def follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false) | ||||||
|     reblogs = true if reblogs.nil? |     rel = active_relationships.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit) | ||||||
| 
 |  | ||||||
|     rel = active_relationships.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit) |  | ||||||
|                               .find_or_create_by!(target_account: other_account) |                               .find_or_create_by!(target_account: other_account) | ||||||
| 
 | 
 | ||||||
|     rel.update!(show_reblogs: reblogs) |     rel.show_reblogs = reblogs unless reblogs.nil? | ||||||
|  |     rel.notify       = notify  unless notify.nil? | ||||||
|  | 
 | ||||||
|  |     rel.save! if rel.changed? | ||||||
|  | 
 | ||||||
|     remove_potential_friendship(other_account) |     remove_potential_friendship(other_account) | ||||||
| 
 | 
 | ||||||
|     rel |     rel | ||||||
|   end |   end | ||||||
| 
 | 
 | ||||||
|   def request_follow!(other_account, reblogs: nil, uri: nil, rate_limit: false) |   def request_follow!(other_account, reblogs: nil, notify: nil, uri: nil, rate_limit: false) | ||||||
|     reblogs = true if reblogs.nil? |     rel = follow_requests.create_with(show_reblogs: reblogs.nil? ? true : reblogs, notify: notify.nil? ? false : notify, uri: uri, rate_limit: rate_limit) | ||||||
| 
 |  | ||||||
|     rel = follow_requests.create_with(show_reblogs: reblogs, uri: uri, rate_limit: rate_limit) |  | ||||||
|                          .find_or_create_by!(target_account: other_account) |                          .find_or_create_by!(target_account: other_account) | ||||||
| 
 | 
 | ||||||
|     rel.update!(show_reblogs: reblogs) |     rel.show_reblogs = reblogs unless reblogs.nil? | ||||||
|  |     rel.notify       = notify  unless notify.nil? | ||||||
|  | 
 | ||||||
|  |     rel.save! if rel.changed? | ||||||
|  | 
 | ||||||
|     remove_potential_friendship(other_account) |     remove_potential_friendship(other_account) | ||||||
| 
 | 
 | ||||||
|     rel |     rel | ||||||
|  |  | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
		Reference in New Issue