Merge branch 'master' into glitch-soc/merge-upstream
Conflicts: - app/models/media_attachment.rb
This commit is contained in:
		
						commit
						73bc58c656
					
				
					 67 changed files with 896 additions and 288 deletions
				
			
		
							
								
								
									
										39
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								CHANGELOG.md
									
									
									
									
									
								
							| 
						 | 
					@ -3,6 +3,45 @@ Changelog
 | 
				
			||||||
 | 
					
 | 
				
			||||||
All notable changes to this project will be documented in this file.
 | 
					All notable changes to this project will be documented in this file.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					## [2.8.1] - 2019-05-04
 | 
				
			||||||
 | 
					### Added
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Add link to existing domain block when trying to block an already-blocked domain ([ThibG](https://github.com/tootsuite/mastodon/pull/10663))
 | 
				
			||||||
 | 
					- Add button to view context to media modal when opened from account gallery in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10676))
 | 
				
			||||||
 | 
					- Add ability to create multiple-choice polls in web UI ([ThibG](https://github.com/tootsuite/mastodon/pull/10603))
 | 
				
			||||||
 | 
					- Add `GITHUB_REPOSITORY` and `SOURCE_BASE_URL` environment variables ([rosylilly](https://github.com/tootsuite/mastodon/pull/10600))
 | 
				
			||||||
 | 
					- Add `/interact/` paths to `robots.txt` ([ThibG](https://github.com/tootsuite/mastodon/pull/10666))
 | 
				
			||||||
 | 
					- Add `blurhash` to the Attachment entity in the REST API ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Changed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Change hidden media to be shown as a blurhash-based colorful gradient instead of a black box in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
 | 
				
			||||||
 | 
					- Change rejected media to be shown as a blurhash-based gradient instead of a list of filenames in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10630))
 | 
				
			||||||
 | 
					- Change e-mail whitelist/blacklist to not be checked when invited ([Gargron](https://github.com/tootsuite/mastodon/pull/10683))
 | 
				
			||||||
 | 
					- Change cache header of REST API results to no-cache ([ThibG](https://github.com/tootsuite/mastodon/pull/10655))
 | 
				
			||||||
 | 
					- Change the "mark media as sensitive" button to be more obvious in web UI ([Gargron](https://github.com/tootsuite/mastodon/pull/10673), [Gargron](https://github.com/tootsuite/mastodon/pull/10682))
 | 
				
			||||||
 | 
					- Change account gallery in web UI to display 3 columns, open media modal ([Gargron](https://github.com/tootsuite/mastodon/pull/10667), [Gargron](https://github.com/tootsuite/mastodon/pull/10674))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					### Fixed
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					- Fix LDAP/PAM/SAML/CAS users not being pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10621))
 | 
				
			||||||
 | 
					- Fix accounts created through tootctl not being always pre-approved ([Gargron](https://github.com/tootsuite/mastodon/pull/10684))
 | 
				
			||||||
 | 
					- Fix Sidekiq retrying ActivityPub processing jobs that fail validation ([ThibG](https://github.com/tootsuite/mastodon/pull/10614))
 | 
				
			||||||
 | 
					- Fix toots not being scrolled into view sometimes through keyboard selection ([ThibG](https://github.com/tootsuite/mastodon/pull/10593))
 | 
				
			||||||
 | 
					- Fix expired invite links being usable to bypass approval mode ([ThibG](https://github.com/tootsuite/mastodon/pull/10657))
 | 
				
			||||||
 | 
					- Fix not being able to save e-mail preference for new pending accounts ([Gargron](https://github.com/tootsuite/mastodon/pull/10622))
 | 
				
			||||||
 | 
					- Fix upload progressbar when image resizing is involved ([ThibG](https://github.com/tootsuite/mastodon/pull/10632))
 | 
				
			||||||
 | 
					- Fix block action not automatically cancelling pending follow request ([ThibG](https://github.com/tootsuite/mastodon/pull/10633))
 | 
				
			||||||
 | 
					- Fix stoplight logging to stderr separate from Rails logger ([Gargron](https://github.com/tootsuite/mastodon/pull/10624))
 | 
				
			||||||
 | 
					- Fix sign up button not saying sign up when invite is used ([Gargron](https://github.com/tootsuite/mastodon/pull/10623))
 | 
				
			||||||
 | 
					- Fix health checks in Docker Compose configuration ([fabianonline](https://github.com/tootsuite/mastodon/pull/10553))
 | 
				
			||||||
 | 
					- Fix modal items not being scrollable on touch devices ([kedamaDQ](https://github.com/tootsuite/mastodon/pull/10605))
 | 
				
			||||||
 | 
					- Fix Keybase configuration using wrong domain when a web domain is used ([BenLubar](https://github.com/tootsuite/mastodon/pull/10565))
 | 
				
			||||||
 | 
					- Fix avatar GIFs not being animated on-hover on public profiles ([hyenagirl64](https://github.com/tootsuite/mastodon/pull/10549))
 | 
				
			||||||
 | 
					- Fix OpenGraph parser not understanding some valid property meta tags ([da2x](https://github.com/tootsuite/mastodon/pull/10604))
 | 
				
			||||||
 | 
					- Fix wrong fonts being displayed when Roboto is installed on user's machine ([ThibG](https://github.com/tootsuite/mastodon/pull/10594))
 | 
				
			||||||
 | 
					- Fix confirmation modals being too narrow for a secondary action button ([ThibG](https://github.com/tootsuite/mastodon/pull/10586))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## [2.8.0] - 2019-04-10
 | 
					## [2.8.0] - 2019-04-10
 | 
				
			||||||
### Added
 | 
					### Added
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										9
									
								
								Gemfile
									
									
									
									
									
								
							
							
						
						
									
										9
									
								
								Gemfile
									
									
									
									
									
								
							| 
						 | 
					@ -21,6 +21,7 @@ gem 'fog-openstack', '~> 0.3', require: false
 | 
				
			||||||
gem 'paperclip', '~> 6.0'
 | 
					gem 'paperclip', '~> 6.0'
 | 
				
			||||||
gem 'paperclip-av-transcoder', '~> 0.6'
 | 
					gem 'paperclip-av-transcoder', '~> 0.6'
 | 
				
			||||||
gem 'streamio-ffmpeg', '~> 3.0'
 | 
					gem 'streamio-ffmpeg', '~> 3.0'
 | 
				
			||||||
 | 
					gem 'blurhash', '~> 0.1'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
gem 'active_model_serializers', '~> 0.10'
 | 
					gem 'active_model_serializers', '~> 0.10'
 | 
				
			||||||
gem 'addressable', '~> 2.6'
 | 
					gem 'addressable', '~> 2.6'
 | 
				
			||||||
| 
						 | 
					@ -66,7 +67,7 @@ gem 'ox', '~> 2.10'
 | 
				
			||||||
gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
 | 
					gem 'posix-spawn', git: 'https://github.com/rtomayko/posix-spawn', ref: '58465d2e213991f8afb13b984854a49fcdcc980c'
 | 
				
			||||||
gem 'pundit', '~> 2.0'
 | 
					gem 'pundit', '~> 2.0'
 | 
				
			||||||
gem 'premailer-rails'
 | 
					gem 'premailer-rails'
 | 
				
			||||||
gem 'rack-attack', '~> 5.4'
 | 
					gem 'rack-attack', '~> 6.0'
 | 
				
			||||||
gem 'rack-cors', '~> 1.0', require: 'rack/cors'
 | 
					gem 'rack-cors', '~> 1.0', require: 'rack/cors'
 | 
				
			||||||
gem 'rails-i18n', '~> 5.1'
 | 
					gem 'rails-i18n', '~> 5.1'
 | 
				
			||||||
gem 'rails-settings-cached', '~> 0.6'
 | 
					gem 'rails-settings-cached', '~> 0.6'
 | 
				
			||||||
| 
						 | 
					@ -124,14 +125,14 @@ group :development do
 | 
				
			||||||
  gem 'annotate', '~> 2.7'
 | 
					  gem 'annotate', '~> 2.7'
 | 
				
			||||||
  gem 'better_errors', '~> 2.5'
 | 
					  gem 'better_errors', '~> 2.5'
 | 
				
			||||||
  gem 'binding_of_caller', '~> 0.7'
 | 
					  gem 'binding_of_caller', '~> 0.7'
 | 
				
			||||||
  gem 'bullet', '~> 5.9'
 | 
					  gem 'bullet', '~> 6.0'
 | 
				
			||||||
  gem 'letter_opener', '~> 1.7'
 | 
					  gem 'letter_opener', '~> 1.7'
 | 
				
			||||||
  gem 'letter_opener_web', '~> 1.3'
 | 
					  gem 'letter_opener_web', '~> 1.3'
 | 
				
			||||||
  gem 'memory_profiler'
 | 
					  gem 'memory_profiler'
 | 
				
			||||||
  gem 'rubocop', '~> 0.67', require: false
 | 
					  gem 'rubocop', '~> 0.68', require: false
 | 
				
			||||||
  gem 'brakeman', '~> 4.5', require: false
 | 
					  gem 'brakeman', '~> 4.5', require: false
 | 
				
			||||||
  gem 'bundler-audit', '~> 0.6', require: false
 | 
					  gem 'bundler-audit', '~> 0.6', require: false
 | 
				
			||||||
  gem 'scss_lint', '~> 0.57', require: false
 | 
					  gem 'scss_lint', '~> 0.58', require: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  gem 'capistrano', '~> 3.11'
 | 
					  gem 'capistrano', '~> 3.11'
 | 
				
			||||||
  gem 'capistrano-rails', '~> 1.4'
 | 
					  gem 'capistrano-rails', '~> 1.4'
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										39
									
								
								Gemfile.lock
									
									
									
									
									
								
							
							
						
						
									
										39
									
								
								Gemfile.lock
									
									
									
									
									
								
							| 
						 | 
					@ -66,8 +66,8 @@ GEM
 | 
				
			||||||
      public_suffix (>= 2.0.2, < 4.0)
 | 
					      public_suffix (>= 2.0.2, < 4.0)
 | 
				
			||||||
    airbrussh (1.3.0)
 | 
					    airbrussh (1.3.0)
 | 
				
			||||||
      sshkit (>= 1.6.1, != 1.7.0)
 | 
					      sshkit (>= 1.6.1, != 1.7.0)
 | 
				
			||||||
    annotate (2.7.4)
 | 
					    annotate (2.7.5)
 | 
				
			||||||
      activerecord (>= 3.2, < 6.0)
 | 
					      activerecord (>= 3.2, < 7.0)
 | 
				
			||||||
      rake (>= 10.4, < 13.0)
 | 
					      rake (>= 10.4, < 13.0)
 | 
				
			||||||
    arel (9.0.0)
 | 
					    arel (9.0.0)
 | 
				
			||||||
    ast (2.4.0)
 | 
					    ast (2.4.0)
 | 
				
			||||||
| 
						 | 
					@ -99,12 +99,14 @@ GEM
 | 
				
			||||||
      rack (>= 0.9.0)
 | 
					      rack (>= 0.9.0)
 | 
				
			||||||
    binding_of_caller (0.8.0)
 | 
					    binding_of_caller (0.8.0)
 | 
				
			||||||
      debug_inspector (>= 0.0.1)
 | 
					      debug_inspector (>= 0.0.1)
 | 
				
			||||||
    bootsnap (1.4.3)
 | 
					    blurhash (0.1.2)
 | 
				
			||||||
 | 
					      ffi (~> 1.10.0)
 | 
				
			||||||
 | 
					    bootsnap (1.4.4)
 | 
				
			||||||
      msgpack (~> 1.0)
 | 
					      msgpack (~> 1.0)
 | 
				
			||||||
    brakeman (4.5.0)
 | 
					    brakeman (4.5.0)
 | 
				
			||||||
    browser (2.5.3)
 | 
					    browser (2.5.3)
 | 
				
			||||||
    builder (3.2.3)
 | 
					    builder (3.2.3)
 | 
				
			||||||
    bullet (5.9.0)
 | 
					    bullet (6.0.0)
 | 
				
			||||||
      activesupport (>= 3.0.0)
 | 
					      activesupport (>= 3.0.0)
 | 
				
			||||||
      uniform_notifier (~> 1.11)
 | 
					      uniform_notifier (~> 1.11)
 | 
				
			||||||
    bundler-audit (0.6.1)
 | 
					    bundler-audit (0.6.1)
 | 
				
			||||||
| 
						 | 
					@ -205,7 +207,7 @@ GEM
 | 
				
			||||||
    et-orbi (1.1.6)
 | 
					    et-orbi (1.1.6)
 | 
				
			||||||
      tzinfo
 | 
					      tzinfo
 | 
				
			||||||
    excon (0.62.0)
 | 
					    excon (0.62.0)
 | 
				
			||||||
    fabrication (2.20.1)
 | 
					    fabrication (2.20.2)
 | 
				
			||||||
    faker (1.9.3)
 | 
					    faker (1.9.3)
 | 
				
			||||||
      i18n (>= 0.7)
 | 
					      i18n (>= 0.7)
 | 
				
			||||||
    faraday (0.15.0)
 | 
					    faraday (0.15.0)
 | 
				
			||||||
| 
						 | 
					@ -348,7 +350,7 @@ GEM
 | 
				
			||||||
    mini_mime (1.0.1)
 | 
					    mini_mime (1.0.1)
 | 
				
			||||||
    mini_portile2 (2.4.0)
 | 
					    mini_portile2 (2.4.0)
 | 
				
			||||||
    minitest (5.11.3)
 | 
					    minitest (5.11.3)
 | 
				
			||||||
    msgpack (1.2.9)
 | 
					    msgpack (1.2.10)
 | 
				
			||||||
    multi_json (1.13.1)
 | 
					    multi_json (1.13.1)
 | 
				
			||||||
    multipart-post (2.0.0)
 | 
					    multipart-post (2.0.0)
 | 
				
			||||||
    necromancer (0.4.0)
 | 
					    necromancer (0.4.0)
 | 
				
			||||||
| 
						 | 
					@ -395,7 +397,7 @@ GEM
 | 
				
			||||||
    parallel (1.17.0)
 | 
					    parallel (1.17.0)
 | 
				
			||||||
    parallel_tests (2.28.0)
 | 
					    parallel_tests (2.28.0)
 | 
				
			||||||
      parallel
 | 
					      parallel
 | 
				
			||||||
    parser (2.6.2.1)
 | 
					    parser (2.6.3.0)
 | 
				
			||||||
      ast (~> 2.4.0)
 | 
					      ast (~> 2.4.0)
 | 
				
			||||||
    pastel (0.7.2)
 | 
					    pastel (0.7.2)
 | 
				
			||||||
      equatable (~> 0.5.0)
 | 
					      equatable (~> 0.5.0)
 | 
				
			||||||
| 
						 | 
					@ -420,14 +422,13 @@ GEM
 | 
				
			||||||
      pry (~> 0.10)
 | 
					      pry (~> 0.10)
 | 
				
			||||||
    pry-rails (0.3.9)
 | 
					    pry-rails (0.3.9)
 | 
				
			||||||
      pry (>= 0.10.4)
 | 
					      pry (>= 0.10.4)
 | 
				
			||||||
    psych (3.1.0)
 | 
					 | 
				
			||||||
    public_suffix (3.0.3)
 | 
					    public_suffix (3.0.3)
 | 
				
			||||||
    puma (3.12.1)
 | 
					    puma (3.12.1)
 | 
				
			||||||
    pundit (2.0.1)
 | 
					    pundit (2.0.1)
 | 
				
			||||||
      activesupport (>= 3.0.0)
 | 
					      activesupport (>= 3.0.0)
 | 
				
			||||||
    raabro (1.1.6)
 | 
					    raabro (1.1.6)
 | 
				
			||||||
    rack (2.0.7)
 | 
					    rack (2.0.7)
 | 
				
			||||||
    rack-attack (5.4.2)
 | 
					    rack-attack (6.0.0)
 | 
				
			||||||
      rack (>= 1.0, < 3)
 | 
					      rack (>= 1.0, < 3)
 | 
				
			||||||
    rack-cors (1.0.3)
 | 
					    rack-cors (1.0.3)
 | 
				
			||||||
    rack-protection (2.0.5)
 | 
					    rack-protection (2.0.5)
 | 
				
			||||||
| 
						 | 
					@ -472,8 +473,8 @@ GEM
 | 
				
			||||||
    rainbow (3.0.0)
 | 
					    rainbow (3.0.0)
 | 
				
			||||||
    rake (12.3.2)
 | 
					    rake (12.3.2)
 | 
				
			||||||
    rb-fsevent (0.10.3)
 | 
					    rb-fsevent (0.10.3)
 | 
				
			||||||
    rb-inotify (0.9.10)
 | 
					    rb-inotify (0.10.0)
 | 
				
			||||||
      ffi (>= 0.5.0, < 2)
 | 
					      ffi (~> 1.0)
 | 
				
			||||||
    rdf (3.0.9)
 | 
					    rdf (3.0.9)
 | 
				
			||||||
      hamster (~> 3.0)
 | 
					      hamster (~> 3.0)
 | 
				
			||||||
      link_header (~> 0.0, >= 0.0.8)
 | 
					      link_header (~> 0.0, >= 0.0.8)
 | 
				
			||||||
| 
						 | 
					@ -528,11 +529,10 @@ GEM
 | 
				
			||||||
      rspec-core (~> 3.0, >= 3.0.0)
 | 
					      rspec-core (~> 3.0, >= 3.0.0)
 | 
				
			||||||
      sidekiq (>= 2.4.0)
 | 
					      sidekiq (>= 2.4.0)
 | 
				
			||||||
    rspec-support (3.8.0)
 | 
					    rspec-support (3.8.0)
 | 
				
			||||||
    rubocop (0.67.2)
 | 
					    rubocop (0.68.1)
 | 
				
			||||||
      jaro_winkler (~> 1.5.1)
 | 
					      jaro_winkler (~> 1.5.1)
 | 
				
			||||||
      parallel (~> 1.10)
 | 
					      parallel (~> 1.10)
 | 
				
			||||||
      parser (>= 2.5, != 2.5.1.1)
 | 
					      parser (>= 2.5, != 2.5.1.1)
 | 
				
			||||||
      psych (>= 3.1.0)
 | 
					 | 
				
			||||||
      rainbow (>= 2.2.2, < 4.0)
 | 
					      rainbow (>= 2.2.2, < 4.0)
 | 
				
			||||||
      ruby-progressbar (~> 1.7)
 | 
					      ruby-progressbar (~> 1.7)
 | 
				
			||||||
      unicode-display_width (>= 1.4.0, < 1.6)
 | 
					      unicode-display_width (>= 1.4.0, < 1.6)
 | 
				
			||||||
| 
						 | 
					@ -546,12 +546,12 @@ GEM
 | 
				
			||||||
      crass (~> 1.0.2)
 | 
					      crass (~> 1.0.2)
 | 
				
			||||||
      nokogiri (>= 1.8.0)
 | 
					      nokogiri (>= 1.8.0)
 | 
				
			||||||
      nokogumbo (~> 2.0)
 | 
					      nokogumbo (~> 2.0)
 | 
				
			||||||
    sass (3.6.0)
 | 
					    sass (3.7.4)
 | 
				
			||||||
      sass-listen (~> 4.0.0)
 | 
					      sass-listen (~> 4.0.0)
 | 
				
			||||||
    sass-listen (4.0.0)
 | 
					    sass-listen (4.0.0)
 | 
				
			||||||
      rb-fsevent (~> 0.9, >= 0.9.4)
 | 
					      rb-fsevent (~> 0.9, >= 0.9.4)
 | 
				
			||||||
      rb-inotify (~> 0.9, >= 0.9.7)
 | 
					      rb-inotify (~> 0.9, >= 0.9.7)
 | 
				
			||||||
    scss_lint (0.57.1)
 | 
					    scss_lint (0.58.0)
 | 
				
			||||||
      rake (>= 0.9, < 13)
 | 
					      rake (>= 0.9, < 13)
 | 
				
			||||||
      sass (~> 3.5, >= 3.5.5)
 | 
					      sass (~> 3.5, >= 3.5.5)
 | 
				
			||||||
    sidekiq (5.2.7)
 | 
					    sidekiq (5.2.7)
 | 
				
			||||||
| 
						 | 
					@ -663,10 +663,11 @@ DEPENDENCIES
 | 
				
			||||||
  aws-sdk-s3 (~> 1.36)
 | 
					  aws-sdk-s3 (~> 1.36)
 | 
				
			||||||
  better_errors (~> 2.5)
 | 
					  better_errors (~> 2.5)
 | 
				
			||||||
  binding_of_caller (~> 0.7)
 | 
					  binding_of_caller (~> 0.7)
 | 
				
			||||||
 | 
					  blurhash (~> 0.1)
 | 
				
			||||||
  bootsnap (~> 1.4)
 | 
					  bootsnap (~> 1.4)
 | 
				
			||||||
  brakeman (~> 4.5)
 | 
					  brakeman (~> 4.5)
 | 
				
			||||||
  browser
 | 
					  browser
 | 
				
			||||||
  bullet (~> 5.9)
 | 
					  bullet (~> 6.0)
 | 
				
			||||||
  bundler-audit (~> 0.6)
 | 
					  bundler-audit (~> 0.6)
 | 
				
			||||||
  capistrano (~> 3.11)
 | 
					  capistrano (~> 3.11)
 | 
				
			||||||
  capistrano-rails (~> 1.4)
 | 
					  capistrano-rails (~> 1.4)
 | 
				
			||||||
| 
						 | 
					@ -737,7 +738,7 @@ DEPENDENCIES
 | 
				
			||||||
  pry-rails (~> 0.3)
 | 
					  pry-rails (~> 0.3)
 | 
				
			||||||
  puma (~> 3.12)
 | 
					  puma (~> 3.12)
 | 
				
			||||||
  pundit (~> 2.0)
 | 
					  pundit (~> 2.0)
 | 
				
			||||||
  rack-attack (~> 5.4)
 | 
					  rack-attack (~> 6.0)
 | 
				
			||||||
  rack-cors (~> 1.0)
 | 
					  rack-cors (~> 1.0)
 | 
				
			||||||
  rails (~> 5.2.3)
 | 
					  rails (~> 5.2.3)
 | 
				
			||||||
  rails-controller-testing (~> 1.0)
 | 
					  rails-controller-testing (~> 1.0)
 | 
				
			||||||
| 
						 | 
					@ -750,9 +751,9 @@ DEPENDENCIES
 | 
				
			||||||
  rqrcode (~> 0.10)
 | 
					  rqrcode (~> 0.10)
 | 
				
			||||||
  rspec-rails (~> 3.8)
 | 
					  rspec-rails (~> 3.8)
 | 
				
			||||||
  rspec-sidekiq (~> 3.0)
 | 
					  rspec-sidekiq (~> 3.0)
 | 
				
			||||||
  rubocop (~> 0.67)
 | 
					  rubocop (~> 0.68)
 | 
				
			||||||
  sanitize (~> 5.0)
 | 
					  sanitize (~> 5.0)
 | 
				
			||||||
  scss_lint (~> 0.57)
 | 
					  scss_lint (~> 0.58)
 | 
				
			||||||
  sidekiq (~> 5.2)
 | 
					  sidekiq (~> 5.2)
 | 
				
			||||||
  sidekiq-bulk (~> 0.2.0)
 | 
					  sidekiq-bulk (~> 0.2.0)
 | 
				
			||||||
  sidekiq-scheduler (~> 3.0)
 | 
					  sidekiq-scheduler (~> 3.0)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -13,7 +13,18 @@ module Admin
 | 
				
			||||||
      authorize :domain_block, :create?
 | 
					      authorize :domain_block, :create?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      @domain_block = DomainBlock.new(resource_params)
 | 
					      @domain_block = DomainBlock.new(resource_params)
 | 
				
			||||||
 | 
					      existing_domain_block = resource_params[:domain].present? ? DomainBlock.find_by(domain: resource_params[:domain]) : nil
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
 | 
				
			||||||
 | 
					        @domain_block.save
 | 
				
			||||||
 | 
					        flash[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe # rubocop:disable Rails/OutputSafety
 | 
				
			||||||
 | 
					        @domain_block.errors[:domain].clear
 | 
				
			||||||
 | 
					        render :new
 | 
				
			||||||
 | 
					      else
 | 
				
			||||||
 | 
					        if existing_domain_block.present?
 | 
				
			||||||
 | 
					          @domain_block = existing_domain_block
 | 
				
			||||||
 | 
					          @domain_block.update(resource_params)
 | 
				
			||||||
 | 
					        end
 | 
				
			||||||
        if @domain_block.save
 | 
					        if @domain_block.save
 | 
				
			||||||
          DomainBlockWorker.perform_async(@domain_block.id)
 | 
					          DomainBlockWorker.perform_async(@domain_block.id)
 | 
				
			||||||
          log_action :create, @domain_block
 | 
					          log_action :create, @domain_block
 | 
				
			||||||
| 
						 | 
					@ -22,6 +33,7 @@ module Admin
 | 
				
			||||||
          render :new
 | 
					          render :new
 | 
				
			||||||
        end
 | 
					        end
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def show
 | 
					    def show
 | 
				
			||||||
      authorize @domain_block, :show?
 | 
					      authorize @domain_block, :show?
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,6 +9,8 @@ class Api::BaseController < ApplicationController
 | 
				
			||||||
  skip_before_action :store_current_location
 | 
					  skip_before_action :store_current_location
 | 
				
			||||||
  skip_before_action :check_user_permissions
 | 
					  skip_before_action :check_user_permissions
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  before_action :set_cache_headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  protect_from_forgery with: :null_session
 | 
					  protect_from_forgery with: :null_session
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
 | 
					  rescue_from ActiveRecord::RecordInvalid, Mastodon::ValidationError do |e|
 | 
				
			||||||
| 
						 | 
					@ -88,4 +90,8 @@ class Api::BaseController < ApplicationController
 | 
				
			||||||
  def authorize_if_got_token!(*scopes)
 | 
					  def authorize_if_got_token!(*scopes)
 | 
				
			||||||
    doorkeeper_authorize!(*scopes) if doorkeeper_token
 | 
					    doorkeeper_authorize!(*scopes) if doorkeeper_token
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def set_cache_headers
 | 
				
			||||||
 | 
					    response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,8 @@
 | 
				
			||||||
class Api::V1::CustomEmojisController < Api::BaseController
 | 
					class Api::V1::CustomEmojisController < Api::BaseController
 | 
				
			||||||
  respond_to :json
 | 
					  respond_to :json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  skip_before_action :set_cache_headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def index
 | 
					  def index
 | 
				
			||||||
    render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
 | 
					    render_cached_json('api:v1:custom_emojis', expires_in: 1.minute) do
 | 
				
			||||||
      ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
 | 
					      ActiveModelSerializers::SerializableResource.new(CustomEmoji.local.where(disabled: false), each_serializer: REST::CustomEmojiSerializer)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,6 +2,7 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Api::V1::Instances::ActivityController < Api::BaseController
 | 
					class Api::V1::Instances::ActivityController < Api::BaseController
 | 
				
			||||||
  before_action :require_enabled_api!
 | 
					  before_action :require_enabled_api!
 | 
				
			||||||
 | 
					  skip_before_action :set_cache_headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  respond_to :json
 | 
					  respond_to :json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,6 +2,7 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Api::V1::Instances::PeersController < Api::BaseController
 | 
					class Api::V1::Instances::PeersController < Api::BaseController
 | 
				
			||||||
  before_action :require_enabled_api!
 | 
					  before_action :require_enabled_api!
 | 
				
			||||||
 | 
					  skip_before_action :set_cache_headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  respond_to :json
 | 
					  respond_to :json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,6 +2,7 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class Api::V1::InstancesController < Api::BaseController
 | 
					class Api::V1::InstancesController < Api::BaseController
 | 
				
			||||||
  respond_to :json
 | 
					  respond_to :json
 | 
				
			||||||
 | 
					  skip_before_action :set_cache_headers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def show
 | 
					  def show
 | 
				
			||||||
    render_cached_json('api:v1:instances', expires_in: 5.minutes) do
 | 
					    render_cached_json('api:v1:instances', expires_in: 5.minutes) do
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -96,7 +96,8 @@ class Auth::RegistrationsController < Devise::RegistrationsController
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def set_invite
 | 
					  def set_invite
 | 
				
			||||||
    @invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
 | 
					    invite = invite_code.present? ? Invite.find_by(code: invite_code) : nil
 | 
				
			||||||
 | 
					    @invite = invite&.valid_for_use? ? invite : nil
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def determine_layout
 | 
					  def determine_layout
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -21,7 +21,7 @@ class Settings::NotificationsController < Settings::BaseController
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def user_settings_params
 | 
					  def user_settings_params
 | 
				
			||||||
    params.require(:user).permit(
 | 
					    params.require(:user).permit(
 | 
				
			||||||
      notification_emails: %i(follow follow_request reblog favourite mention digest report),
 | 
					      notification_emails: %i(follow follow_request reblog favourite mention digest report pending_account),
 | 
				
			||||||
      interactions: %i(must_be_follower must_be_following must_be_following_dm)
 | 
					      interactions: %i(must_be_follower must_be_following must_be_following_dm)
 | 
				
			||||||
    )
 | 
					    )
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -203,8 +203,8 @@ export function uploadCompose(files) {
 | 
				
			||||||
  return function (dispatch, getState) {
 | 
					  return function (dispatch, getState) {
 | 
				
			||||||
    const uploadLimit = 4;
 | 
					    const uploadLimit = 4;
 | 
				
			||||||
    const media  = getState().getIn(['compose', 'media_attachments']);
 | 
					    const media  = getState().getIn(['compose', 'media_attachments']);
 | 
				
			||||||
    const total = Array.from(files).reduce((a, v) => a + v.size, 0);
 | 
					 | 
				
			||||||
    const progress = new Array(files.length).fill(0);
 | 
					    const progress = new Array(files.length).fill(0);
 | 
				
			||||||
 | 
					    let total = Array.from(files).reduce((a, v) => a + v.size, 0);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (files.length + media.size > uploadLimit) {
 | 
					    if (files.length + media.size > uploadLimit) {
 | 
				
			||||||
      dispatch(showAlert(undefined, messages.uploadErrorLimit));
 | 
					      dispatch(showAlert(undefined, messages.uploadErrorLimit));
 | 
				
			||||||
| 
						 | 
					@ -224,6 +224,8 @@ export function uploadCompose(files) {
 | 
				
			||||||
      resizeImage(f).then(file => {
 | 
					      resizeImage(f).then(file => {
 | 
				
			||||||
        const data = new FormData();
 | 
					        const data = new FormData();
 | 
				
			||||||
        data.append('file', file);
 | 
					        data.append('file', file);
 | 
				
			||||||
 | 
					        // Account for disparity in size of original image and resized data
 | 
				
			||||||
 | 
					        total += file.size - f.size;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        return api(getState).post('/api/v1/media', data, {
 | 
					        return api(getState).post('/api/v1/media', data, {
 | 
				
			||||||
          onUploadProgress: function({ loaded }){
 | 
					          onUploadProgress: function({ loaded }){
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -96,7 +96,7 @@ export const expandPublicTimeline          = ({ maxId, onlyMedia } = {}, done =
 | 
				
			||||||
export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
 | 
					export const expandCommunityTimeline       = ({ maxId, onlyMedia } = {}, done = noOp) => expandTimeline(`community${onlyMedia ? ':media' : ''}`, '/api/v1/timelines/public', { local: true, max_id: maxId, only_media: !!onlyMedia }, done);
 | 
				
			||||||
export const expandAccountTimeline         = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
 | 
					export const expandAccountTimeline         = (accountId, { maxId, withReplies } = {}) => expandTimeline(`account:${accountId}${withReplies ? ':with_replies' : ''}`, `/api/v1/accounts/${accountId}/statuses`, { exclude_replies: !withReplies, max_id: maxId });
 | 
				
			||||||
export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
 | 
					export const expandAccountFeaturedTimeline = accountId => expandTimeline(`account:${accountId}:pinned`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true });
 | 
				
			||||||
export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true });
 | 
					export const expandAccountMediaTimeline    = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
 | 
				
			||||||
export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 | 
					export const expandListTimeline            = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
 | 
				
			||||||
export const expandHashtagTimeline         = (hashtag, { maxId, tags } = {}, done = noOp) => {
 | 
					export const expandHashtagTimeline         = (hashtag, { maxId, tags } = {}, done = noOp) => {
 | 
				
			||||||
  return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
 | 
					  return expandTimeline(`hashtag:${hashtag}`, `/api/v1/timelines/tag/${hashtag}`, {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,7 @@ import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
				
			||||||
import { isIOS } from '../is_mobile';
 | 
					import { isIOS } from '../is_mobile';
 | 
				
			||||||
import classNames from 'classnames';
 | 
					import classNames from 'classnames';
 | 
				
			||||||
import { autoPlayGif, displayMedia } from '../initial_state';
 | 
					import { autoPlayGif, displayMedia } from '../initial_state';
 | 
				
			||||||
 | 
					import { decode } from 'blurhash';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
 | 
					  toggle_visible: { id: 'media_gallery.toggle_visible', defaultMessage: 'Toggle visibility' },
 | 
				
			||||||
| 
						 | 
					@ -21,6 +22,7 @@ class Item extends React.PureComponent {
 | 
				
			||||||
    size: PropTypes.number.isRequired,
 | 
					    size: PropTypes.number.isRequired,
 | 
				
			||||||
    onClick: PropTypes.func.isRequired,
 | 
					    onClick: PropTypes.func.isRequired,
 | 
				
			||||||
    displayWidth: PropTypes.number,
 | 
					    displayWidth: PropTypes.number,
 | 
				
			||||||
 | 
					    visible: PropTypes.bool.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static defaultProps = {
 | 
					  static defaultProps = {
 | 
				
			||||||
| 
						 | 
					@ -29,6 +31,10 @@ class Item extends React.PureComponent {
 | 
				
			||||||
    size: 1,
 | 
					    size: 1,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  state = {
 | 
				
			||||||
 | 
					    loaded: false,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMouseEnter = (e) => {
 | 
					  handleMouseEnter = (e) => {
 | 
				
			||||||
    if (this.hoverToPlay()) {
 | 
					    if (this.hoverToPlay()) {
 | 
				
			||||||
      e.target.play();
 | 
					      e.target.play();
 | 
				
			||||||
| 
						 | 
					@ -62,8 +68,40 @@ class Item extends React.PureComponent {
 | 
				
			||||||
    e.stopPropagation();
 | 
					    e.stopPropagation();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentDidMount () {
 | 
				
			||||||
 | 
					    if (this.props.attachment.get('blurhash')) {
 | 
				
			||||||
 | 
					      this._decode();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentDidUpdate (prevProps) {
 | 
				
			||||||
 | 
					    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
 | 
				
			||||||
 | 
					      this._decode();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  _decode () {
 | 
				
			||||||
 | 
					    const hash   = this.props.attachment.get('blurhash');
 | 
				
			||||||
 | 
					    const pixels = decode(hash, 32, 32);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (pixels) {
 | 
				
			||||||
 | 
					      const ctx       = this.canvas.getContext('2d');
 | 
				
			||||||
 | 
					      const imageData = new ImageData(pixels, 32, 32);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      ctx.putImageData(imageData, 0, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setCanvasRef = c => {
 | 
				
			||||||
 | 
					    this.canvas = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleImageLoad = () => {
 | 
				
			||||||
 | 
					    this.setState({ loaded: true });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { attachment, index, size, standalone, displayWidth } = this.props;
 | 
					    const { attachment, index, size, standalone, displayWidth, visible } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let width  = 50;
 | 
					    let width  = 50;
 | 
				
			||||||
    let height = 100;
 | 
					    let height = 100;
 | 
				
			||||||
| 
						 | 
					@ -116,7 +154,15 @@ class Item extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let thumbnail = '';
 | 
					    let thumbnail = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (attachment.get('type') === 'image') {
 | 
					    if (attachment.get('type') === 'unknown') {
 | 
				
			||||||
 | 
					      return (
 | 
				
			||||||
 | 
					        <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
 | 
				
			||||||
 | 
					          <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url')} target='_blank' style={{ cursor: 'pointer' }}>
 | 
				
			||||||
 | 
					            <canvas width={32} height={32} ref={this.setCanvasRef} className='media-gallery__preview' />
 | 
				
			||||||
 | 
					          </a>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } else if (attachment.get('type') === 'image') {
 | 
				
			||||||
      const previewUrl   = attachment.get('preview_url');
 | 
					      const previewUrl   = attachment.get('preview_url');
 | 
				
			||||||
      const previewWidth = attachment.getIn(['meta', 'small', 'width']);
 | 
					      const previewWidth = attachment.getIn(['meta', 'small', 'width']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -147,6 +193,7 @@ class Item extends React.PureComponent {
 | 
				
			||||||
            alt={attachment.get('description')}
 | 
					            alt={attachment.get('description')}
 | 
				
			||||||
            title={attachment.get('description')}
 | 
					            title={attachment.get('description')}
 | 
				
			||||||
            style={{ objectPosition: `${x}% ${y}%` }}
 | 
					            style={{ objectPosition: `${x}% ${y}%` }}
 | 
				
			||||||
 | 
					            onLoad={this.handleImageLoad}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
        </a>
 | 
					        </a>
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
| 
						 | 
					@ -176,7 +223,8 @@ class Item extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
 | 
					      <div className={classNames('media-gallery__item', { standalone })} key={attachment.get('id')} style={{ left: left, top: top, right: right, bottom: bottom, width: `${width}%`, height: `${height}%` }}>
 | 
				
			||||||
        {thumbnail}
 | 
					        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && this.state.loaded })} />
 | 
				
			||||||
 | 
					        {visible && thumbnail}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -225,6 +273,7 @@ class MediaGallery extends React.PureComponent {
 | 
				
			||||||
    if (node /*&& this.isStandaloneEligible()*/) {
 | 
					    if (node /*&& this.isStandaloneEligible()*/) {
 | 
				
			||||||
      // offsetWidth triggers a layout, so only calculate when we need to
 | 
					      // offsetWidth triggers a layout, so only calculate when we need to
 | 
				
			||||||
      if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
 | 
					      if (this.props.cacheWidth) this.props.cacheWidth(node.offsetWidth);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.setState({
 | 
					      this.setState({
 | 
				
			||||||
        width: node.offsetWidth,
 | 
					        width: node.offsetWidth,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
| 
						 | 
					@ -242,7 +291,7 @@ class MediaGallery extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const width = this.state.width || defaultWidth;
 | 
					    const width = this.state.width || defaultWidth;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let children;
 | 
					    let children, spoilerButton;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const style = {};
 | 
					    const style = {};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -256,35 +305,28 @@ class MediaGallery extends React.PureComponent {
 | 
				
			||||||
      style.height = height;
 | 
					      style.height = height;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!visible) {
 | 
					 | 
				
			||||||
      let warning;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      if (sensitive) {
 | 
					 | 
				
			||||||
        warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      children = (
 | 
					 | 
				
			||||||
        <button type='button' className='media-spoiler' onClick={this.handleOpen} style={style} ref={this.handleRef}>
 | 
					 | 
				
			||||||
          <span className='media-spoiler__warning'>{warning}</span>
 | 
					 | 
				
			||||||
          <span className='media-spoiler__trigger'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
 | 
					 | 
				
			||||||
        </button>
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
    const size = media.take(4).size;
 | 
					    const size = media.take(4).size;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.isStandaloneEligible()) {
 | 
					    if (this.isStandaloneEligible()) {
 | 
				
			||||||
        children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} />;
 | 
					      children = <Item standalone onClick={this.handleClick} attachment={media.get(0)} displayWidth={width} visible={visible} />;
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
        children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} />);
 | 
					      children = media.take(4).map((attachment, i) => <Item key={attachment.get('id')} onClick={this.handleClick} attachment={attachment} index={i} size={size} displayWidth={width} visible={visible} />);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (visible) {
 | 
				
			||||||
 | 
					      spoilerButton = <IconButton title={intl.formatMessage(messages.toggle_visible)} icon='eye-slash' overlay onClick={this.handleOpen} />;
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      spoilerButton = (
 | 
				
			||||||
 | 
					        <button type='button' onClick={this.handleOpen} className='spoiler-button__overlay'>
 | 
				
			||||||
 | 
					          <span className='spoiler-button__overlay__label'>{sensitive ? <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' /> : <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />}</span>
 | 
				
			||||||
 | 
					        </button>
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='media-gallery' style={style} ref={this.handleRef}>
 | 
					      <div className='media-gallery' style={style} ref={this.handleRef}>
 | 
				
			||||||
        <div className={classNames('spoiler-button', { 'spoiler-button--visible': visible })}>
 | 
					        <div className={classNames('spoiler-button', { 'spoiler-button--minified': visible })}>
 | 
				
			||||||
          <IconButton title={intl.formatMessage(messages.toggle_visible)} icon={visible ? 'eye' : 'eye-slash'} overlay onClick={this.handleOpen} />
 | 
					          {spoilerButton}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        {children}
 | 
					        {children}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -274,7 +274,7 @@ class Status extends ImmutablePureComponent {
 | 
				
			||||||
    if (status.get('poll')) {
 | 
					    if (status.get('poll')) {
 | 
				
			||||||
      media = <PollContainer pollId={status.get('poll')} />;
 | 
					      media = <PollContainer pollId={status.get('poll')} />;
 | 
				
			||||||
    } else if (status.get('media_attachments').size > 0) {
 | 
					    } else if (status.get('media_attachments').size > 0) {
 | 
				
			||||||
      if (this.props.muted || status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
 | 
					      if (this.props.muted) {
 | 
				
			||||||
        media = (
 | 
					        media = (
 | 
				
			||||||
          <AttachmentList
 | 
					          <AttachmentList
 | 
				
			||||||
            compact
 | 
					            compact
 | 
				
			||||||
| 
						 | 
					@ -289,6 +289,7 @@ class Status extends ImmutablePureComponent {
 | 
				
			||||||
            {Component => (
 | 
					            {Component => (
 | 
				
			||||||
              <Component
 | 
					              <Component
 | 
				
			||||||
                preview={video.get('preview_url')}
 | 
					                preview={video.get('preview_url')}
 | 
				
			||||||
 | 
					                blurhash={video.get('blurhash')}
 | 
				
			||||||
                src={video.get('url')}
 | 
					                src={video.get('url')}
 | 
				
			||||||
                alt={video.get('description')}
 | 
					                alt={video.get('description')}
 | 
				
			||||||
                width={this.props.cachedMediaWidth}
 | 
					                width={this.props.cachedMediaWidth}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -46,22 +46,28 @@ export default class StatusList extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMoveUp = (id, featured) => {
 | 
					  handleMoveUp = (id, featured) => {
 | 
				
			||||||
    const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
 | 
					    const elementIndex = this.getCurrentStatusIndex(id, featured) - 1;
 | 
				
			||||||
    this._selectChild(elementIndex);
 | 
					    this._selectChild(elementIndex, true);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMoveDown = (id, featured) => {
 | 
					  handleMoveDown = (id, featured) => {
 | 
				
			||||||
    const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
 | 
					    const elementIndex = this.getCurrentStatusIndex(id, featured) + 1;
 | 
				
			||||||
    this._selectChild(elementIndex);
 | 
					    this._selectChild(elementIndex, false);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleLoadOlder = debounce(() => {
 | 
					  handleLoadOlder = debounce(() => {
 | 
				
			||||||
    this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
 | 
					    this.props.onLoadMore(this.props.statusIds.size > 0 ? this.props.statusIds.last() : undefined);
 | 
				
			||||||
  }, 300, { leading: true })
 | 
					  }, 300, { leading: true })
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  _selectChild (index) {
 | 
					  _selectChild (index, align_top) {
 | 
				
			||||||
    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 | 
					    const container = this.node.node;
 | 
				
			||||||
 | 
					    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (element) {
 | 
					    if (element) {
 | 
				
			||||||
 | 
					      if (align_top && container.scrollTop > element.offsetTop) {
 | 
				
			||||||
 | 
					        element.scrollIntoView(true);
 | 
				
			||||||
 | 
					      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
 | 
				
			||||||
 | 
					        element.scrollIntoView(false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      element.focus();
 | 
					      element.focus();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,62 +1,142 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
import Permalink from '../../../components/permalink';
 | 
					import { autoPlayGif, displayMedia } from 'mastodon/initial_state';
 | 
				
			||||||
import { displayMedia } from '../../../initial_state';
 | 
					import classNames from 'classnames';
 | 
				
			||||||
import Icon from 'mastodon/components/icon';
 | 
					import { decode } from 'blurhash';
 | 
				
			||||||
 | 
					import { isIOS } from 'mastodon/is_mobile';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class MediaItem extends ImmutablePureComponent {
 | 
					export default class MediaItem extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    media: ImmutablePropTypes.map.isRequired,
 | 
					    attachment: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
 | 
					    displayWidth: PropTypes.number.isRequired,
 | 
				
			||||||
 | 
					    onOpenMedia: PropTypes.func.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  state = {
 | 
					  state = {
 | 
				
			||||||
    visible: displayMedia !== 'hide_all' && !this.props.media.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
 | 
					    visible: displayMedia !== 'hide_all' && !this.props.attachment.getIn(['status', 'sensitive']) || displayMedia === 'show_all',
 | 
				
			||||||
 | 
					    loaded: false,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleClick = () => {
 | 
					  componentDidMount () {
 | 
				
			||||||
    if (!this.state.visible) {
 | 
					    if (this.props.attachment.get('blurhash')) {
 | 
				
			||||||
      this.setState({ visible: true });
 | 
					      this._decode();
 | 
				
			||||||
      return true;
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return false;
 | 
					  componentDidUpdate (prevProps) {
 | 
				
			||||||
 | 
					    if (prevProps.attachment.get('blurhash') !== this.props.attachment.get('blurhash') && this.props.attachment.get('blurhash')) {
 | 
				
			||||||
 | 
					      this._decode();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  _decode () {
 | 
				
			||||||
 | 
					    const hash   = this.props.attachment.get('blurhash');
 | 
				
			||||||
 | 
					    const pixels = decode(hash, 32, 32);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (pixels) {
 | 
				
			||||||
 | 
					      const ctx       = this.canvas.getContext('2d');
 | 
				
			||||||
 | 
					      const imageData = new ImageData(pixels, 32, 32);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      ctx.putImageData(imageData, 0, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setCanvasRef = c => {
 | 
				
			||||||
 | 
					    this.canvas = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleImageLoad = () => {
 | 
				
			||||||
 | 
					    this.setState({ loaded: true });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleMouseEnter = e => {
 | 
				
			||||||
 | 
					    if (this.hoverToPlay()) {
 | 
				
			||||||
 | 
					      e.target.play();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleMouseLeave = e => {
 | 
				
			||||||
 | 
					    if (this.hoverToPlay()) {
 | 
				
			||||||
 | 
					      e.target.pause();
 | 
				
			||||||
 | 
					      e.target.currentTime = 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  hoverToPlay () {
 | 
				
			||||||
 | 
					    return !autoPlayGif && ['gifv', 'video'].indexOf(this.props.attachment.get('type')) !== -1;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleClick = e => {
 | 
				
			||||||
 | 
					    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
 | 
				
			||||||
 | 
					      e.preventDefault();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (this.state.visible) {
 | 
				
			||||||
 | 
					        this.props.onOpenMedia(this.props.attachment);
 | 
				
			||||||
 | 
					      } else {
 | 
				
			||||||
 | 
					        this.setState({ visible: true });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { media } = this.props;
 | 
					    const { attachment, displayWidth } = this.props;
 | 
				
			||||||
    const { visible } = this.state;
 | 
					    const { visible, loaded } = this.state;
 | 
				
			||||||
    const status = media.get('status');
 | 
					
 | 
				
			||||||
    const focusX = media.getIn(['meta', 'focus', 'x']);
 | 
					    const width  = `${Math.floor((displayWidth - 4) / 3) - 4}px`;
 | 
				
			||||||
    const focusY = media.getIn(['meta', 'focus', 'y']);
 | 
					    const height = width;
 | 
				
			||||||
 | 
					    const status = attachment.get('status');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    let thumbnail = '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (attachment.get('type') === 'unknown') {
 | 
				
			||||||
 | 
					      // Skip
 | 
				
			||||||
 | 
					    } else if (attachment.get('type') === 'image') {
 | 
				
			||||||
 | 
					      const focusX = attachment.getIn(['meta', 'focus', 'x']) || 0;
 | 
				
			||||||
 | 
					      const focusY = attachment.getIn(['meta', 'focus', 'y']) || 0;
 | 
				
			||||||
      const x      = ((focusX /  2) + .5) * 100;
 | 
					      const x      = ((focusX /  2) + .5) * 100;
 | 
				
			||||||
      const y      = ((focusY / -2) + .5) * 100;
 | 
					      const y      = ((focusY / -2) + .5) * 100;
 | 
				
			||||||
    const style = {};
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let label, icon;
 | 
					      thumbnail = (
 | 
				
			||||||
 | 
					        <img
 | 
				
			||||||
 | 
					          src={attachment.get('preview_url')}
 | 
				
			||||||
 | 
					          alt={attachment.get('description')}
 | 
				
			||||||
 | 
					          title={attachment.get('description')}
 | 
				
			||||||
 | 
					          style={{ objectPosition: `${x}% ${y}%` }}
 | 
				
			||||||
 | 
					          onLoad={this.handleImageLoad}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } else if (['gifv', 'video'].indexOf(attachment.get('type')) !== -1) {
 | 
				
			||||||
 | 
					      const autoPlay = !isIOS() && autoPlayGif;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (media.get('type') === 'gifv') {
 | 
					      thumbnail = (
 | 
				
			||||||
      label = <span className='media-gallery__gifv__label'>GIF</span>;
 | 
					        <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
 | 
				
			||||||
    }
 | 
					          <video
 | 
				
			||||||
 | 
					            className='media-gallery__item-gifv-thumbnail'
 | 
				
			||||||
 | 
					            aria-label={attachment.get('description')}
 | 
				
			||||||
 | 
					            title={attachment.get('description')}
 | 
				
			||||||
 | 
					            role='application'
 | 
				
			||||||
 | 
					            src={attachment.get('url')}
 | 
				
			||||||
 | 
					            onMouseEnter={this.handleMouseEnter}
 | 
				
			||||||
 | 
					            onMouseLeave={this.handleMouseLeave}
 | 
				
			||||||
 | 
					            autoPlay={autoPlay}
 | 
				
			||||||
 | 
					            loop
 | 
				
			||||||
 | 
					            muted
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (visible) {
 | 
					          <span className='media-gallery__gifv__label'>GIF</span>
 | 
				
			||||||
      style.backgroundImage    = `url(${media.get('preview_url')})`;
 | 
					        </div>
 | 
				
			||||||
      style.backgroundPosition = `${x}% ${y}%`;
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      icon = (
 | 
					 | 
				
			||||||
        <span className='account-gallery__item__icons'>
 | 
					 | 
				
			||||||
          <Icon id='eye-slash' />
 | 
					 | 
				
			||||||
        </span>
 | 
					 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='account-gallery__item'>
 | 
					      <div className='account-gallery__item' style={{ width, height }}>
 | 
				
			||||||
        <Permalink to={`/statuses/${status.get('id')}`} href={status.get('url')} style={style} onInterceptClick={this.handleClick}>
 | 
					        <a className='media-gallery__item-thumbnail' href={status.get('url')} target='_blank' style={{ cursor: 'pointer' }} onClick={this.handleClick}>
 | 
				
			||||||
          {icon}
 | 
					          <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': visible && loaded })} />
 | 
				
			||||||
          {label}
 | 
					          {visible && thumbnail}
 | 
				
			||||||
        </Permalink>
 | 
					        </a>
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,22 +2,23 @@ import React from 'react';
 | 
				
			||||||
import { connect } from 'react-redux';
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import { fetchAccount } from '../../actions/accounts';
 | 
					import { fetchAccount } from 'mastodon/actions/accounts';
 | 
				
			||||||
import { expandAccountMediaTimeline } from '../../actions/timelines';
 | 
					import { expandAccountMediaTimeline } from '../../actions/timelines';
 | 
				
			||||||
import LoadingIndicator from '../../components/loading_indicator';
 | 
					import LoadingIndicator from 'mastodon/components/loading_indicator';
 | 
				
			||||||
import Column from '../ui/components/column';
 | 
					import Column from '../ui/components/column';
 | 
				
			||||||
import ColumnBackButton from '../../components/column_back_button';
 | 
					import ColumnBackButton from 'mastodon/components/column_back_button';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
import { getAccountGallery } from '../../selectors';
 | 
					import { getAccountGallery } from 'mastodon/selectors';
 | 
				
			||||||
import MediaItem from './components/media_item';
 | 
					import MediaItem from './components/media_item';
 | 
				
			||||||
import HeaderContainer from '../account_timeline/containers/header_container';
 | 
					import HeaderContainer from '../account_timeline/containers/header_container';
 | 
				
			||||||
import { ScrollContainer } from 'react-router-scroll-4';
 | 
					import { ScrollContainer } from 'react-router-scroll-4';
 | 
				
			||||||
import LoadMore from '../../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';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = (state, props) => ({
 | 
					const mapStateToProps = (state, props) => ({
 | 
				
			||||||
  isAccount: !!state.getIn(['accounts', props.params.accountId]),
 | 
					  isAccount: !!state.getIn(['accounts', props.params.accountId]),
 | 
				
			||||||
  medias: 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']),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -51,12 +52,16 @@ class AccountGallery extends ImmutablePureComponent {
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    params: PropTypes.object.isRequired,
 | 
					    params: PropTypes.object.isRequired,
 | 
				
			||||||
    dispatch: PropTypes.func.isRequired,
 | 
					    dispatch: PropTypes.func.isRequired,
 | 
				
			||||||
    medias: ImmutablePropTypes.list.isRequired,
 | 
					    attachments: ImmutablePropTypes.list.isRequired,
 | 
				
			||||||
    isLoading: PropTypes.bool,
 | 
					    isLoading: PropTypes.bool,
 | 
				
			||||||
    hasMore: PropTypes.bool,
 | 
					    hasMore: PropTypes.bool,
 | 
				
			||||||
    isAccount: PropTypes.bool,
 | 
					    isAccount: PropTypes.bool,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  state = {
 | 
				
			||||||
 | 
					    width: 323,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentDidMount () {
 | 
					  componentDidMount () {
 | 
				
			||||||
    this.props.dispatch(fetchAccount(this.props.params.accountId));
 | 
					    this.props.dispatch(fetchAccount(this.props.params.accountId));
 | 
				
			||||||
    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
 | 
					    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId));
 | 
				
			||||||
| 
						 | 
					@ -71,11 +76,11 @@ class AccountGallery extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleScrollToBottom = () => {
 | 
					  handleScrollToBottom = () => {
 | 
				
			||||||
    if (this.props.hasMore) {
 | 
					    if (this.props.hasMore) {
 | 
				
			||||||
      this.handleLoadMore(this.props.medias.size > 0 ? this.props.medias.last().getIn(['status', 'id']) : undefined);
 | 
					      this.handleLoadMore(this.props.attachments.size > 0 ? this.props.attachments.last().getIn(['status', 'id']) : undefined);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleScroll = (e) => {
 | 
					  handleScroll = e => {
 | 
				
			||||||
    const { scrollTop, scrollHeight, clientHeight } = e.target;
 | 
					    const { scrollTop, scrollHeight, clientHeight } = e.target;
 | 
				
			||||||
    const offset = scrollHeight - scrollTop - clientHeight;
 | 
					    const offset = scrollHeight - scrollTop - clientHeight;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -88,13 +93,31 @@ class AccountGallery extends ImmutablePureComponent {
 | 
				
			||||||
    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
 | 
					    this.props.dispatch(expandAccountMediaTimeline(this.props.params.accountId, { maxId }));
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleLoadOlder = (e) => {
 | 
					  handleLoadOlder = e => {
 | 
				
			||||||
    e.preventDefault();
 | 
					    e.preventDefault();
 | 
				
			||||||
    this.handleScrollToBottom();
 | 
					    this.handleScrollToBottom();
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleOpenMedia = attachment => {
 | 
				
			||||||
 | 
					    if (attachment.get('type') === 'video') {
 | 
				
			||||||
 | 
					      this.props.dispatch(openModal('VIDEO', { media: attachment, status: attachment.get('status') }));
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      const media = attachment.getIn(['status', 'media_attachments']);
 | 
				
			||||||
 | 
					      const index = media.findIndex(x => x.get('id') === attachment.get('id'));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.props.dispatch(openModal('MEDIA', { media, index, status: attachment.get('status') }));
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleRef = c => {
 | 
				
			||||||
 | 
					    if (c) {
 | 
				
			||||||
 | 
					      this.setState({ width: c.offsetWidth });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { medias, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
 | 
					    const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount } = this.props;
 | 
				
			||||||
 | 
					    const { width } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!isAccount) {
 | 
					    if (!isAccount) {
 | 
				
			||||||
      return (
 | 
					      return (
 | 
				
			||||||
| 
						 | 
					@ -104,9 +127,7 @@ class AccountGallery extends ImmutablePureComponent {
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let loadOlder = null;
 | 
					    if (!attachments && isLoading) {
 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!medias && isLoading) {
 | 
					 | 
				
			||||||
      return (
 | 
					      return (
 | 
				
			||||||
        <Column>
 | 
					        <Column>
 | 
				
			||||||
          <LoadingIndicator />
 | 
					          <LoadingIndicator />
 | 
				
			||||||
| 
						 | 
					@ -114,7 +135,9 @@ class AccountGallery extends ImmutablePureComponent {
 | 
				
			||||||
      );
 | 
					      );
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (hasMore && !(isLoading && medias.size === 0)) {
 | 
					    let loadOlder = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (hasMore && !(isLoading && attachments.size === 0)) {
 | 
				
			||||||
      loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
 | 
					      loadOlder = <LoadMore visible={!isLoading} onClick={this.handleLoadOlder} />;
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -126,23 +149,17 @@ 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} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div role='feed' className='account-gallery__container'>
 | 
					            <div role='feed' className='account-gallery__container' ref={this.handleRef}>
 | 
				
			||||||
              {medias.map((media, index) => media === null ? (
 | 
					              {attachments.map((attachment, index) => attachment === null ? (
 | 
				
			||||||
                <LoadMoreMedia
 | 
					                <LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
 | 
				
			||||||
                  key={'more:' + medias.getIn(index + 1, 'id')}
 | 
					 | 
				
			||||||
                  maxId={index > 0 ? medias.getIn(index - 1, 'id') : null}
 | 
					 | 
				
			||||||
                  onLoadMore={this.handleLoadMore}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
              ) : (
 | 
					              ) : (
 | 
				
			||||||
                <MediaItem
 | 
					                <MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
 | 
				
			||||||
                  key={media.get('id')}
 | 
					 | 
				
			||||||
                  media={media}
 | 
					 | 
				
			||||||
                />
 | 
					 | 
				
			||||||
              ))}
 | 
					              ))}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              {loadOlder}
 | 
					              {loadOlder}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            {isLoading && medias.size === 0 && (
 | 
					            {isLoading && attachments.size === 0 && (
 | 
				
			||||||
              <div className='scrollable__append'>
 | 
					              <div className='scrollable__append'>
 | 
				
			||||||
                <LoadingIndicator />
 | 
					                <LoadingIndicator />
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -10,7 +10,6 @@ import UploadButtonContainer from '../containers/upload_button_container';
 | 
				
			||||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
					import { defineMessages, injectIntl } from 'react-intl';
 | 
				
			||||||
import SpoilerButtonContainer from '../containers/spoiler_button_container';
 | 
					import SpoilerButtonContainer from '../containers/spoiler_button_container';
 | 
				
			||||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
 | 
					import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
 | 
				
			||||||
import SensitiveButtonContainer from '../containers/sensitive_button_container';
 | 
					 | 
				
			||||||
import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 | 
					import EmojiPickerDropdown from '../containers/emoji_picker_dropdown_container';
 | 
				
			||||||
import PollFormContainer from '../containers/poll_form_container';
 | 
					import PollFormContainer from '../containers/poll_form_container';
 | 
				
			||||||
import UploadFormContainer from '../containers/upload_form_container';
 | 
					import UploadFormContainer from '../containers/upload_form_container';
 | 
				
			||||||
| 
						 | 
					@ -215,7 +214,6 @@ class ComposeForm extends ImmutablePureComponent {
 | 
				
			||||||
            <UploadButtonContainer />
 | 
					            <UploadButtonContainer />
 | 
				
			||||||
            <PollButtonContainer />
 | 
					            <PollButtonContainer />
 | 
				
			||||||
            <PrivacyDropdownContainer />
 | 
					            <PrivacyDropdownContainer />
 | 
				
			||||||
            <SensitiveButtonContainer />
 | 
					 | 
				
			||||||
            <SpoilerButtonContainer />
 | 
					            <SpoilerButtonContainer />
 | 
				
			||||||
          </div>
 | 
					          </div>
 | 
				
			||||||
          <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
 | 
					          <div className='character-counter__wrapper'><CharacterCounter max={maxChars} text={text} /></div>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import UploadProgressContainer from '../containers/upload_progress_container';
 | 
					import UploadProgressContainer from '../containers/upload_progress_container';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
import UploadContainer from '../containers/upload_container';
 | 
					import UploadContainer from '../containers/upload_container';
 | 
				
			||||||
 | 
					import SensitiveButtonContainer from '../containers/sensitive_button_container';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class UploadForm extends ImmutablePureComponent {
 | 
					export default class UploadForm extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -22,6 +23,8 @@ export default class UploadForm extends ImmutablePureComponent {
 | 
				
			||||||
            <UploadContainer id={id} key={id} />
 | 
					            <UploadContainer id={id} key={id} />
 | 
				
			||||||
          ))}
 | 
					          ))}
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {!mediaIds.isEmpty() && <SensitiveButtonContainer />}
 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,11 +2,9 @@ import React from 'react';
 | 
				
			||||||
import { connect } from 'react-redux';
 | 
					import { connect } from 'react-redux';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import classNames from 'classnames';
 | 
					import classNames from 'classnames';
 | 
				
			||||||
import IconButton from '../../../components/icon_button';
 | 
					import { changeComposeSensitivity } from 'mastodon/actions/compose';
 | 
				
			||||||
import { changeComposeSensitivity } from '../../../actions/compose';
 | 
					import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
 | 
				
			||||||
import Motion from '../../ui/util/optional_motion';
 | 
					import Icon from 'mastodon/components/icon';
 | 
				
			||||||
import spring from 'react-motion/lib/spring';
 | 
					 | 
				
			||||||
import { injectIntl, defineMessages } from 'react-intl';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
 | 
					  marked: { id: 'compose_form.sensitive.marked', defaultMessage: 'Media is marked as sensitive' },
 | 
				
			||||||
| 
						 | 
					@ -14,7 +12,6 @@ const messages = defineMessages({
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const mapStateToProps = state => ({
 | 
					const mapStateToProps = state => ({
 | 
				
			||||||
  visible: state.getIn(['compose', 'media_attachments']).size > 0,
 | 
					 | 
				
			||||||
  active: state.getIn(['compose', 'sensitive']),
 | 
					  active: state.getIn(['compose', 'sensitive']),
 | 
				
			||||||
  disabled: state.getIn(['compose', 'spoiler']),
 | 
					  disabled: state.getIn(['compose', 'spoiler']),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
| 
						 | 
					@ -30,7 +27,6 @@ const mapDispatchToProps = dispatch => ({
 | 
				
			||||||
class SensitiveButton extends React.PureComponent {
 | 
					class SensitiveButton extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    visible: PropTypes.bool,
 | 
					 | 
				
			||||||
    active: PropTypes.bool,
 | 
					    active: PropTypes.bool,
 | 
				
			||||||
    disabled: PropTypes.bool,
 | 
					    disabled: PropTypes.bool,
 | 
				
			||||||
    onClick: PropTypes.func.isRequired,
 | 
					    onClick: PropTypes.func.isRequired,
 | 
				
			||||||
| 
						 | 
					@ -38,33 +34,15 @@ class SensitiveButton extends React.PureComponent {
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { visible, active, disabled, onClick, intl } = this.props;
 | 
					    const { active, disabled, onClick, intl } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <Motion defaultStyle={{ scale: 0.87 }} style={{ scale: spring(visible ? 1 : 0.87, { stiffness: 200, damping: 3 }) }}>
 | 
					      <div className='compose-form__sensitive-button'>
 | 
				
			||||||
        {({ scale }) => {
 | 
					        <button className={classNames('icon-button', { active })} onClick={onClick} disabled={disabled} title={intl.formatMessage(active ? messages.marked : messages.unmarked)}>
 | 
				
			||||||
          const icon = active ? 'eye-slash' : 'eye';
 | 
					          <Icon id='eye-slash' /> <FormattedMessage id='compose_form.sensitive.hide' defaultMessage='Mark media as sensitive' />
 | 
				
			||||||
          const className = classNames('compose-form__sensitive-button', {
 | 
					        </button>
 | 
				
			||||||
            'compose-form__sensitive-button--visible': visible,
 | 
					 | 
				
			||||||
          });
 | 
					 | 
				
			||||||
          return (
 | 
					 | 
				
			||||||
            <div className={className} style={{ transform: `scale(${scale})` }}>
 | 
					 | 
				
			||||||
              <IconButton
 | 
					 | 
				
			||||||
                className='compose-form__sensitive-button__icon'
 | 
					 | 
				
			||||||
                title={intl.formatMessage(active ? messages.marked : messages.unmarked)}
 | 
					 | 
				
			||||||
                icon={icon}
 | 
					 | 
				
			||||||
                onClick={onClick}
 | 
					 | 
				
			||||||
                size={18}
 | 
					 | 
				
			||||||
                active={active}
 | 
					 | 
				
			||||||
                disabled={disabled}
 | 
					 | 
				
			||||||
                style={{ lineHeight: null, height: null }}
 | 
					 | 
				
			||||||
                inverted
 | 
					 | 
				
			||||||
              />
 | 
					 | 
				
			||||||
      </div>
 | 
					      </div>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
        }}
 | 
					 | 
				
			||||||
      </Motion>
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,18 +20,24 @@ export default class ConversationsList extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMoveUp = id => {
 | 
					  handleMoveUp = id => {
 | 
				
			||||||
    const elementIndex = this.getCurrentIndex(id) - 1;
 | 
					    const elementIndex = this.getCurrentIndex(id) - 1;
 | 
				
			||||||
    this._selectChild(elementIndex);
 | 
					    this._selectChild(elementIndex, true);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMoveDown = id => {
 | 
					  handleMoveDown = id => {
 | 
				
			||||||
    const elementIndex = this.getCurrentIndex(id) + 1;
 | 
					    const elementIndex = this.getCurrentIndex(id) + 1;
 | 
				
			||||||
    this._selectChild(elementIndex);
 | 
					    this._selectChild(elementIndex, false);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  _selectChild (index) {
 | 
					  _selectChild (index, align_top) {
 | 
				
			||||||
    const element = this.node.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 | 
					    const container = this.node.node;
 | 
				
			||||||
 | 
					    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (element) {
 | 
					    if (element) {
 | 
				
			||||||
 | 
					      if (align_top && container.scrollTop > element.offsetTop) {
 | 
				
			||||||
 | 
					        element.scrollIntoView(true);
 | 
				
			||||||
 | 
					      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
 | 
				
			||||||
 | 
					        element.scrollIntoView(false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      element.focus();
 | 
					      element.focus();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -113,18 +113,24 @@ class Notifications extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMoveUp = id => {
 | 
					  handleMoveUp = id => {
 | 
				
			||||||
    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
 | 
					    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) - 1;
 | 
				
			||||||
    this._selectChild(elementIndex);
 | 
					    this._selectChild(elementIndex, true);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMoveDown = id => {
 | 
					  handleMoveDown = id => {
 | 
				
			||||||
    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
 | 
					    const elementIndex = this.props.notifications.findIndex(item => item !== null && item.get('id') === id) + 1;
 | 
				
			||||||
    this._selectChild(elementIndex);
 | 
					    this._selectChild(elementIndex, false);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  _selectChild (index) {
 | 
					  _selectChild (index, align_top) {
 | 
				
			||||||
    const element = this.column.node.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 | 
					    const container = this.column.node;
 | 
				
			||||||
 | 
					    const element = container.querySelector(`article:nth-of-type(${index + 1}) .focusable`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (element) {
 | 
					    if (element) {
 | 
				
			||||||
 | 
					      if (align_top && container.scrollTop > element.offsetTop) {
 | 
				
			||||||
 | 
					        element.scrollIntoView(true);
 | 
				
			||||||
 | 
					      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
 | 
				
			||||||
 | 
					        element.scrollIntoView(false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      element.focus();
 | 
					      element.focus();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -35,6 +35,7 @@ export default class StatusCheckBox extends React.PureComponent {
 | 
				
			||||||
            {Component => (
 | 
					            {Component => (
 | 
				
			||||||
              <Component
 | 
					              <Component
 | 
				
			||||||
                preview={video.get('preview_url')}
 | 
					                preview={video.get('preview_url')}
 | 
				
			||||||
 | 
					                blurhash={video.get('blurhash')}
 | 
				
			||||||
                src={video.get('url')}
 | 
					                src={video.get('url')}
 | 
				
			||||||
                alt={video.get('description')}
 | 
					                alt={video.get('description')}
 | 
				
			||||||
                width={239}
 | 
					                width={239}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,7 +5,6 @@ import Avatar from '../../../components/avatar';
 | 
				
			||||||
import DisplayName from '../../../components/display_name';
 | 
					import DisplayName from '../../../components/display_name';
 | 
				
			||||||
import StatusContent from '../../../components/status_content';
 | 
					import StatusContent from '../../../components/status_content';
 | 
				
			||||||
import MediaGallery from '../../../components/media_gallery';
 | 
					import MediaGallery from '../../../components/media_gallery';
 | 
				
			||||||
import AttachmentList from '../../../components/attachment_list';
 | 
					 | 
				
			||||||
import { Link } from 'react-router-dom';
 | 
					import { Link } from 'react-router-dom';
 | 
				
			||||||
import { FormattedDate, FormattedNumber } from 'react-intl';
 | 
					import { FormattedDate, FormattedNumber } from 'react-intl';
 | 
				
			||||||
import Card from './card';
 | 
					import Card from './card';
 | 
				
			||||||
| 
						 | 
					@ -109,14 +108,13 @@ export default class DetailedStatus extends ImmutablePureComponent {
 | 
				
			||||||
    if (status.get('poll')) {
 | 
					    if (status.get('poll')) {
 | 
				
			||||||
      media = <PollContainer pollId={status.get('poll')} />;
 | 
					      media = <PollContainer pollId={status.get('poll')} />;
 | 
				
			||||||
    } else if (status.get('media_attachments').size > 0) {
 | 
					    } else if (status.get('media_attachments').size > 0) {
 | 
				
			||||||
      if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
 | 
					      if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
 | 
				
			||||||
        media = <AttachmentList media={status.get('media_attachments')} />;
 | 
					 | 
				
			||||||
      } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
 | 
					 | 
				
			||||||
        const video = status.getIn(['media_attachments', 0]);
 | 
					        const video = status.getIn(['media_attachments', 0]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        media = (
 | 
					        media = (
 | 
				
			||||||
          <Video
 | 
					          <Video
 | 
				
			||||||
            preview={video.get('preview_url')}
 | 
					            preview={video.get('preview_url')}
 | 
				
			||||||
 | 
					            blurhash={video.get('blurhash')}
 | 
				
			||||||
            src={video.get('url')}
 | 
					            src={video.get('url')}
 | 
				
			||||||
            alt={video.get('description')}
 | 
					            alt={video.get('description')}
 | 
				
			||||||
            width={300}
 | 
					            width={300}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -316,15 +316,15 @@ class Status extends ImmutablePureComponent {
 | 
				
			||||||
    const { status, ancestorsIds, descendantsIds } = this.props;
 | 
					    const { status, ancestorsIds, descendantsIds } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (id === status.get('id')) {
 | 
					    if (id === status.get('id')) {
 | 
				
			||||||
      this._selectChild(ancestorsIds.size - 1);
 | 
					      this._selectChild(ancestorsIds.size - 1, true);
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      let index = ancestorsIds.indexOf(id);
 | 
					      let index = ancestorsIds.indexOf(id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (index === -1) {
 | 
					      if (index === -1) {
 | 
				
			||||||
        index = descendantsIds.indexOf(id);
 | 
					        index = descendantsIds.indexOf(id);
 | 
				
			||||||
        this._selectChild(ancestorsIds.size + index);
 | 
					        this._selectChild(ancestorsIds.size + index, true);
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        this._selectChild(index - 1);
 | 
					        this._selectChild(index - 1, true);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -333,23 +333,29 @@ class Status extends ImmutablePureComponent {
 | 
				
			||||||
    const { status, ancestorsIds, descendantsIds } = this.props;
 | 
					    const { status, ancestorsIds, descendantsIds } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (id === status.get('id')) {
 | 
					    if (id === status.get('id')) {
 | 
				
			||||||
      this._selectChild(ancestorsIds.size + 1);
 | 
					      this._selectChild(ancestorsIds.size + 1, false);
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
      let index = ancestorsIds.indexOf(id);
 | 
					      let index = ancestorsIds.indexOf(id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (index === -1) {
 | 
					      if (index === -1) {
 | 
				
			||||||
        index = descendantsIds.indexOf(id);
 | 
					        index = descendantsIds.indexOf(id);
 | 
				
			||||||
        this._selectChild(ancestorsIds.size + index + 2);
 | 
					        this._selectChild(ancestorsIds.size + index + 2, false);
 | 
				
			||||||
      } else {
 | 
					      } else {
 | 
				
			||||||
        this._selectChild(index + 1);
 | 
					        this._selectChild(index + 1, false);
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  _selectChild (index) {
 | 
					  _selectChild (index, align_top) {
 | 
				
			||||||
    const element = this.node.querySelectorAll('.focusable')[index];
 | 
					    const container = this.node;
 | 
				
			||||||
 | 
					    const element = container.querySelectorAll('.focusable')[index];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (element) {
 | 
					    if (element) {
 | 
				
			||||||
 | 
					      if (align_top && container.scrollTop > element.offsetTop) {
 | 
				
			||||||
 | 
					        element.scrollIntoView(true);
 | 
				
			||||||
 | 
					      } else if (!align_top && container.scrollTop + container.clientHeight < element.offsetTop + element.offsetHeight) {
 | 
				
			||||||
 | 
					        element.scrollIntoView(false);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
      element.focus();
 | 
					      element.focus();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,11 +2,11 @@ import React from 'react';
 | 
				
			||||||
import ReactSwipeableViews from 'react-swipeable-views';
 | 
					import ReactSwipeableViews from 'react-swipeable-views';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import Video from '../../video';
 | 
					import Video from 'mastodon/features/video';
 | 
				
			||||||
import ExtendedVideoPlayer from '../../../components/extended_video_player';
 | 
					import ExtendedVideoPlayer from 'mastodon/components/extended_video_player';
 | 
				
			||||||
import classNames from 'classnames';
 | 
					import classNames from 'classnames';
 | 
				
			||||||
import { defineMessages, injectIntl } from 'react-intl';
 | 
					import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
 | 
				
			||||||
import IconButton from '../../../components/icon_button';
 | 
					import IconButton from 'mastodon/components/icon_button';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
import ImageLoader from './image_loader';
 | 
					import ImageLoader from './image_loader';
 | 
				
			||||||
import Icon from 'mastodon/components/icon';
 | 
					import Icon from 'mastodon/components/icon';
 | 
				
			||||||
| 
						 | 
					@ -24,6 +24,7 @@ class MediaModal extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    media: ImmutablePropTypes.list.isRequired,
 | 
					    media: ImmutablePropTypes.list.isRequired,
 | 
				
			||||||
 | 
					    status: ImmutablePropTypes.map,
 | 
				
			||||||
    index: PropTypes.number.isRequired,
 | 
					    index: PropTypes.number.isRequired,
 | 
				
			||||||
    onClose: PropTypes.func.isRequired,
 | 
					    onClose: PropTypes.func.isRequired,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
| 
						 | 
					@ -72,9 +73,12 @@ class MediaModal extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentDidMount () {
 | 
					  componentDidMount () {
 | 
				
			||||||
    window.addEventListener('keydown', this.handleKeyDown, false);
 | 
					    window.addEventListener('keydown', this.handleKeyDown, false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.context.router) {
 | 
					    if (this.context.router) {
 | 
				
			||||||
      const history = this.context.router.history;
 | 
					      const history = this.context.router.history;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      history.push(history.location.pathname, previewState);
 | 
					      history.push(history.location.pathname, previewState);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.unlistenHistory = history.listen(() => {
 | 
					      this.unlistenHistory = history.listen(() => {
 | 
				
			||||||
        this.props.onClose();
 | 
					        this.props.onClose();
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
| 
						 | 
					@ -83,6 +87,7 @@ class MediaModal extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillUnmount () {
 | 
					  componentWillUnmount () {
 | 
				
			||||||
    window.removeEventListener('keydown', this.handleKeyDown);
 | 
					    window.removeEventListener('keydown', this.handleKeyDown);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.context.router) {
 | 
					    if (this.context.router) {
 | 
				
			||||||
      this.unlistenHistory();
 | 
					      this.unlistenHistory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -102,8 +107,15 @@ class MediaModal extends ImmutablePureComponent {
 | 
				
			||||||
    }));
 | 
					    }));
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleStatusClick = e => {
 | 
				
			||||||
 | 
					    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
 | 
				
			||||||
 | 
					      e.preventDefault();
 | 
				
			||||||
 | 
					      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { media, intl, onClose } = this.props;
 | 
					    const { media, status, intl, onClose } = this.props;
 | 
				
			||||||
    const { navigationHidden } = this.state;
 | 
					    const { navigationHidden } = this.state;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const index = this.getIndex();
 | 
					    const index = this.getIndex();
 | 
				
			||||||
| 
						 | 
					@ -144,6 +156,7 @@ class MediaModal extends ImmutablePureComponent {
 | 
				
			||||||
        return (
 | 
					        return (
 | 
				
			||||||
          <Video
 | 
					          <Video
 | 
				
			||||||
            preview={image.get('preview_url')}
 | 
					            preview={image.get('preview_url')}
 | 
				
			||||||
 | 
					            blurhash={image.get('blurhash')}
 | 
				
			||||||
            src={image.get('url')}
 | 
					            src={image.get('url')}
 | 
				
			||||||
            width={image.get('width')}
 | 
					            width={image.get('width')}
 | 
				
			||||||
            height={image.get('height')}
 | 
					            height={image.get('height')}
 | 
				
			||||||
| 
						 | 
					@ -206,10 +219,19 @@ class MediaModal extends ImmutablePureComponent {
 | 
				
			||||||
            {content}
 | 
					            {content}
 | 
				
			||||||
          </ReactSwipeableViews>
 | 
					          </ReactSwipeableViews>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className={navigationClassName}>
 | 
					        <div className={navigationClassName}>
 | 
				
			||||||
          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
 | 
					          <IconButton className='media-modal__close' title={intl.formatMessage(messages.close)} icon='times' onClick={onClose} size={40} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          {leftNav}
 | 
					          {leftNav}
 | 
				
			||||||
          {rightNav}
 | 
					          {rightNav}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					          {status && (
 | 
				
			||||||
 | 
					            <div className={classNames('media-modal__meta', { 'media-modal__meta--shifted': media.size > 1 })}>
 | 
				
			||||||
 | 
					              <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>
 | 
				
			||||||
 | 
					            </div>
 | 
				
			||||||
 | 
					          )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
          <ul className='media-modal__pagination'>
 | 
					          <ul className='media-modal__pagination'>
 | 
				
			||||||
            {pagination}
 | 
					            {pagination}
 | 
				
			||||||
          </ul>
 | 
					          </ul>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,28 +1,69 @@
 | 
				
			||||||
import React from 'react';
 | 
					import React from 'react';
 | 
				
			||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
					import ImmutablePropTypes from 'react-immutable-proptypes';
 | 
				
			||||||
import PropTypes from 'prop-types';
 | 
					import PropTypes from 'prop-types';
 | 
				
			||||||
import Video from '../../video';
 | 
					import Video from 'mastodon/features/video';
 | 
				
			||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
					import ImmutablePureComponent from 'react-immutable-pure-component';
 | 
				
			||||||
 | 
					import { FormattedMessage } from 'react-intl';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const previewState = 'previewVideoModal';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class VideoModal extends ImmutablePureComponent {
 | 
					export default class VideoModal extends ImmutablePureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  static propTypes = {
 | 
					  static propTypes = {
 | 
				
			||||||
    media: ImmutablePropTypes.map.isRequired,
 | 
					    media: ImmutablePropTypes.map.isRequired,
 | 
				
			||||||
 | 
					    status: ImmutablePropTypes.map,
 | 
				
			||||||
    time: PropTypes.number,
 | 
					    time: PropTypes.number,
 | 
				
			||||||
    onClose: PropTypes.func.isRequired,
 | 
					    onClose: PropTypes.func.isRequired,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static contextTypes = {
 | 
				
			||||||
 | 
					    router: PropTypes.object,
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentDidMount () {
 | 
				
			||||||
 | 
					    if (this.context.router) {
 | 
				
			||||||
 | 
					      const history = this.context.router.history;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      history.push(history.location.pathname, previewState);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.unlistenHistory = history.listen(() => {
 | 
				
			||||||
 | 
					        this.props.onClose();
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentWillUnmount () {
 | 
				
			||||||
 | 
					    if (this.context.router) {
 | 
				
			||||||
 | 
					      this.unlistenHistory();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (this.context.router.history.location.state === previewState) {
 | 
				
			||||||
 | 
					        this.context.router.history.goBack();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  handleStatusClick = e => {
 | 
				
			||||||
 | 
					    if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
 | 
				
			||||||
 | 
					      e.preventDefault();
 | 
				
			||||||
 | 
					      this.context.router.history.push(`/statuses/${this.props.status.get('id')}`);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { media, time, onClose } = this.props;
 | 
					    const { media, status, time, onClose } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const link = status && <a href={status.get('url')} onClick={this.handleStatusClick}><FormattedMessage id='lightbox.view_context' defaultMessage='View context' /></a>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					    return (
 | 
				
			||||||
      <div className='modal-root__modal video-modal'>
 | 
					      <div className='modal-root__modal video-modal'>
 | 
				
			||||||
        <div>
 | 
					        <div>
 | 
				
			||||||
          <Video
 | 
					          <Video
 | 
				
			||||||
            preview={media.get('preview_url')}
 | 
					            preview={media.get('preview_url')}
 | 
				
			||||||
 | 
					            blurhash={media.get('blurhash')}
 | 
				
			||||||
            src={media.get('url')}
 | 
					            src={media.get('url')}
 | 
				
			||||||
            startTime={time}
 | 
					            startTime={time}
 | 
				
			||||||
            onCloseVideo={onClose}
 | 
					            onCloseVideo={onClose}
 | 
				
			||||||
 | 
					            link={link}
 | 
				
			||||||
            detailed
 | 
					            detailed
 | 
				
			||||||
            alt={media.get('description')}
 | 
					            alt={media.get('description')}
 | 
				
			||||||
          />
 | 
					          />
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -367,11 +367,16 @@ class UI extends React.PureComponent {
 | 
				
			||||||
  handleHotkeyFocusColumn = e => {
 | 
					  handleHotkeyFocusColumn = e => {
 | 
				
			||||||
    const index  = (e.key * 1) + 1; // First child is drawer, skip that
 | 
					    const index  = (e.key * 1) + 1; // First child is drawer, skip that
 | 
				
			||||||
    const column = this.node.querySelector(`.column:nth-child(${index})`);
 | 
					    const column = this.node.querySelector(`.column:nth-child(${index})`);
 | 
				
			||||||
 | 
					    if (!column) return;
 | 
				
			||||||
 | 
					    const container = column.querySelector('.scrollable');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (column) {
 | 
					    if (container) {
 | 
				
			||||||
      const status = column.querySelector('.focusable');
 | 
					      const status = container.querySelector('.focusable');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if (status) {
 | 
					      if (status) {
 | 
				
			||||||
 | 
					        if (container.scrollTop > status.offsetTop) {
 | 
				
			||||||
 | 
					          status.scrollIntoView(true);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
        status.focus();
 | 
					        status.focus();
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,7 @@ import classNames from 'classnames';
 | 
				
			||||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 | 
					import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 | 
				
			||||||
import { displayMedia } from '../../initial_state';
 | 
					import { displayMedia } from '../../initial_state';
 | 
				
			||||||
import Icon from 'mastodon/components/icon';
 | 
					import Icon from 'mastodon/components/icon';
 | 
				
			||||||
 | 
					import { decode } from 'blurhash';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const messages = defineMessages({
 | 
					const messages = defineMessages({
 | 
				
			||||||
  play: { id: 'video.play', defaultMessage: 'Play' },
 | 
					  play: { id: 'video.play', defaultMessage: 'Play' },
 | 
				
			||||||
| 
						 | 
					@ -102,6 +103,8 @@ class Video extends React.PureComponent {
 | 
				
			||||||
    inline: PropTypes.bool,
 | 
					    inline: PropTypes.bool,
 | 
				
			||||||
    cacheWidth: PropTypes.func,
 | 
					    cacheWidth: PropTypes.func,
 | 
				
			||||||
    intl: PropTypes.object.isRequired,
 | 
					    intl: PropTypes.object.isRequired,
 | 
				
			||||||
 | 
					    blurhash: PropTypes.string,
 | 
				
			||||||
 | 
					    link: PropTypes.node,
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  state = {
 | 
					  state = {
 | 
				
			||||||
| 
						 | 
					@ -139,6 +142,7 @@ class Video extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  setVideoRef = c => {
 | 
					  setVideoRef = c => {
 | 
				
			||||||
    this.video = c;
 | 
					    this.video = c;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (this.video) {
 | 
					    if (this.video) {
 | 
				
			||||||
      this.setState({ volume: this.video.volume, muted: this.video.muted });
 | 
					      this.setState({ volume: this.video.volume, muted: this.video.muted });
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
| 
						 | 
					@ -152,6 +156,10 @@ class Video extends React.PureComponent {
 | 
				
			||||||
    this.volume = c;
 | 
					    this.volume = c;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  setCanvasRef = c => {
 | 
				
			||||||
 | 
					    this.canvas = c;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleClickRoot = e => e.stopPropagation();
 | 
					  handleClickRoot = e => e.stopPropagation();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handlePlay = () => {
 | 
					  handlePlay = () => {
 | 
				
			||||||
| 
						 | 
					@ -170,7 +178,6 @@ class Video extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleVolumeMouseDown = e => {
 | 
					  handleVolumeMouseDown = e => {
 | 
				
			||||||
 | 
					 | 
				
			||||||
    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
 | 
					    document.addEventListener('mousemove', this.handleMouseVolSlide, true);
 | 
				
			||||||
    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
 | 
					    document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
 | 
				
			||||||
    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
 | 
					    document.addEventListener('touchmove', this.handleMouseVolSlide, true);
 | 
				
			||||||
| 
						 | 
					@ -190,7 +197,6 @@ class Video extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleMouseVolSlide = throttle(e => {
 | 
					  handleMouseVolSlide = throttle(e => {
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const rect = this.volume.getBoundingClientRect();
 | 
					    const rect = this.volume.getBoundingClientRect();
 | 
				
			||||||
    const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
 | 
					    const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -261,6 +267,10 @@ class Video extends React.PureComponent {
 | 
				
			||||||
    document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
 | 
					    document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
 | 
				
			||||||
    document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
 | 
					    document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
 | 
				
			||||||
    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
					    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (this.props.blurhash) {
 | 
				
			||||||
 | 
					      this._decode();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  componentWillUnmount () {
 | 
					  componentWillUnmount () {
 | 
				
			||||||
| 
						 | 
					@ -270,6 +280,24 @@ class Video extends React.PureComponent {
 | 
				
			||||||
    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
					    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  componentDidUpdate (prevProps) {
 | 
				
			||||||
 | 
					    if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
 | 
				
			||||||
 | 
					      this._decode();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  _decode () {
 | 
				
			||||||
 | 
					    const hash   = this.props.blurhash;
 | 
				
			||||||
 | 
					    const pixels = decode(hash, 32, 32);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (pixels) {
 | 
				
			||||||
 | 
					      const ctx       = this.canvas.getContext('2d');
 | 
				
			||||||
 | 
					      const imageData = new ImageData(pixels, 32, 32);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      ctx.putImageData(imageData, 0, 0);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleFullscreenChange = () => {
 | 
					  handleFullscreenChange = () => {
 | 
				
			||||||
    this.setState({ fullscreen: isFullscreen() });
 | 
					    this.setState({ fullscreen: isFullscreen() });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					@ -314,6 +342,7 @@ class Video extends React.PureComponent {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  handleOpenVideo = () => {
 | 
					  handleOpenVideo = () => {
 | 
				
			||||||
    const { src, preview, width, height, alt } = this.props;
 | 
					    const { src, preview, width, height, alt } = this.props;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const media = fromJS({
 | 
					    const media = fromJS({
 | 
				
			||||||
      type: 'video',
 | 
					      type: 'video',
 | 
				
			||||||
      url: src,
 | 
					      url: src,
 | 
				
			||||||
| 
						 | 
					@ -333,7 +362,7 @@ class Video extends React.PureComponent {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  render () {
 | 
					  render () {
 | 
				
			||||||
    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive } = this.props;
 | 
					    const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link } = 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 = (currentTime / duration) * 100;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -351,6 +380,7 @@ class Video extends React.PureComponent {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let preload;
 | 
					    let preload;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (startTime || fullscreen || dragging) {
 | 
					    if (startTime || fullscreen || dragging) {
 | 
				
			||||||
      preload = 'auto';
 | 
					      preload = 'auto';
 | 
				
			||||||
    } else if (detailed) {
 | 
					    } else if (detailed) {
 | 
				
			||||||
| 
						 | 
					@ -360,6 +390,7 @@ class Video extends React.PureComponent {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    let warning;
 | 
					    let warning;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (sensitive) {
 | 
					    if (sensitive) {
 | 
				
			||||||
      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
 | 
					      warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
 | 
				
			||||||
    } else {
 | 
					    } else {
 | 
				
			||||||
| 
						 | 
					@ -377,7 +408,9 @@ class Video extends React.PureComponent {
 | 
				
			||||||
        onClick={this.handleClickRoot}
 | 
					        onClick={this.handleClickRoot}
 | 
				
			||||||
        tabIndex={0}
 | 
					        tabIndex={0}
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
        <video
 | 
					        <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        {revealed && <video
 | 
				
			||||||
          ref={this.setVideoRef}
 | 
					          ref={this.setVideoRef}
 | 
				
			||||||
          src={src}
 | 
					          src={src}
 | 
				
			||||||
          poster={preview}
 | 
					          poster={preview}
 | 
				
			||||||
| 
						 | 
					@ -397,12 +430,13 @@ class Video extends React.PureComponent {
 | 
				
			||||||
          onLoadedData={this.handleLoadedData}
 | 
					          onLoadedData={this.handleLoadedData}
 | 
				
			||||||
          onProgress={this.handleProgress}
 | 
					          onProgress={this.handleProgress}
 | 
				
			||||||
          onVolumeChange={this.handleVolumeChange}
 | 
					          onVolumeChange={this.handleVolumeChange}
 | 
				
			||||||
        />
 | 
					        />}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <button type='button' className={classNames('video-player__spoiler', { active: !revealed })} onClick={this.toggleReveal}>
 | 
					        <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed })}>
 | 
				
			||||||
          <span className='video-player__spoiler__title'>{warning}</span>
 | 
					          <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
 | 
				
			||||||
          <span className='video-player__spoiler__subtitle'><FormattedMessage id='status.sensitive_toggle' defaultMessage='Click to view' /></span>
 | 
					            <span className='spoiler-button__overlay__label'>{warning}</span>
 | 
				
			||||||
          </button>
 | 
					          </button>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        <div className={classNames('video-player__controls', { active: paused || hovered })}>
 | 
					        <div className={classNames('video-player__controls', { active: paused || hovered })}>
 | 
				
			||||||
          <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
 | 
					          <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
 | 
				
			||||||
| 
						 | 
					@ -420,6 +454,7 @@ class Video extends React.PureComponent {
 | 
				
			||||||
            <div className='video-player__buttons left'>
 | 
					            <div className='video-player__buttons left'>
 | 
				
			||||||
              <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
 | 
					              <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
 | 
				
			||||||
              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
 | 
					              <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
 | 
					              <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
 | 
				
			||||||
                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
 | 
					                <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
 | 
				
			||||||
                <span
 | 
					                <span
 | 
				
			||||||
| 
						 | 
					@ -429,17 +464,19 @@ class Video extends React.PureComponent {
 | 
				
			||||||
                />
 | 
					                />
 | 
				
			||||||
              </div>
 | 
					              </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
              {(detailed || fullscreen) &&
 | 
					              {(detailed || fullscreen) && (
 | 
				
			||||||
                <span>
 | 
					                <span>
 | 
				
			||||||
                  <span className='video-player__time-current'>{formatTime(currentTime)}</span>
 | 
					                  <span className='video-player__time-current'>{formatTime(currentTime)}</span>
 | 
				
			||||||
                  <span className='video-player__time-sep'>/</span>
 | 
					                  <span className='video-player__time-sep'>/</span>
 | 
				
			||||||
                  <span className='video-player__time-total'>{formatTime(duration)}</span>
 | 
					                  <span className='video-player__time-total'>{formatTime(duration)}</span>
 | 
				
			||||||
                </span>
 | 
					                </span>
 | 
				
			||||||
              }
 | 
					              )}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					              {link && <span className='video-player__link'>{link}</span>}
 | 
				
			||||||
            </div>
 | 
					            </div>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <div className='video-player__buttons right'>
 | 
					            <div className='video-player__buttons right'>
 | 
				
			||||||
              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye' fixedWidth /></button>}
 | 
					              {!onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
 | 
				
			||||||
              {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
 | 
					              {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
 | 
				
			||||||
              {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
 | 
					              {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
 | 
				
			||||||
              <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
 | 
					              <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -243,7 +243,7 @@
 | 
				
			||||||
  "navigation_bar.pins": "Ամրացված թթեր",
 | 
					  "navigation_bar.pins": "Ամրացված թթեր",
 | 
				
			||||||
  "navigation_bar.preferences": "Նախապատվություններ",
 | 
					  "navigation_bar.preferences": "Նախապատվություններ",
 | 
				
			||||||
  "navigation_bar.public_timeline": "Դաշնային հոսք",
 | 
					  "navigation_bar.public_timeline": "Դաշնային հոսք",
 | 
				
			||||||
  "navigation_bar.security": "Security",
 | 
					  "navigation_bar.security": "Անվտանգություն",
 | 
				
			||||||
  "notification.favourite": "{name} հավանեց թութդ",
 | 
					  "notification.favourite": "{name} հավանեց թութդ",
 | 
				
			||||||
  "notification.follow": "{name} սկսեց հետեւել քեզ",
 | 
					  "notification.follow": "{name} սկսեց հետեւել քեզ",
 | 
				
			||||||
  "notification.mention": "{name} նշեց քեզ",
 | 
					  "notification.mention": "{name} նշեց քեզ",
 | 
				
			||||||
| 
						 | 
					@ -309,7 +309,7 @@
 | 
				
			||||||
  "search_results.accounts": "People",
 | 
					  "search_results.accounts": "People",
 | 
				
			||||||
  "search_results.hashtags": "Hashtags",
 | 
					  "search_results.hashtags": "Hashtags",
 | 
				
			||||||
  "search_results.statuses": "Toots",
 | 
					  "search_results.statuses": "Toots",
 | 
				
			||||||
  "search_results.total": "{count, number} {count, plural, one {result} other {results}}",
 | 
					  "search_results.total": "{count, number} {count, plural, one {արդյունք} other {արդյունք}}",
 | 
				
			||||||
  "status.admin_account": "Open moderation interface for @{name}",
 | 
					  "status.admin_account": "Open moderation interface for @{name}",
 | 
				
			||||||
  "status.admin_status": "Open this status in the moderation interface",
 | 
					  "status.admin_status": "Open this status in the moderation interface",
 | 
				
			||||||
  "status.block": "Արգելափակել @{name}֊ին",
 | 
					  "status.block": "Արգելափակել @{name}֊ին",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -264,6 +264,16 @@
 | 
				
			||||||
.compose-form {
 | 
					.compose-form {
 | 
				
			||||||
  padding: 10px;
 | 
					  padding: 10px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__sensitive-button {
 | 
				
			||||||
 | 
					    padding: 10px;
 | 
				
			||||||
 | 
					    padding-top: 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    .icon-button {
 | 
				
			||||||
 | 
					      font-size: 14px;
 | 
				
			||||||
 | 
					      font-weight: 500;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  .compose-form__warning {
 | 
					  .compose-form__warning {
 | 
				
			||||||
    color: $inverted-text-color;
 | 
					    color: $inverted-text-color;
 | 
				
			||||||
    margin-bottom: 10px;
 | 
					    margin-bottom: 10px;
 | 
				
			||||||
| 
						 | 
					@ -2412,7 +2422,7 @@ a.account__display-name {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    & > div {
 | 
					    & > div {
 | 
				
			||||||
      background: rgba($base-shadow-color, 0.6);
 | 
					      background: rgba($base-shadow-color, 0.6);
 | 
				
			||||||
      border-radius: 4px;
 | 
					      border-radius: 8px;
 | 
				
			||||||
      padding: 12px 9px;
 | 
					      padding: 12px 9px;
 | 
				
			||||||
      flex: 0 0 auto;
 | 
					      flex: 0 0 auto;
 | 
				
			||||||
      display: flex;
 | 
					      display: flex;
 | 
				
			||||||
| 
						 | 
					@ -2423,19 +2433,18 @@ a.account__display-name {
 | 
				
			||||||
    button,
 | 
					    button,
 | 
				
			||||||
    a {
 | 
					    a {
 | 
				
			||||||
      display: inline;
 | 
					      display: inline;
 | 
				
			||||||
      color: $primary-text-color;
 | 
					      color: $secondary-text-color;
 | 
				
			||||||
      background: transparent;
 | 
					      background: transparent;
 | 
				
			||||||
      border: 0;
 | 
					      border: 0;
 | 
				
			||||||
      padding: 0 5px;
 | 
					      padding: 0 8px;
 | 
				
			||||||
      text-decoration: none;
 | 
					      text-decoration: none;
 | 
				
			||||||
      opacity: 0.6;
 | 
					 | 
				
			||||||
      font-size: 18px;
 | 
					      font-size: 18px;
 | 
				
			||||||
      line-height: 18px;
 | 
					      line-height: 18px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      &:hover,
 | 
					      &:hover,
 | 
				
			||||||
      &:active,
 | 
					      &:active,
 | 
				
			||||||
      &:focus {
 | 
					      &:focus {
 | 
				
			||||||
        opacity: 1;
 | 
					        color: $primary-text-color;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2932,15 +2941,49 @@ a.status-card.compact:hover {
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.spoiler-button {
 | 
					.spoiler-button {
 | 
				
			||||||
  display: none;
 | 
					  top: 0;
 | 
				
			||||||
  left: 4px;
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
  position: absolute;
 | 
					  position: absolute;
 | 
				
			||||||
  text-shadow: 0 1px 1px $base-shadow-color, 1px 0 1px $base-shadow-color;
 | 
					 | 
				
			||||||
  top: 4px;
 | 
					 | 
				
			||||||
  z-index: 100;
 | 
					  z-index: 100;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &.spoiler-button--visible {
 | 
					  &--minified {
 | 
				
			||||||
    display: block;
 | 
					    display: block;
 | 
				
			||||||
 | 
					    left: 4px;
 | 
				
			||||||
 | 
					    top: 4px;
 | 
				
			||||||
 | 
					    width: auto;
 | 
				
			||||||
 | 
					    height: auto;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &--hidden {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__overlay {
 | 
				
			||||||
 | 
					    display: block;
 | 
				
			||||||
 | 
					    background: transparent;
 | 
				
			||||||
 | 
					    width: 100%;
 | 
				
			||||||
 | 
					    height: 100%;
 | 
				
			||||||
 | 
					    border: 0;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &__label {
 | 
				
			||||||
 | 
					      display: inline-block;
 | 
				
			||||||
 | 
					      background: rgba($base-overlay-background, 0.5);
 | 
				
			||||||
 | 
					      border-radius: 8px;
 | 
				
			||||||
 | 
					      padding: 8px 12px;
 | 
				
			||||||
 | 
					      color: $primary-text-color;
 | 
				
			||||||
 | 
					      font-weight: 500;
 | 
				
			||||||
 | 
					      font-size: 14px;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &:hover,
 | 
				
			||||||
 | 
					    &:focus,
 | 
				
			||||||
 | 
					    &:active {
 | 
				
			||||||
 | 
					      .spoiler-button__overlay__label {
 | 
				
			||||||
 | 
					        background: rgba($base-overlay-background, 0.8);
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3728,6 +3771,31 @@ a.status-card.compact:hover {
 | 
				
			||||||
  pointer-events: none;
 | 
					  pointer-events: none;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.media-modal__meta {
 | 
				
			||||||
 | 
					  text-align: center;
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  bottom: 20px;
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  pointer-events: none;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &--shifted {
 | 
				
			||||||
 | 
					    bottom: 62px;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  a {
 | 
				
			||||||
 | 
					    text-decoration: none;
 | 
				
			||||||
 | 
					    font-weight: 500;
 | 
				
			||||||
 | 
					    color: $ui-secondary-color;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &:hover,
 | 
				
			||||||
 | 
					    &:focus,
 | 
				
			||||||
 | 
					    &:active {
 | 
				
			||||||
 | 
					      text-decoration: underline;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.media-modal__page-dot {
 | 
					.media-modal__page-dot {
 | 
				
			||||||
  display: inline-block;
 | 
					  display: inline-block;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -4200,6 +4268,7 @@ a.status-card.compact:hover {
 | 
				
			||||||
  pointer-events: none;
 | 
					  pointer-events: none;
 | 
				
			||||||
  opacity: 0.9;
 | 
					  opacity: 0.9;
 | 
				
			||||||
  transition: opacity 0.1s ease;
 | 
					  transition: opacity 0.1s ease;
 | 
				
			||||||
 | 
					  line-height: 18px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.media-gallery__gifv {
 | 
					.media-gallery__gifv {
 | 
				
			||||||
| 
						 | 
					@ -4313,6 +4382,8 @@ a.status-card.compact:hover {
 | 
				
			||||||
  text-decoration: none;
 | 
					  text-decoration: none;
 | 
				
			||||||
  color: $secondary-text-color;
 | 
					  color: $secondary-text-color;
 | 
				
			||||||
  line-height: 0;
 | 
					  line-height: 0;
 | 
				
			||||||
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  z-index: 1;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &,
 | 
					  &,
 | 
				
			||||||
  img {
 | 
					  img {
 | 
				
			||||||
| 
						 | 
					@ -4325,6 +4396,21 @@ a.status-card.compact:hover {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.media-gallery__preview {
 | 
				
			||||||
 | 
					  width: 100%;
 | 
				
			||||||
 | 
					  height: 100%;
 | 
				
			||||||
 | 
					  object-fit: cover;
 | 
				
			||||||
 | 
					  position: absolute;
 | 
				
			||||||
 | 
					  top: 0;
 | 
				
			||||||
 | 
					  left: 0;
 | 
				
			||||||
 | 
					  z-index: 0;
 | 
				
			||||||
 | 
					  background: $base-overlay-background;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &--hidden {
 | 
				
			||||||
 | 
					    display: none;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.media-gallery__gifv {
 | 
					.media-gallery__gifv {
 | 
				
			||||||
  height: 100%;
 | 
					  height: 100%;
 | 
				
			||||||
  overflow: hidden;
 | 
					  overflow: hidden;
 | 
				
			||||||
| 
						 | 
					@ -4620,6 +4706,23 @@ a.status-card.compact:hover {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  &__link {
 | 
				
			||||||
 | 
					    padding: 2px 10px;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    a {
 | 
				
			||||||
 | 
					      text-decoration: none;
 | 
				
			||||||
 | 
					      font-size: 14px;
 | 
				
			||||||
 | 
					      font-weight: 500;
 | 
				
			||||||
 | 
					      color: $white;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      &:hover,
 | 
				
			||||||
 | 
					      &:active,
 | 
				
			||||||
 | 
					      &:focus {
 | 
				
			||||||
 | 
					        text-decoration: underline;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  &__seek {
 | 
					  &__seek {
 | 
				
			||||||
    cursor: pointer;
 | 
					    cursor: pointer;
 | 
				
			||||||
    height: 24px;
 | 
					    height: 24px;
 | 
				
			||||||
| 
						 | 
					@ -4712,62 +4815,18 @@ a.status-card.compact:hover {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.account-gallery__container {
 | 
					.account-gallery__container {
 | 
				
			||||||
  display: flex;
 | 
					  display: flex;
 | 
				
			||||||
  justify-content: center;
 | 
					 | 
				
			||||||
  flex-wrap: wrap;
 | 
					  flex-wrap: wrap;
 | 
				
			||||||
  padding: 2px;
 | 
					  padding: 4px 2px;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.account-gallery__item {
 | 
					.account-gallery__item {
 | 
				
			||||||
  flex-grow: 1;
 | 
					  border: none;
 | 
				
			||||||
  width: 50%;
 | 
					  box-sizing: border-box;
 | 
				
			||||||
  overflow: hidden;
 | 
					  display: block;
 | 
				
			||||||
  position: relative;
 | 
					  position: relative;
 | 
				
			||||||
 | 
					  border-radius: 4px;
 | 
				
			||||||
  &::before {
 | 
					  overflow: hidden;
 | 
				
			||||||
    content: "";
 | 
					 | 
				
			||||||
    display: block;
 | 
					 | 
				
			||||||
    padding-top: 100%;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  a {
 | 
					 | 
				
			||||||
    display: block;
 | 
					 | 
				
			||||||
    width: calc(100% - 4px);
 | 
					 | 
				
			||||||
    height: calc(100% - 4px);
 | 
					 | 
				
			||||||
  margin: 2px;
 | 
					  margin: 2px;
 | 
				
			||||||
    top: 0;
 | 
					 | 
				
			||||||
    left: 0;
 | 
					 | 
				
			||||||
    background-color: $base-overlay-background;
 | 
					 | 
				
			||||||
    background-size: cover;
 | 
					 | 
				
			||||||
    background-position: center;
 | 
					 | 
				
			||||||
    position: absolute;
 | 
					 | 
				
			||||||
    color: $darker-text-color;
 | 
					 | 
				
			||||||
    text-decoration: none;
 | 
					 | 
				
			||||||
    border-radius: 4px;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    &:hover,
 | 
					 | 
				
			||||||
    &:active,
 | 
					 | 
				
			||||||
    &:focus {
 | 
					 | 
				
			||||||
      outline: 0;
 | 
					 | 
				
			||||||
      color: $secondary-text-color;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      &::before {
 | 
					 | 
				
			||||||
        content: "";
 | 
					 | 
				
			||||||
        display: block;
 | 
					 | 
				
			||||||
        width: 100%;
 | 
					 | 
				
			||||||
        height: 100%;
 | 
					 | 
				
			||||||
        background: rgba($base-overlay-background, 0.3);
 | 
					 | 
				
			||||||
        border-radius: 4px;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  &__icons {
 | 
					 | 
				
			||||||
    position: absolute;
 | 
					 | 
				
			||||||
    top: 50%;
 | 
					 | 
				
			||||||
    left: 50%;
 | 
					 | 
				
			||||||
    transform: translate(-50%, -50%);
 | 
					 | 
				
			||||||
    font-size: 24px;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
.notification__filter-bar,
 | 
					.notification__filter-bar,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -533,6 +533,17 @@ code {
 | 
				
			||||||
    color: $error-value-color;
 | 
					    color: $error-value-color;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  a {
 | 
				
			||||||
 | 
					    display: inline-block;
 | 
				
			||||||
 | 
					    color: $darker-text-color;
 | 
				
			||||||
 | 
					    text-decoration: none;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    &:hover {
 | 
				
			||||||
 | 
					      color: $primary-text-color;
 | 
				
			||||||
 | 
					      text-decoration: underline;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  p {
 | 
					  p {
 | 
				
			||||||
    margin-bottom: 15px;
 | 
					    margin-bottom: 15px;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -194,7 +194,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
				
			||||||
      next if attachment['url'].blank?
 | 
					      next if attachment['url'].blank?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      href             = Addressable::URI.parse(attachment['url']).normalize.to_s
 | 
					      href             = Addressable::URI.parse(attachment['url']).normalize.to_s
 | 
				
			||||||
      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'])
 | 
					      media_attachment = MediaAttachment.create(account: @account, remote_url: href, description: attachment['name'].presence, focus: attachment['focalPoint'], blurhash: supported_blurhash?(attachment['blurhash']) ? attachment['blurhash'] : nil)
 | 
				
			||||||
      media_attachments << media_attachment
 | 
					      media_attachments << media_attachment
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      next if unsupported_media_type?(attachment['mediaType']) || skip_download?
 | 
					      next if unsupported_media_type?(attachment['mediaType']) || skip_download?
 | 
				
			||||||
| 
						 | 
					@ -369,6 +369,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
 | 
				
			||||||
    mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
 | 
					    mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def supported_blurhash?(blurhash)
 | 
				
			||||||
 | 
					    components = blurhash.blank? ? nil : Blurhash.components(blurhash)
 | 
				
			||||||
 | 
					    components.present? && components.none? { |comp| comp > 5 }
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def skip_download?
 | 
					  def skip_download?
 | 
				
			||||||
    return @skip_download if defined?(@skip_download)
 | 
					    return @skip_download if defined?(@skip_download)
 | 
				
			||||||
    @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
 | 
					    @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,6 +19,7 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
 | 
				
			||||||
    conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
 | 
					    conversation: { 'ostatus' => 'http://ostatus.org#', 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri', 'conversation' => 'ostatus:conversation' },
 | 
				
			||||||
    focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
 | 
					    focal_point: { 'toot' => 'http://joinmastodon.org/ns#', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' } },
 | 
				
			||||||
    identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
 | 
					    identity_proof: { 'toot' => 'http://joinmastodon.org/ns#', 'IdentityProof' => 'toot:IdentityProof' },
 | 
				
			||||||
 | 
					    blurhash: { 'toot' => 'http://joinmastodon.org/ns#', 'blurhash' => 'toot:blurhash' },
 | 
				
			||||||
  }.freeze
 | 
					  }.freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def self.default_key_transform
 | 
					  def self.default_key_transform
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,7 @@ module LdapAuthenticable
 | 
				
			||||||
  def ldap_setup(_attributes)
 | 
					  def ldap_setup(_attributes)
 | 
				
			||||||
    self.confirmed_at = Time.now.utc
 | 
					    self.confirmed_at = Time.now.utc
 | 
				
			||||||
    self.admin        = false
 | 
					    self.admin        = false
 | 
				
			||||||
 | 
					    self.external     = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    save!
 | 
					    save!
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -66,6 +66,7 @@ module Omniauthable
 | 
				
			||||||
        email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
 | 
					        email: email || "#{TEMP_EMAIL_PREFIX}-#{auth.uid}-#{auth.provider}.com",
 | 
				
			||||||
        password: Devise.friendly_token[0, 20],
 | 
					        password: Devise.friendly_token[0, 20],
 | 
				
			||||||
        agreement: true,
 | 
					        agreement: true,
 | 
				
			||||||
 | 
					        external: true,
 | 
				
			||||||
        account_attributes: {
 | 
					        account_attributes: {
 | 
				
			||||||
          username: ensure_unique_username(auth.uid),
 | 
					          username: ensure_unique_username(auth.uid),
 | 
				
			||||||
          display_name: display_name,
 | 
					          display_name: display_name,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -34,6 +34,7 @@ module PamAuthenticable
 | 
				
			||||||
      self.confirmed_at = Time.now.utc
 | 
					      self.confirmed_at = Time.now.utc
 | 
				
			||||||
      self.admin        = false
 | 
					      self.admin        = false
 | 
				
			||||||
      self.account      = account
 | 
					      self.account      = account
 | 
				
			||||||
 | 
					      self.external     = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      account.destroy! unless save
 | 
					      account.destroy! unless save
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -29,4 +29,11 @@ class DomainBlock < ApplicationRecord
 | 
				
			||||||
  def self.blocked?(domain)
 | 
					  def self.blocked?(domain)
 | 
				
			||||||
    where(domain: domain, severity: :suspend).exists?
 | 
					    where(domain: domain, severity: :suspend).exists?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def stricter_than?(other_block)
 | 
				
			||||||
 | 
					    return true if suspend?
 | 
				
			||||||
 | 
					    return false if other_block.suspend? && (silence? || noop?)
 | 
				
			||||||
 | 
					    return false if other_block.silence? && noop?
 | 
				
			||||||
 | 
					    (reject_media || !other_block.reject_media) && (reject_reports || !other_block.reject_reports)
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -18,6 +18,7 @@
 | 
				
			||||||
#  account_id          :bigint(8)
 | 
					#  account_id          :bigint(8)
 | 
				
			||||||
#  description         :text
 | 
					#  description         :text
 | 
				
			||||||
#  scheduled_status_id :bigint(8)
 | 
					#  scheduled_status_id :bigint(8)
 | 
				
			||||||
 | 
					#  blurhash            :string
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class MediaAttachment < ApplicationRecord
 | 
					class MediaAttachment < ApplicationRecord
 | 
				
			||||||
| 
						 | 
					@ -34,6 +35,11 @@ class MediaAttachment < ApplicationRecord
 | 
				
			||||||
  VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
 | 
					  VIDEO_CONVERTIBLE_MIME_TYPES = ['video/webm', 'video/quicktime'].freeze
 | 
				
			||||||
  AUDIO_MIME_TYPES             = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
 | 
					  AUDIO_MIME_TYPES             = ['audio/mpeg', 'audio/mp4', 'audio/vnd.wav', 'audio/wav', 'audio/x-wav', 'audio/x-wave', 'audio/ogg',].freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  BLURHASH_OPTIONS = {
 | 
				
			||||||
 | 
					    x_comp: 4,
 | 
				
			||||||
 | 
					    y_comp: 4,
 | 
				
			||||||
 | 
					  }.freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  IMAGE_STYLES = {
 | 
					  IMAGE_STYLES = {
 | 
				
			||||||
    original: {
 | 
					    original: {
 | 
				
			||||||
      pixels: 1_638_400, # 1280x1280px
 | 
					      pixels: 1_638_400, # 1280x1280px
 | 
				
			||||||
| 
						 | 
					@ -43,6 +49,7 @@ class MediaAttachment < ApplicationRecord
 | 
				
			||||||
    small: {
 | 
					    small: {
 | 
				
			||||||
      pixels: 160_000, # 400x400px
 | 
					      pixels: 160_000, # 400x400px
 | 
				
			||||||
      file_geometry_parser: FastGeometryParser,
 | 
					      file_geometry_parser: FastGeometryParser,
 | 
				
			||||||
 | 
					      blurhash: BLURHASH_OPTIONS,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  }.freeze
 | 
					  }.freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -71,6 +78,8 @@ class MediaAttachment < ApplicationRecord
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
      format: 'png',
 | 
					      format: 'png',
 | 
				
			||||||
      time: 0,
 | 
					      time: 0,
 | 
				
			||||||
 | 
					      file_geometry_parser: FastGeometryParser,
 | 
				
			||||||
 | 
					      blurhash: BLURHASH_OPTIONS,
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
  }.freeze
 | 
					  }.freeze
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -186,13 +195,13 @@ class MediaAttachment < ApplicationRecord
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def file_processors(f)
 | 
					    def file_processors(f)
 | 
				
			||||||
      if f.file_content_type == 'image/gif'
 | 
					      if f.file_content_type == 'image/gif'
 | 
				
			||||||
        [:gif_transcoder]
 | 
					        [:gif_transcoder, :blurhash_transcoder]
 | 
				
			||||||
      elsif VIDEO_MIME_TYPES.include? f.file_content_type
 | 
					      elsif VIDEO_MIME_TYPES.include? f.file_content_type
 | 
				
			||||||
        [:video_transcoder]
 | 
					        [:video_transcoder, :blurhash_transcoder]
 | 
				
			||||||
      elsif AUDIO_MIME_TYPES.include? f.file_content_type
 | 
					      elsif AUDIO_MIME_TYPES.include? f.file_content_type
 | 
				
			||||||
        [:audio_transcoder]
 | 
					        [:audio_transcoder]
 | 
				
			||||||
      else
 | 
					      else
 | 
				
			||||||
        [:lazy_thumbnail]
 | 
					        [:lazy_thumbnail, :blurhash_transcoder]
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -78,7 +78,7 @@ class User < ApplicationRecord
 | 
				
			||||||
  accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
 | 
					  accepts_nested_attributes_for :invite_request, reject_if: ->(attributes) { attributes['text'].blank? }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
 | 
					  validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
 | 
				
			||||||
  validates_with BlacklistedEmailValidator, if: :email_changed?
 | 
					  validates_with BlacklistedEmailValidator, on: :create
 | 
				
			||||||
  validates_with EmailMxValidator, if: :validate_email_dns?
 | 
					  validates_with EmailMxValidator, if: :validate_email_dns?
 | 
				
			||||||
  validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
 | 
					  validates :agreement, acceptance: { allow_nil: false, accept: [true, 'true', '1'] }, on: :create
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -107,13 +107,14 @@ class User < ApplicationRecord
 | 
				
			||||||
           :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, to: :settings, prefix: :setting, allow_nil: false
 | 
					           :expand_spoilers, :default_language, :aggregate_reblogs, :show_application, to: :settings, prefix: :setting, allow_nil: false
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  attr_reader :invite_code
 | 
					  attr_reader :invite_code
 | 
				
			||||||
 | 
					  attr_writer :external
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def confirmed?
 | 
					  def confirmed?
 | 
				
			||||||
    confirmed_at.present?
 | 
					    confirmed_at.present?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def invited?
 | 
					  def invited?
 | 
				
			||||||
    invite_id.present?
 | 
					    invite_id.present? && invite.valid_for_use?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def disable!
 | 
					  def disable!
 | 
				
			||||||
| 
						 | 
					@ -273,13 +274,17 @@ class User < ApplicationRecord
 | 
				
			||||||
  private
 | 
					  private
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def set_approved
 | 
					  def set_approved
 | 
				
			||||||
    self.approved = open_registrations? || invited?
 | 
					    self.approved = open_registrations? || invited? || external?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def open_registrations?
 | 
					  def open_registrations?
 | 
				
			||||||
    Setting.registrations_mode == 'open'
 | 
					    Setting.registrations_mode == 'open'
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  def external?
 | 
				
			||||||
 | 
					    !!@external
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def sanitize_languages
 | 
					  def sanitize_languages
 | 
				
			||||||
    return if chosen_languages.nil?
 | 
					    return if chosen_languages.nil?
 | 
				
			||||||
    chosen_languages.reject!(&:blank?)
 | 
					    chosen_languages.reject!(&:blank?)
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,7 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
					class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
				
			||||||
  context_extensions :atom_uri, :conversation, :sensitive,
 | 
					  context_extensions :atom_uri, :conversation, :sensitive,
 | 
				
			||||||
                     :hashtag, :emoji, :focal_point
 | 
					                     :hashtag, :emoji, :focal_point, :blurhash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  attributes :id, :type, :summary,
 | 
					  attributes :id, :type, :summary,
 | 
				
			||||||
             :in_reply_to, :published, :url,
 | 
					             :in_reply_to, :published, :url,
 | 
				
			||||||
| 
						 | 
					@ -153,7 +153,7 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer
 | 
				
			||||||
  class MediaAttachmentSerializer < ActivityPub::Serializer
 | 
					  class MediaAttachmentSerializer < ActivityPub::Serializer
 | 
				
			||||||
    include RoutingHelper
 | 
					    include RoutingHelper
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    attributes :type, :media_type, :url, :name
 | 
					    attributes :type, :media_type, :url, :name, :blurhash
 | 
				
			||||||
    attribute :focal_point, if: :focal_point?
 | 
					    attribute :focal_point, if: :focal_point?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def type
 | 
					    def type
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -5,7 +5,7 @@ class REST::MediaAttachmentSerializer < ActiveModel::Serializer
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  attributes :id, :type, :url, :preview_url,
 | 
					  attributes :id, :type, :url, :preview_url,
 | 
				
			||||||
             :remote_url, :text_url, :meta,
 | 
					             :remote_url, :text_url, :meta,
 | 
				
			||||||
             :description
 | 
					             :description, :blurhash
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def id
 | 
					  def id
 | 
				
			||||||
    object.id.to_s
 | 
					    object.id.to_s
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,7 @@ class BlockService < BaseService
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    UnfollowService.new.call(account, target_account) if account.following?(target_account)
 | 
					    UnfollowService.new.call(account, target_account) if account.following?(target_account)
 | 
				
			||||||
    UnfollowService.new.call(target_account, account) if target_account.following?(account)
 | 
					    UnfollowService.new.call(target_account, account) if target_account.following?(account)
 | 
				
			||||||
 | 
					    RejectFollowService.new.call(account, target_account) if target_account.requested?(account)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    block = account.block!(target_account)
 | 
					    block = account.block!(target_account)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,10 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class BlacklistedEmailValidator < ActiveModel::Validator
 | 
					class BlacklistedEmailValidator < ActiveModel::Validator
 | 
				
			||||||
  def validate(user)
 | 
					  def validate(user)
 | 
				
			||||||
 | 
					    return if user.invited?
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    @email = user.email
 | 
					    @email = user.email
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email?
 | 
					    user.errors.add(:email, I18n.t('users.invalid_email')) if blocked_email?
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -28,7 +28,7 @@
 | 
				
			||||||
  - elsif !status.media_attachments.empty?
 | 
					  - elsif !status.media_attachments.empty?
 | 
				
			||||||
    - if status.media_attachments.first.video?
 | 
					    - if status.media_attachments.first.video?
 | 
				
			||||||
      - video = status.media_attachments.first
 | 
					      - video = status.media_attachments.first
 | 
				
			||||||
      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
 | 
					      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
 | 
				
			||||||
        = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
 | 
					        = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
 | 
				
			||||||
    - else
 | 
					    - else
 | 
				
			||||||
      = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
 | 
					      = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,7 +33,7 @@
 | 
				
			||||||
  - elsif !status.media_attachments.empty?
 | 
					  - elsif !status.media_attachments.empty?
 | 
				
			||||||
    - if status.media_attachments.first.video?
 | 
					    - if status.media_attachments.first.video?
 | 
				
			||||||
      - video = status.media_attachments.first
 | 
					      - video = status.media_attachments.first
 | 
				
			||||||
      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
 | 
					      = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
 | 
				
			||||||
        = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
 | 
					        = render partial: 'stream_entries/attachment_list', locals: { attachments: status.media_attachments }
 | 
				
			||||||
    - else
 | 
					    - else
 | 
				
			||||||
      = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
 | 
					      = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,5 +7,7 @@ class ActivityPub::ProcessingWorker
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  def perform(account_id, body, delivered_to_account_id = nil)
 | 
					  def perform(account_id, body, delivered_to_account_id = nil)
 | 
				
			||||||
    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
 | 
					    ActivityPub::ProcessCollectionService.new.call(body, Account.find(account_id), override_timestamps: true, delivered_to_account_id: delivered_to_account_id, delivery: true)
 | 
				
			||||||
 | 
					  rescue ActiveRecord::RecordInvalid => e
 | 
				
			||||||
 | 
					    Rails.logger.debug "Error processing incoming ActivityPub object: #{e}"
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,6 @@
 | 
				
			||||||
ActiveSupport::Notifications.subscribe('rack.attack') do |_name, _start, _finish, _request_id, req|
 | 
					ActiveSupport::Notifications.subscribe(/rack_attack/) do |_name, _start, _finish, _request_id, payload|
 | 
				
			||||||
 | 
					  req = payload[:request]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
 | 
					  next unless [:throttle, :blacklist].include? req.env['rack.attack.match_type']
 | 
				
			||||||
  Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
 | 
					  Rails.logger.info("Rate limit hit (#{req.env['rack.attack.match_type']}): #{req.ip} #{req.request_method} #{req.fullpath}")
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -269,6 +269,7 @@ co:
 | 
				
			||||||
      created_msg: U blucchime di u duminiu hè attivu
 | 
					      created_msg: U blucchime di u duminiu hè attivu
 | 
				
			||||||
      destroyed_msg: U blucchime di u duminiu ùn hè più attivu
 | 
					      destroyed_msg: U blucchime di u duminiu ùn hè più attivu
 | 
				
			||||||
      domain: Duminiu
 | 
					      domain: Duminiu
 | 
				
			||||||
 | 
					      existing_domain_block_html: Avete digià impostu limite più strette nant'à %{name}, duvete <a href="%{unblock_url}">sbluccallu</a> primu.
 | 
				
			||||||
      new:
 | 
					      new:
 | 
				
			||||||
        create: Creà un blucchime
 | 
					        create: Creà un blucchime
 | 
				
			||||||
        hint: U blucchime di duminiu ùn impedirà micca a creazione di conti indè a database, mà metudi di muderazione specifiche saranu applicati.
 | 
					        hint: U blucchime di duminiu ùn impedirà micca a creazione di conti indè a database, mà metudi di muderazione specifiche saranu applicati.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -270,6 +270,7 @@ en:
 | 
				
			||||||
      created_msg: Domain block is now being processed
 | 
					      created_msg: Domain block is now being processed
 | 
				
			||||||
      destroyed_msg: Domain block has been undone
 | 
					      destroyed_msg: Domain block has been undone
 | 
				
			||||||
      domain: Domain
 | 
					      domain: Domain
 | 
				
			||||||
 | 
					      existing_domain_block_html: You have already imposed stricter limits on %{name}, you need to <a href="%{unblock_url}">unblock it</a> first.
 | 
				
			||||||
      new:
 | 
					      new:
 | 
				
			||||||
        create: Create block
 | 
					        create: Create block
 | 
				
			||||||
        hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
 | 
					        hint: The domain block will not prevent creation of account entries in the database, but will retroactively and automatically apply specific moderation methods on those accounts.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -260,10 +260,10 @@ fr:
 | 
				
			||||||
        title: Nouveau blocage de domaine
 | 
					        title: Nouveau blocage de domaine
 | 
				
			||||||
      reject_media: Fichiers média rejetés
 | 
					      reject_media: Fichiers média rejetés
 | 
				
			||||||
      reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions
 | 
					      reject_media_hint: Supprime localement les fichiers média stockés et refuse d’en télécharger ultérieurement. Ne concerne pas les suspensions
 | 
				
			||||||
      reject_reports: Rapports de rejet
 | 
					      reject_reports: Rejeter les signalements
 | 
				
			||||||
      reject_reports_hint: Ignorez tous les rapports provenant de ce domaine. Sans objet pour les suspensions
 | 
					      reject_reports_hint: Ignorez tous les signalements provenant de ce domaine. Ne concerne pas les suspensions
 | 
				
			||||||
      rejecting_media: rejet des fichiers multimédia
 | 
					      rejecting_media: rejet des fichiers multimédia
 | 
				
			||||||
      rejecting_reports: rejet de rapports
 | 
					      rejecting_reports: rejet des signalements
 | 
				
			||||||
      severity:
 | 
					      severity:
 | 
				
			||||||
        silence: silencié
 | 
					        silence: silencié
 | 
				
			||||||
        suspend: suspendu
 | 
					        suspend: suspendu
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -527,16 +527,17 @@ sk:
 | 
				
			||||||
    login: Prihlás sa
 | 
					    login: Prihlás sa
 | 
				
			||||||
    logout: Odhlás sa
 | 
					    logout: Odhlás sa
 | 
				
			||||||
    migrate_account: Presúvam sa na iný účet
 | 
					    migrate_account: Presúvam sa na iný účet
 | 
				
			||||||
    migrate_account_html: Pokiaľ si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>.
 | 
					    migrate_account_html: Ak si želáš presmerovať tento účet na nejaký iný, môžeš si to <a href="%{path}">nastaviť tu</a>.
 | 
				
			||||||
    or_log_in_with: Alebo prihlásiť z
 | 
					    or_log_in_with: Alebo prihlás s
 | 
				
			||||||
    providers:
 | 
					    providers:
 | 
				
			||||||
      cas: CAS
 | 
					      cas: CAS
 | 
				
			||||||
      saml: SAML
 | 
					      saml: SAML
 | 
				
			||||||
    register: Zaregistruj sa
 | 
					    register: Zaregistruj sa
 | 
				
			||||||
    resend_confirmation: Poslať potvrdzujúce pokyny znovu
 | 
					    resend_confirmation: Zašli potvrdzujúce pokyny znovu
 | 
				
			||||||
    reset_password: Obnov heslo
 | 
					    reset_password: Obnov heslo
 | 
				
			||||||
    security: Zabezpečenie
 | 
					    security: Zabezpečenie
 | 
				
			||||||
    set_new_password: Nastav nové heslo
 | 
					    set_new_password: Nastav nové heslo
 | 
				
			||||||
 | 
					    trouble_logging_in: Problém s prihlásením?
 | 
				
			||||||
  authorize_follow:
 | 
					  authorize_follow:
 | 
				
			||||||
    already_following: Tento účet už následuješ
 | 
					    already_following: Tento účet už následuješ
 | 
				
			||||||
    error: Naneštastie nastala chyba pri hľadaní vzdialeného účtu
 | 
					    error: Naneštastie nastala chyba pri hľadaní vzdialeného účtu
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,5 @@
 | 
				
			||||||
 | 
					class AddBlurhashToMediaAttachments < ActiveRecord::Migration[5.2]
 | 
				
			||||||
 | 
					  def change
 | 
				
			||||||
 | 
					    add_column :media_attachments, :blurhash, :string
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					@ -10,7 +10,7 @@
 | 
				
			||||||
#
 | 
					#
 | 
				
			||||||
# It's strongly recommended that you check this file into your version control system.
 | 
					# It's strongly recommended that you check this file into your version control system.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
ActiveRecord::Schema.define(version: 2019_04_09_054914) do
 | 
					ActiveRecord::Schema.define(version: 2019_04_20_025523) do
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  # These are extensions that must be enabled in order to support this database
 | 
					  # These are extensions that must be enabled in order to support this database
 | 
				
			||||||
  enable_extension "plpgsql"
 | 
					  enable_extension "plpgsql"
 | 
				
			||||||
| 
						 | 
					@ -373,6 +373,7 @@ ActiveRecord::Schema.define(version: 2019_04_09_054914) do
 | 
				
			||||||
    t.bigint "account_id"
 | 
					    t.bigint "account_id"
 | 
				
			||||||
    t.text "description"
 | 
					    t.text "description"
 | 
				
			||||||
    t.bigint "scheduled_status_id"
 | 
					    t.bigint "scheduled_status_id"
 | 
				
			||||||
 | 
					    t.string "blurhash"
 | 
				
			||||||
    t.index ["account_id"], name: "index_media_attachments_on_account_id"
 | 
					    t.index ["account_id"], name: "index_media_attachments_on_account_id"
 | 
				
			||||||
    t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
 | 
					    t.index ["scheduled_status_id"], name: "index_media_attachments_on_scheduled_status_id"
 | 
				
			||||||
    t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
 | 
					    t.index ["shortcode"], name: "index_media_attachments_on_shortcode", unique: true
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,6 +9,7 @@ require_relative 'mastodon/search_cli'
 | 
				
			||||||
require_relative 'mastodon/settings_cli'
 | 
					require_relative 'mastodon/settings_cli'
 | 
				
			||||||
require_relative 'mastodon/statuses_cli'
 | 
					require_relative 'mastodon/statuses_cli'
 | 
				
			||||||
require_relative 'mastodon/domains_cli'
 | 
					require_relative 'mastodon/domains_cli'
 | 
				
			||||||
 | 
					require_relative 'mastodon/cache_cli'
 | 
				
			||||||
require_relative 'mastodon/version'
 | 
					require_relative 'mastodon/version'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
module Mastodon
 | 
					module Mastodon
 | 
				
			||||||
| 
						 | 
					@ -41,6 +42,9 @@ module Mastodon
 | 
				
			||||||
    desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
 | 
					    desc 'domains SUBCOMMAND ...ARGS', 'Manage account domains'
 | 
				
			||||||
    subcommand 'domains', Mastodon::DomainsCLI
 | 
					    subcommand 'domains', Mastodon::DomainsCLI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    desc 'cache SUBCOMMAND ...ARGS', 'Manage cache'
 | 
				
			||||||
 | 
					    subcommand 'cache', Mastodon::CacheCLI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    option :dry_run, type: :boolean
 | 
					    option :dry_run, type: :boolean
 | 
				
			||||||
    desc 'self-destruct', 'Erase the server from the federation'
 | 
					    desc 'self-destruct', 'Erase the server from the federation'
 | 
				
			||||||
    long_desc <<~LONG_DESC
 | 
					    long_desc <<~LONG_DESC
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -73,7 +73,7 @@ module Mastodon
 | 
				
			||||||
    def create(username)
 | 
					    def create(username)
 | 
				
			||||||
      account  = Account.new(username: username)
 | 
					      account  = Account.new(username: username)
 | 
				
			||||||
      password = SecureRandom.hex
 | 
					      password = SecureRandom.hex
 | 
				
			||||||
      user     = User.new(email: options[:email], password: password, agreement: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
 | 
					      user     = User.new(email: options[:email], password: password, agreement: true, approved: true, admin: options[:role] == 'admin', moderator: options[:role] == 'moderator', confirmed_at: options[:confirmed] ? Time.now.utc : nil)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      if options[:reattach]
 | 
					      if options[:reattach]
 | 
				
			||||||
        account = Account.find_local(username) || Account.new(username: username)
 | 
					        account = Account.find_local(username) || Account.new(username: username)
 | 
				
			||||||
| 
						 | 
					@ -115,6 +115,7 @@ module Mastodon
 | 
				
			||||||
    option :enable, type: :boolean
 | 
					    option :enable, type: :boolean
 | 
				
			||||||
    option :disable, type: :boolean
 | 
					    option :disable, type: :boolean
 | 
				
			||||||
    option :disable_2fa, type: :boolean
 | 
					    option :disable_2fa, type: :boolean
 | 
				
			||||||
 | 
					    option :approve, type: :boolean
 | 
				
			||||||
    desc 'modify USERNAME', 'Modify a user'
 | 
					    desc 'modify USERNAME', 'Modify a user'
 | 
				
			||||||
    long_desc <<-LONG_DESC
 | 
					    long_desc <<-LONG_DESC
 | 
				
			||||||
      Modify a user account.
 | 
					      Modify a user account.
 | 
				
			||||||
| 
						 | 
					@ -128,6 +129,9 @@ module Mastodon
 | 
				
			||||||
      With the --disable option, lock the user out of their account. The
 | 
					      With the --disable option, lock the user out of their account. The
 | 
				
			||||||
      --enable option is the opposite.
 | 
					      --enable option is the opposite.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      With the --approve option, the account will be approved, if it was
 | 
				
			||||||
 | 
					      previously not due to not having open registrations.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      With the --disable-2fa option, the two-factor authentication
 | 
					      With the --disable-2fa option, the two-factor authentication
 | 
				
			||||||
      requirement for the user can be removed.
 | 
					      requirement for the user can be removed.
 | 
				
			||||||
    LONG_DESC
 | 
					    LONG_DESC
 | 
				
			||||||
| 
						 | 
					@ -147,6 +151,7 @@ module Mastodon
 | 
				
			||||||
      user.email = options[:email] if options[:email]
 | 
					      user.email = options[:email] if options[:email]
 | 
				
			||||||
      user.disabled = false if options[:enable]
 | 
					      user.disabled = false if options[:enable]
 | 
				
			||||||
      user.disabled = true if options[:disable]
 | 
					      user.disabled = true if options[:disable]
 | 
				
			||||||
 | 
					      user.approved = true if options[:approve]
 | 
				
			||||||
      user.otp_required_for_login = false if options[:disable_2fa]
 | 
					      user.otp_required_for_login = false if options[:disable_2fa]
 | 
				
			||||||
      user.confirm if options[:confirm]
 | 
					      user.confirm if options[:confirm]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										19
									
								
								lib/mastodon/cache_cli.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								lib/mastodon/cache_cli.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,19 @@
 | 
				
			||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					require_relative '../../config/boot'
 | 
				
			||||||
 | 
					require_relative '../../config/environment'
 | 
				
			||||||
 | 
					require_relative 'cli_helper'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module Mastodon
 | 
				
			||||||
 | 
					  class CacheCLI < Thor
 | 
				
			||||||
 | 
					    def self.exit_on_failure?
 | 
				
			||||||
 | 
					      true
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    desc 'clear', 'Clear out the cache storage'
 | 
				
			||||||
 | 
					    def clear
 | 
				
			||||||
 | 
					      Rails.cache.clear
 | 
				
			||||||
 | 
					      say('OK', :green)
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					@ -13,7 +13,7 @@ module Mastodon
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def patch
 | 
					    def patch
 | 
				
			||||||
      0
 | 
					      1
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def pre
 | 
					    def pre
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
							
								
								
									
										16
									
								
								lib/paperclip/blurhash_transcoder.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/paperclip/blurhash_transcoder.rb
									
									
									
									
									
										Normal file
									
								
							| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					# frozen_string_literal: true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					module Paperclip
 | 
				
			||||||
 | 
					  class BlurhashTranscoder < Paperclip::Processor
 | 
				
			||||||
 | 
					    def make
 | 
				
			||||||
 | 
					      return @file unless options[:style] == :small
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      pixels   = convert(':source RGB:-', source: File.expand_path(@file.path)).unpack('C*')
 | 
				
			||||||
 | 
					      geometry = options.fetch(:file_geometry_parser).from_file(@file)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      attachment.instance.blurhash = Blurhash.encode(geometry.width, geometry.height, pixels, options[:blurhash] || {})
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      @file
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
 | 
					end
 | 
				
			||||||
| 
						 | 
					@ -80,6 +80,7 @@
 | 
				
			||||||
    "babel-plugin-react-intl": "^3.0.1",
 | 
					    "babel-plugin-react-intl": "^3.0.1",
 | 
				
			||||||
    "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
 | 
					    "babel-plugin-transform-react-remove-prop-types": "^0.4.24",
 | 
				
			||||||
    "babel-runtime": "^6.26.0",
 | 
					    "babel-runtime": "^6.26.0",
 | 
				
			||||||
 | 
					    "blurhash": "^1.0.0",
 | 
				
			||||||
    "classnames": "^2.2.5",
 | 
					    "classnames": "^2.2.5",
 | 
				
			||||||
    "compression-webpack-plugin": "^2.0.0",
 | 
					    "compression-webpack-plugin": "^2.0.0",
 | 
				
			||||||
    "cross-env": "^5.1.4",
 | 
					    "cross-env": "^5.1.4",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,3 +2,4 @@
 | 
				
			||||||
 | 
					
 | 
				
			||||||
User-agent: *
 | 
					User-agent: *
 | 
				
			||||||
Disallow: /media_proxy/
 | 
					Disallow: /media_proxy/
 | 
				
			||||||
 | 
					Disallow: /interact/
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -37,7 +37,7 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it 'renders new when failed to save' do
 | 
					    it 'renders new when failed to save' do
 | 
				
			||||||
      Fabricate(:domain_block, domain: 'example.com')
 | 
					      Fabricate(:domain_block, domain: 'example.com', severity: 'suspend')
 | 
				
			||||||
      allow(DomainBlockWorker).to receive(:perform_async).and_return(true)
 | 
					      allow(DomainBlockWorker).to receive(:perform_async).and_return(true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } }
 | 
					      post :create, params: { domain_block: { domain: 'example.com', severity: 'silence' } }
 | 
				
			||||||
| 
						 | 
					@ -45,6 +45,17 @@ RSpec.describe Admin::DomainBlocksController, type: :controller do
 | 
				
			||||||
      expect(DomainBlockWorker).not_to have_received(:perform_async)
 | 
					      expect(DomainBlockWorker).not_to have_received(:perform_async)
 | 
				
			||||||
      expect(response).to render_template :new
 | 
					      expect(response).to render_template :new
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'allows upgrading a block' do
 | 
				
			||||||
 | 
					      Fabricate(:domain_block, domain: 'example.com', severity: 'silence')
 | 
				
			||||||
 | 
					      allow(DomainBlockWorker).to receive(:perform_async).and_return(true)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      post :create, params: { domain_block: { domain: 'example.com', severity: 'silence', reject_media: true, reject_reports: true } }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      expect(DomainBlockWorker).to have_received(:perform_async)
 | 
				
			||||||
 | 
					      expect(flash[:notice]).to eq I18n.t('admin.domain_blocks.created_msg')
 | 
				
			||||||
 | 
					      expect(response).to redirect_to(admin_instances_path(limited: '1'))
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  describe 'DELETE #destroy' do
 | 
					  describe 'DELETE #destroy' do
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -107,6 +107,89 @@ RSpec.describe Auth::RegistrationsController, type: :controller do
 | 
				
			||||||
      end
 | 
					      end
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'approval-based registrations without invite' do
 | 
				
			||||||
 | 
					      around do |example|
 | 
				
			||||||
 | 
					        registrations_mode = Setting.registrations_mode
 | 
				
			||||||
 | 
					        example.run
 | 
				
			||||||
 | 
					        Setting.registrations_mode = registrations_mode
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      subject do
 | 
				
			||||||
 | 
					        Setting.registrations_mode = 'approved'
 | 
				
			||||||
 | 
					        request.headers["Accept-Language"] = accept_language
 | 
				
			||||||
 | 
					        post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678' } }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'redirects to login page' do
 | 
				
			||||||
 | 
					        subject
 | 
				
			||||||
 | 
					        expect(response).to redirect_to new_user_session_path
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'creates user' do
 | 
				
			||||||
 | 
					        subject
 | 
				
			||||||
 | 
					        user = User.find_by(email: 'test@example.com')
 | 
				
			||||||
 | 
					        expect(user).to_not be_nil
 | 
				
			||||||
 | 
					        expect(user.locale).to eq(accept_language)
 | 
				
			||||||
 | 
					        expect(user.approved).to eq(false)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'approval-based registrations with expired invite' do
 | 
				
			||||||
 | 
					      around do |example|
 | 
				
			||||||
 | 
					        registrations_mode = Setting.registrations_mode
 | 
				
			||||||
 | 
					        example.run
 | 
				
			||||||
 | 
					        Setting.registrations_mode = registrations_mode
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      subject do
 | 
				
			||||||
 | 
					        Setting.registrations_mode = 'approved'
 | 
				
			||||||
 | 
					        request.headers["Accept-Language"] = accept_language
 | 
				
			||||||
 | 
					        invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.ago)
 | 
				
			||||||
 | 
					        post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code } }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'redirects to login page' do
 | 
				
			||||||
 | 
					        subject
 | 
				
			||||||
 | 
					        expect(response).to redirect_to new_user_session_path
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'creates user' do
 | 
				
			||||||
 | 
					        subject
 | 
				
			||||||
 | 
					        user = User.find_by(email: 'test@example.com')
 | 
				
			||||||
 | 
					        expect(user).to_not be_nil
 | 
				
			||||||
 | 
					        expect(user.locale).to eq(accept_language)
 | 
				
			||||||
 | 
					        expect(user.approved).to eq(false)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    context 'approval-based registrations with valid invite' do
 | 
				
			||||||
 | 
					      around do |example|
 | 
				
			||||||
 | 
					        registrations_mode = Setting.registrations_mode
 | 
				
			||||||
 | 
					        example.run
 | 
				
			||||||
 | 
					        Setting.registrations_mode = registrations_mode
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      subject do
 | 
				
			||||||
 | 
					        Setting.registrations_mode = 'approved'
 | 
				
			||||||
 | 
					        request.headers["Accept-Language"] = accept_language
 | 
				
			||||||
 | 
					        invite = Fabricate(:invite, max_uses: nil, expires_at: 1.hour.from_now)
 | 
				
			||||||
 | 
					        post :create, params: { user: { account_attributes: { username: 'test' }, email: 'test@example.com', password: '12345678', password_confirmation: '12345678', 'invite_code': invite.code } }
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'redirects to login page' do
 | 
				
			||||||
 | 
					        subject
 | 
				
			||||||
 | 
					        expect(response).to redirect_to new_user_session_path
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      it 'creates user' do
 | 
				
			||||||
 | 
					        subject
 | 
				
			||||||
 | 
					        user = User.find_by(email: 'test@example.com')
 | 
				
			||||||
 | 
					        expect(user).to_not be_nil
 | 
				
			||||||
 | 
					        expect(user.locale).to eq(accept_language)
 | 
				
			||||||
 | 
					        expect(user.approved).to eq(true)
 | 
				
			||||||
 | 
					      end
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    it 'does nothing if user already exists' do
 | 
					    it 'does nothing if user already exists' do
 | 
				
			||||||
      Fabricate(:user, account: Fabricate(:account, username: 'test'))
 | 
					      Fabricate(:user, account: Fabricate(:account, username: 'test'))
 | 
				
			||||||
      subject
 | 
					      subject
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -36,4 +36,35 @@ RSpec.describe DomainBlock, type: :model do
 | 
				
			||||||
      expect(DomainBlock.blocked?('domain')).to eq false
 | 
					      expect(DomainBlock.blocked?('domain')).to eq false
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
  end
 | 
					  end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  describe 'stricter_than?' do
 | 
				
			||||||
 | 
					    it 'returns true if the new block has suspend severity while the old has lower severity' do
 | 
				
			||||||
 | 
					      suspend = DomainBlock.new(domain: 'domain', severity: :suspend)
 | 
				
			||||||
 | 
					      silence = DomainBlock.new(domain: 'domain', severity: :silence)
 | 
				
			||||||
 | 
					      noop = DomainBlock.new(domain: 'domain', severity: :noop)
 | 
				
			||||||
 | 
					      expect(suspend.stricter_than?(silence)).to be true
 | 
				
			||||||
 | 
					      expect(suspend.stricter_than?(noop)).to be true
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns false if the new block has lower severity than the old one' do
 | 
				
			||||||
 | 
					      suspend = DomainBlock.new(domain: 'domain', severity: :suspend)
 | 
				
			||||||
 | 
					      silence = DomainBlock.new(domain: 'domain', severity: :silence)
 | 
				
			||||||
 | 
					      noop = DomainBlock.new(domain: 'domain', severity: :noop)
 | 
				
			||||||
 | 
					      expect(silence.stricter_than?(suspend)).to be false
 | 
				
			||||||
 | 
					      expect(noop.stricter_than?(suspend)).to be false
 | 
				
			||||||
 | 
					      expect(noop.stricter_than?(silence)).to be false
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns false if the new block does is less strict regarding reports' do
 | 
				
			||||||
 | 
					      older = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: true)
 | 
				
			||||||
 | 
					      newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_reports: false)
 | 
				
			||||||
 | 
					      expect(newer.stricter_than?(older)).to be false
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    it 'returns false if the new block does is less strict regarding media' do
 | 
				
			||||||
 | 
					      older = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: true)
 | 
				
			||||||
 | 
					      newer = DomainBlock.new(domain: 'domain', severity: :silence, reject_media: false)
 | 
				
			||||||
 | 
					      expect(newer.stricter_than?(older)).to be false
 | 
				
			||||||
 | 
					    end
 | 
				
			||||||
 | 
					  end
 | 
				
			||||||
end
 | 
					end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,7 @@ RSpec.describe BlacklistedEmailValidator, type: :validator do
 | 
				
			||||||
    let(:errors) { double(add: nil) }
 | 
					    let(:errors) { double(add: nil) }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    before do
 | 
					    before do
 | 
				
			||||||
 | 
					      allow(user).to receive(:invited?) { false }
 | 
				
			||||||
      allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
 | 
					      allow_any_instance_of(described_class).to receive(:blocked_email?) { blocked_email }
 | 
				
			||||||
      described_class.new.validate(user)
 | 
					      described_class.new.validate(user)
 | 
				
			||||||
    end
 | 
					    end
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1747,6 +1747,11 @@ bluebird@^3.5.1, bluebird@^3.5.3:
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
 | 
					  resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.3.tgz#7d01c6f9616c9a51ab0f8c549a79dfe6ec33efa7"
 | 
				
			||||||
  integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==
 | 
					  integrity sha512-/qKPUQlaW1OyR51WeCPBvRnAlnZFUJkCSG5HzGnuIqhgyJtF+T94lFnn33eiazjRm2LAHVy2guNnaq48X9SJuw==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					blurhash@^1.0.0:
 | 
				
			||||||
 | 
					  version "1.0.0"
 | 
				
			||||||
 | 
					  resolved "https://registry.yarnpkg.com/blurhash/-/blurhash-1.0.0.tgz#9087bc5cc4d482f1305059d7410df4133adcab2e"
 | 
				
			||||||
 | 
					  integrity sha512-x6fpZnd6AWde4U9m7xhUB44qIvGV4W6OdTAXGabYm4oZUOOGh5K1HAEoGAQn3iG4gbbPn9RSGce3VfNgGsX/Vw==
 | 
				
			||||||
 | 
					
 | 
				
			||||||
bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
 | 
					bn.js@^4.0.0, bn.js@^4.1.0, bn.js@^4.1.1, bn.js@^4.4.0:
 | 
				
			||||||
  version "4.11.8"
 | 
					  version "4.11.8"
 | 
				
			||||||
  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
 | 
					  resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in a new issue