Merge pull request #1430 from ThibG/glitch-soc/merge-upstream

Merge upstream changes
This commit is contained in:
ThibG 2020-09-29 10:29:42 +02:00 committed by GitHub
commit 62e3f588de
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
197 changed files with 2543 additions and 1037 deletions

18
Gemfile
View File

@ -5,10 +5,10 @@ ruby '>= 2.5.0', '< 3.0.0'
gem 'pkg-config', '~> 1.4' gem 'pkg-config', '~> 1.4'
gem 'puma', '~> 4.3' gem 'puma', '~> 5.0'
gem 'rails', '~> 5.2.4.3' gem 'rails', '~> 5.2.4.4'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 0.20' gem 'thor', '~> 1.0'
gem 'rack', '~> 2.2.3' gem 'rack', '~> 2.2.3'
gem 'thwait', '~> 0.2.0' gem 'thwait', '~> 0.2.0'
@ -20,7 +20,7 @@ gem 'makara', '~> 0.4'
gem 'pghero', '~> 2.7' gem 'pghero', '~> 2.7'
gem 'dotenv-rails', '~> 2.7' gem 'dotenv-rails', '~> 2.7'
gem 'aws-sdk-s3', '~> 1.79', require: false gem 'aws-sdk-s3', '~> 1.81', require: false
gem 'fog-core', '<= 2.1.0' gem 'fog-core', '<= 2.1.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'paperclip', '~> 6.0' gem 'paperclip', '~> 6.0'
@ -123,26 +123,26 @@ end
group :test do group :test do
gem 'capybara', '~> 3.33' gem 'capybara', '~> 3.33'
gem 'climate_control', '~> 0.2' gem 'climate_control', '~> 0.2'
gem 'faker', '~> 2.13' gem 'faker', '~> 2.14'
gem 'microformats', '~> 4.2' gem 'microformats', '~> 4.2'
gem 'rails-controller-testing', '~> 1.0' gem 'rails-controller-testing', '~> 1.0'
gem 'rspec-sidekiq', '~> 3.1' gem 'rspec-sidekiq', '~> 3.1'
gem 'simplecov', '~> 0.19', require: false gem 'simplecov', '~> 0.19', require: false
gem 'webmock', '~> 3.8' gem 'webmock', '~> 3.9'
gem 'parallel_tests', '~> 3.2' gem 'parallel_tests', '~> 3.3'
gem 'rspec_junit_formatter', '~> 0.4' gem 'rspec_junit_formatter', '~> 0.4'
end end
group :development do group :development do
gem 'active_record_query_trace', '~> 1.7' gem 'active_record_query_trace', '~> 1.7'
gem 'annotate', '~> 3.1' gem 'annotate', '~> 3.1'
gem 'better_errors', '~> 2.7' gem 'better_errors', '~> 2.8'
gem 'binding_of_caller', '~> 0.7' gem 'binding_of_caller', '~> 0.7'
gem 'bullet', '~> 6.1' gem 'bullet', '~> 6.1'
gem 'letter_opener', '~> 1.7' gem 'letter_opener', '~> 1.7'
gem 'letter_opener_web', '~> 1.4' gem 'letter_opener_web', '~> 1.4'
gem 'memory_profiler' gem 'memory_profiler'
gem 'rubocop', '~> 0.90', require: false gem 'rubocop', '~> 0.91', require: false
gem 'rubocop-rails', '~> 2.8', require: false gem 'rubocop-rails', '~> 2.8', require: false
gem 'brakeman', '~> 4.9', require: false gem 'brakeman', '~> 4.9', require: false
gem 'bundler-audit', '~> 0.7', require: false gem 'bundler-audit', '~> 0.7', require: false

View File

@ -16,25 +16,25 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (5.2.4.3) actioncable (5.2.4.4)
actionpack (= 5.2.4.3) actionpack (= 5.2.4.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailer (5.2.4.3) actionmailer (5.2.4.4)
actionpack (= 5.2.4.3) actionpack (= 5.2.4.4)
actionview (= 5.2.4.3) actionview (= 5.2.4.4)
activejob (= 5.2.4.3) activejob (= 5.2.4.4)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (5.2.4.3) actionpack (5.2.4.4)
actionview (= 5.2.4.3) actionview (= 5.2.4.4)
activesupport (= 5.2.4.3) activesupport (= 5.2.4.4)
rack (~> 2.0, >= 2.0.8) rack (~> 2.0, >= 2.0.8)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.0.2) rails-html-sanitizer (~> 1.0, >= 1.0.2)
actionview (5.2.4.3) actionview (5.2.4.4)
activesupport (= 5.2.4.3) activesupport (= 5.2.4.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
@ -45,20 +45,20 @@ GEM
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
active_record_query_trace (1.7) active_record_query_trace (1.7)
activejob (5.2.4.3) activejob (5.2.4.4)
activesupport (= 5.2.4.3) activesupport (= 5.2.4.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (5.2.4.3) activemodel (5.2.4.4)
activesupport (= 5.2.4.3) activesupport (= 5.2.4.4)
activerecord (5.2.4.3) activerecord (5.2.4.4)
activemodel (= 5.2.4.3) activemodel (= 5.2.4.4)
activesupport (= 5.2.4.3) activesupport (= 5.2.4.4)
arel (>= 9.0) arel (>= 9.0)
activestorage (5.2.4.3) activestorage (5.2.4.4)
actionpack (= 5.2.4.3) actionpack (= 5.2.4.4)
activerecord (= 5.2.4.3) activerecord (= 5.2.4.4)
marcel (~> 0.3.1) marcel (~> 0.3.1)
activesupport (5.2.4.3) activesupport (5.2.4.4)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
minitest (~> 5.1) minitest (~> 5.1)
@ -79,23 +79,23 @@ GEM
cocaine (~> 0.5.3) cocaine (~> 0.5.3)
awrence (1.1.1) awrence (1.1.1)
aws-eventstream (1.1.0) aws-eventstream (1.1.0)
aws-partitions (1.365.0) aws-partitions (1.373.0)
aws-sdk-core (3.105.0) aws-sdk-core (3.107.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.239.0) aws-partitions (~> 1, >= 1.239.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
jmespath (~> 1.0) jmespath (~> 1.0)
aws-sdk-kms (1.37.0) aws-sdk-kms (1.38.0)
aws-sdk-core (~> 3, >= 3.99.0) aws-sdk-core (~> 3, >= 3.99.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.79.1) aws-sdk-s3 (1.81.0)
aws-sdk-core (~> 3, >= 3.104.3) aws-sdk-core (~> 3, >= 3.104.3)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sigv4 (1.2.2) aws-sigv4 (1.2.2)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
bcrypt (3.1.16) bcrypt (3.1.16)
better_errors (2.7.1) better_errors (2.8.1)
coderay (>= 1.0.0) coderay (>= 1.0.0)
erubi (>= 1.0.0) erubi (>= 1.0.0)
rack (>= 0.9.0) rack (>= 0.9.0)
@ -160,13 +160,12 @@ GEM
cose (1.0.0) cose (1.0.0)
cbor (~> 0.5.9) cbor (~> 0.5.9)
openssl-signature_algorithm (~> 0.4.0) openssl-signature_algorithm (~> 0.4.0)
crack (0.4.3) crack (0.4.4)
safe_yaml (~> 1.0.0)
crass (1.0.6) crass (1.0.6)
css_parser (1.7.1) css_parser (1.7.1)
addressable addressable
debug_inspector (0.0.3) debug_inspector (0.0.3)
devise (4.7.2) devise (4.7.3)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
@ -210,7 +209,7 @@ GEM
tzinfo tzinfo
excon (0.76.0) excon (0.76.0)
fabrication (2.21.1) fabrication (2.21.1)
faker (2.13.0) faker (2.14.0)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
faraday (1.0.1) faraday (1.0.1)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
@ -233,7 +232,7 @@ GEM
fog-json (>= 1.0) fog-json (>= 1.0)
ipaddress (>= 0.8) ipaddress (>= 0.8)
formatador (0.2.5) formatador (0.2.5)
fugit (1.3.8) fugit (1.3.9)
et-orbi (~> 1.1, >= 1.1.8) et-orbi (~> 1.1, >= 1.1.8)
raabro (~> 1.3) raabro (~> 1.3)
fuubar (2.5.0) fuubar (2.5.0)
@ -363,7 +362,7 @@ GEM
net-scp (3.0.0) net-scp (3.0.0)
net-ssh (>= 2.6.5, < 7.0.0) net-ssh (>= 2.6.5, < 7.0.0)
net-ssh (6.1.0) net-ssh (6.1.0)
nio4r (2.5.3) nio4r (2.5.4)
nokogiri (1.10.10) nokogiri (1.10.10)
mini_portile2 (~> 2.4.0) mini_portile2 (~> 2.4.0)
nokogumbo (2.0.2) nokogumbo (2.0.2)
@ -387,7 +386,7 @@ GEM
openssl (2.2.0) openssl (2.2.0)
openssl-signature_algorithm (0.4.0) openssl-signature_algorithm (0.4.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
ox (2.13.3) ox (2.13.4)
paperclip (6.0.0) paperclip (6.0.0)
activemodel (>= 4.2.0) activemodel (>= 4.2.0)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
@ -398,7 +397,7 @@ GEM
av (~> 0.9.0) av (~> 0.9.0)
paperclip (>= 2.5.2) paperclip (>= 2.5.2)
parallel (1.19.2) parallel (1.19.2)
parallel_tests (3.2.0) parallel_tests (3.3.0)
parallel parallel
parser (2.7.1.4) parser (2.7.1.4)
ast (~> 2.4.1) ast (~> 2.4.1)
@ -406,11 +405,11 @@ GEM
pastel (0.8.0) pastel (0.8.0)
tty-color (~> 0.5) tty-color (~> 0.5)
pg (1.2.3) pg (1.2.3)
pghero (2.7.0) pghero (2.7.2)
activerecord (>= 5) activerecord (>= 5)
pkg-config (1.4.2) pkg-config (1.4.3)
posix-spawn (0.3.15) posix-spawn (0.3.15)
premailer (1.13.1) premailer (1.14.2)
addressable addressable
css_parser (>= 1.6.0) css_parser (>= 1.6.0)
htmlentities (>= 4.0.0) htmlentities (>= 4.0.0)
@ -427,7 +426,7 @@ GEM
pry-rails (0.3.9) pry-rails (0.3.9)
pry (>= 0.10.4) pry (>= 0.10.4)
public_suffix (4.0.6) public_suffix (4.0.6)
puma (4.3.6) puma (5.0.1)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.1.0) pundit (2.1.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@ -441,18 +440,18 @@ GEM
rack rack
rack-test (1.1.0) rack-test (1.1.0)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rails (5.2.4.3) rails (5.2.4.4)
actioncable (= 5.2.4.3) actioncable (= 5.2.4.4)
actionmailer (= 5.2.4.3) actionmailer (= 5.2.4.4)
actionpack (= 5.2.4.3) actionpack (= 5.2.4.4)
actionview (= 5.2.4.3) actionview (= 5.2.4.4)
activejob (= 5.2.4.3) activejob (= 5.2.4.4)
activemodel (= 5.2.4.3) activemodel (= 5.2.4.4)
activerecord (= 5.2.4.3) activerecord (= 5.2.4.4)
activestorage (= 5.2.4.3) activestorage (= 5.2.4.4)
activesupport (= 5.2.4.3) activesupport (= 5.2.4.4)
bundler (>= 1.3.0) bundler (>= 1.3.0)
railties (= 5.2.4.3) railties (= 5.2.4.4)
sprockets-rails (>= 2.0.0) sprockets-rails (>= 2.0.0)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
@ -468,9 +467,9 @@ GEM
railties (>= 5.0, < 6) railties (>= 5.0, < 6)
rails-settings-cached (0.6.6) rails-settings-cached (0.6.6)
rails (>= 4.2.0) rails (>= 4.2.0)
railties (5.2.4.3) railties (5.2.4.4)
actionpack (= 5.2.4.3) actionpack (= 5.2.4.4)
activesupport (= 5.2.4.3) activesupport (= 5.2.4.4)
method_source method_source
rake (>= 0.8.7) rake (>= 0.8.7)
thor (>= 0.19.0, < 2.0) thor (>= 0.19.0, < 2.0)
@ -482,7 +481,7 @@ GEM
rdf-normalize (0.4.0) rdf-normalize (0.4.0)
rdf (~> 3.1) rdf (~> 3.1)
redcarpet (3.5.0) redcarpet (3.5.0)
redis (4.2.1) redis (4.2.2)
redis-actionpack (5.2.0) redis-actionpack (5.2.0)
actionpack (>= 5, < 7) actionpack (>= 5, < 7)
redis-rack (>= 2.1.0, < 3) redis-rack (>= 2.1.0, < 3)
@ -501,7 +500,7 @@ GEM
redis-store (>= 1.2, < 2) redis-store (>= 1.2, < 2)
redis-store (1.9.0) redis-store (1.9.0)
redis (>= 4, < 5) redis (>= 4, < 5)
regexp_parser (1.7.1) regexp_parser (1.8.0)
request_store (1.5.0) request_store (1.5.0)
rack (>= 1.4) rack (>= 1.4)
responders (3.0.1) responders (3.0.1)
@ -536,18 +535,18 @@ GEM
rspec-support (3.9.3) rspec-support (3.9.3)
rspec_junit_formatter (0.4.1) rspec_junit_formatter (0.4.1)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (0.90.0) rubocop (0.91.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 2.7.1.1) parser (>= 2.7.1.1)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.7) regexp_parser (>= 1.7)
rexml rexml
rubocop-ast (>= 0.3.0, < 1.0) rubocop-ast (>= 0.4.0, < 1.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 2.0) unicode-display_width (>= 1.4.0, < 2.0)
rubocop-ast (0.3.0) rubocop-ast (0.4.2)
parser (>= 2.7.1.4) parser (>= 2.7.1.4)
rubocop-rails (2.8.0) rubocop-rails (2.8.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 0.87.0) rubocop (>= 0.87.0)
@ -556,7 +555,6 @@ GEM
nokogiri (>= 1.5.10) nokogiri (>= 1.5.10)
rufus-scheduler (3.6.0) rufus-scheduler (3.6.0)
fugit (~> 1.1, >= 1.1.6) fugit (~> 1.1, >= 1.1.6)
safe_yaml (1.0.5)
safety_net_attestation (0.4.0) safety_net_attestation (0.4.0)
jwt (~> 2.0) jwt (~> 2.0)
sanitize (5.2.1) sanitize (5.2.1)
@ -565,7 +563,7 @@ GEM
nokogumbo (~> 2.0) nokogumbo (~> 2.0)
securecompare (1.0.0) securecompare (1.0.0)
semantic_range (2.3.0) semantic_range (2.3.0)
sidekiq (6.1.1) sidekiq (6.1.2)
connection_pool (>= 2.2.2) connection_pool (>= 2.2.2)
rack (~> 2.0) rack (~> 2.0)
redis (>= 4.2.0) redis (>= 4.2.0)
@ -594,7 +592,7 @@ GEM
sprockets (3.7.2) sprockets (3.7.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (> 1, < 3) rack (> 1, < 3)
sprockets-rails (3.2.1) sprockets-rails (3.2.2)
actionpack (>= 4.0) actionpack (>= 4.0)
activesupport (>= 4.0) activesupport (>= 4.0)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
@ -613,7 +611,7 @@ GEM
unicode-display_width (~> 1.1, >= 1.1.1) unicode-display_width (~> 1.1, >= 1.1.1)
terrapin (0.6.0) terrapin (0.6.0)
climate_control (>= 0.0.3, < 1.0) climate_control (>= 0.0.3, < 1.0)
thor (0.20.3) thor (1.0.1)
thread_safe (0.3.6) thread_safe (0.3.6)
thwait (0.2.0) thwait (0.2.0)
e2mmap e2mmap
@ -654,7 +652,7 @@ GEM
safety_net_attestation (~> 0.4.0) safety_net_attestation (~> 0.4.0)
securecompare (~> 1.0) securecompare (~> 1.0)
tpm-key_attestation (~> 0.9.0) tpm-key_attestation (~> 0.9.0)
webmock (3.8.3) webmock (3.9.1)
addressable (>= 2.3.6) addressable (>= 2.3.6)
crack (>= 0.3.2) crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0) hashdiff (>= 0.4.0, < 2.0.0)
@ -681,8 +679,8 @@ DEPENDENCIES
active_record_query_trace (~> 1.7) active_record_query_trace (~> 1.7)
addressable (~> 2.7) addressable (~> 2.7)
annotate (~> 3.1) annotate (~> 3.1)
aws-sdk-s3 (~> 1.79) aws-sdk-s3 (~> 1.81)
better_errors (~> 2.7) better_errors (~> 2.8)
binding_of_caller (~> 0.7) binding_of_caller (~> 0.7)
blurhash (~> 0.1) blurhash (~> 0.1)
bootsnap (~> 1.4) bootsnap (~> 1.4)
@ -711,7 +709,7 @@ DEPENDENCIES
e2mmap (~> 0.1.0) e2mmap (~> 0.1.0)
ed25519 (~> 1.2) ed25519 (~> 1.2)
fabrication (~> 2.21) fabrication (~> 2.21)
faker (~> 2.13) faker (~> 2.14)
fast_blank (~> 1.0) fast_blank (~> 1.0)
fastimage fastimage
fog-core (<= 2.1.0) fog-core (<= 2.1.0)
@ -752,7 +750,7 @@ DEPENDENCIES
paperclip (~> 6.0) paperclip (~> 6.0)
paperclip-av-transcoder (~> 0.6) paperclip-av-transcoder (~> 0.6)
parallel (~> 1.19) parallel (~> 1.19)
parallel_tests (~> 3.2) parallel_tests (~> 3.3)
parslet parslet
pg (~> 1.2) pg (~> 1.2)
pghero (~> 2.7) pghero (~> 2.7)
@ -762,12 +760,12 @@ DEPENDENCIES
private_address_check (~> 0.5) private_address_check (~> 0.5)
pry-byebug (~> 3.9) pry-byebug (~> 3.9)
pry-rails (~> 0.3) pry-rails (~> 0.3)
puma (~> 4.3) puma (~> 5.0)
pundit (~> 2.1) pundit (~> 2.1)
rack (~> 2.2.3) rack (~> 2.2.3)
rack-attack (~> 6.3) rack-attack (~> 6.3)
rack-cors (~> 1.1) rack-cors (~> 1.1)
rails (~> 5.2.4.3) rails (~> 5.2.4.4)
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 5.1) rails-i18n (~> 5.1)
rails-settings-cached (~> 0.6) rails-settings-cached (~> 0.6)
@ -780,7 +778,7 @@ DEPENDENCIES
rspec-rails (~> 4.0) rspec-rails (~> 4.0)
rspec-sidekiq (~> 3.1) rspec-sidekiq (~> 3.1)
rspec_junit_formatter (~> 0.4) rspec_junit_formatter (~> 0.4)
rubocop (~> 0.90) rubocop (~> 0.91)
rubocop-rails (~> 2.8) rubocop-rails (~> 2.8)
ruby-progressbar (~> 1.10) ruby-progressbar (~> 1.10)
sanitize (~> 5.2) sanitize (~> 5.2)
@ -797,12 +795,12 @@ DEPENDENCIES
stoplight (~> 2.2.1) stoplight (~> 2.2.1)
streamio-ffmpeg (~> 3.0) streamio-ffmpeg (~> 3.0)
strong_migrations (~> 0.7) strong_migrations (~> 0.7)
thor (~> 0.20) thor (~> 1.0)
thwait (~> 0.2.0) thwait (~> 0.2.0)
tty-prompt (~> 0.22) tty-prompt (~> 0.22)
twitter-text (~> 1.14) twitter-text (~> 1.14)
tzinfo-data (~> 1.2020) tzinfo-data (~> 1.2020)
webauthn (~> 3.0.0.alpha1) webauthn (~> 3.0.0.alpha1)
webmock (~> 3.8) webmock (~> 3.9)
webpacker (~> 5.2) webpacker (~> 5.2)
webpush webpush

View File

@ -7,6 +7,7 @@ class AccountsController < ApplicationController
include AccountControllerConcern include AccountControllerConcern
include SignatureAuthentication include SignatureAuthentication
before_action :require_signature!, if: -> { request.format == :json && authorized_fetch_mode? }
before_action :set_cache_headers before_action :set_cache_headers
before_action :set_body_classes before_action :set_body_classes
@ -49,7 +50,7 @@ class AccountsController < ApplicationController
format.json do format.json do
expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?) expires_in 3.minutes, public: !(authorized_fetch_mode? && signed_request_account.present?)
render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter, fields: restrict_fields_to render_with_cache json: @account, content_type: 'application/activity+json', serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter
end end
end end
end end
@ -154,12 +155,4 @@ class AccountsController < ApplicationController
def params_slice(*keys) def params_slice(*keys)
params.slice(*keys).permit(*keys) params.slice(*keys).permit(*keys)
end end
def restrict_fields_to
if signed_request_account.present? || public_fetch_mode?
# Return all fields
else
%i(id type preferred_username inbox public_key endpoints)
end
end
end end

View File

@ -57,9 +57,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
def set_statuses def set_statuses
return unless page_requested? return unless page_requested?
@statuses = @account.statuses.permitted_for(@account, signed_request_account)
@statuses = cache_collection_paginated_by_id( @statuses = cache_collection_paginated_by_id(
@statuses, @account.statuses.permitted_for(@account, signed_request_account),
Status, Status,
LIMIT, LIMIT,
params_slice(:max_id, :min_id, :since_id) params_slice(:max_id, :min_id, :since_id)

View File

@ -2,7 +2,7 @@
module Admin module Admin
class AccountsController < BaseController class AccountsController < BaseController
before_action :set_account, only: [:show, :redownload, :remove_avatar, :remove_header, :enable, :unsilence, :unsuspend, :memorialize, :approve, :reject] before_action :set_account, except: [:index]
before_action :require_remote_account!, only: [:redownload] before_action :require_remote_account!, only: [:redownload]
before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject] before_action :require_local_account!, only: [:enable, :memorialize, :approve, :reject]
@ -14,49 +14,58 @@ module Admin
def show def show
authorize @account, :show? authorize @account, :show?
@deletion_request = @account.deletion_request
@account_moderation_note = current_account.account_moderation_notes.new(target_account: @account) @account_moderation_note = current_account.account_moderation_notes.new(target_account: @account)
@moderation_notes = @account.targeted_moderation_notes.latest @moderation_notes = @account.targeted_moderation_notes.latest
@warnings = @account.targeted_account_warnings.latest.custom @warnings = @account.targeted_account_warnings.latest.custom
@domain_block = DomainBlock.rule_for(@account.domain)
end end
def memorialize def memorialize
authorize @account, :memorialize? authorize @account, :memorialize?
@account.memorialize! @account.memorialize!
log_action :memorialize, @account log_action :memorialize, @account
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.memorialized_msg', username: @account.acct)
end end
def enable def enable
authorize @account.user, :enable? authorize @account.user, :enable?
@account.user.enable! @account.user.enable!
log_action :enable, @account.user log_action :enable, @account.user
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.enabled_msg', username: @account.acct)
end end
def approve def approve
authorize @account.user, :approve? authorize @account.user, :approve?
@account.user.approve! @account.user.approve!
redirect_to admin_pending_accounts_path redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.approved_msg', username: @account.acct)
end end
def reject def reject
authorize @account.user, :reject? authorize @account.user, :reject?
SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
redirect_to admin_pending_accounts_path redirect_to admin_pending_accounts_path, notice: I18n.t('admin.accounts.rejected_msg', username: @account.acct)
end
def destroy
authorize @account, :destroy?
Admin::AccountDeletionWorker.perform_async(@account.id)
redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.destroyed_msg', username: @account.acct)
end end
def unsilence def unsilence
authorize @account, :unsilence? authorize @account, :unsilence?
@account.unsilence! @account.unsilence!
log_action :unsilence, @account log_action :unsilence, @account
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsilenced_msg', username: @account.acct)
end end
def unsuspend def unsuspend
authorize @account, :unsuspend? authorize @account, :unsuspend?
@account.unsuspend! @account.unsuspend!
Admin::UnsuspensionWorker.perform_async(@account.id)
log_action :unsuspend, @account log_action :unsuspend, @account
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.unsuspended_msg', username: @account.acct)
end end
def redownload def redownload
@ -65,7 +74,7 @@ module Admin
@account.update!(last_webfingered_at: nil) @account.update!(last_webfingered_at: nil)
ResolveAccountService.new.call(@account) ResolveAccountService.new.call(@account)
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.redownloaded_msg', username: @account.acct)
end end
def remove_avatar def remove_avatar
@ -76,7 +85,7 @@ module Admin
log_action :remove_avatar, @account.user log_action :remove_avatar, @account.user
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_avatar_msg', username: @account.acct)
end end
def remove_header def remove_header
@ -87,7 +96,7 @@ module Admin
log_action :remove_header, @account.user log_action :remove_header, @account.user
redirect_to admin_account_path(@account.id) redirect_to admin_account_path(@account.id), notice: I18n.t('admin.accounts.removed_header_msg', username: @account.acct)
end end
private private

View File

@ -96,12 +96,12 @@ class Api::BaseController < ApplicationController
def require_user! def require_user!
if !current_user if !current_user
render json: { error: 'This method requires an authenticated user' }, status: 422 render json: { error: 'This method requires an authenticated user' }, status: 422
elsif current_user.disabled?
render json: { error: 'Your login is currently disabled' }, status: 403
elsif !current_user.confirmed? elsif !current_user.confirmed?
render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403 render json: { error: 'Your login is missing a confirmed e-mail address' }, status: 403
elsif !current_user.approved? elsif !current_user.approved?
render json: { error: 'Your login is currently pending approval' }, status: 403 render json: { error: 'Your login is currently pending approval' }, status: 403
elsif !current_user.functional?
render json: { error: 'Your login is currently disabled' }, status: 403
else else
set_user_activity set_user_activity
end end

View File

@ -17,6 +17,6 @@ class Api::V1::Accounts::FeaturedTagsController < Api::BaseController
end end
def set_featured_tags def set_featured_tags
@featured_tags = @account.featured_tags @featured_tags = @account.suspended? ? @account.featured_tags : []
end end
end end

View File

@ -25,7 +25,7 @@ class Api::V1::Accounts::FollowerAccountsController < Api::BaseController
end end
def hide_results? def hide_results?
(@account.hides_followers? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) @account.suspended? || (@account.hides_followers? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account))
end end
def default_accounts def default_accounts

View File

@ -25,7 +25,7 @@ class Api::V1::Accounts::FollowingAccountsController < Api::BaseController
end end
def hide_results? def hide_results?
(@account.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account)) @account.suspended? || (@account.hides_following? && current_account&.id != @account.id) || (current_account && @account.blocking?(current_account))
end end
def default_accounts def default_accounts

View File

@ -5,7 +5,7 @@ class Api::V1::Accounts::IdentityProofsController < Api::BaseController
before_action :set_account before_action :set_account
def index def index
@proofs = @account.identity_proofs.active @proofs = @account.suspended? ? [] : @account.identity_proofs.active
render json: @proofs, each_serializer: REST::IdentityProofSerializer render json: @proofs, each_serializer: REST::IdentityProofSerializer
end end

View File

@ -6,7 +6,7 @@ class Api::V1::Accounts::ListsController < Api::BaseController
before_action :set_account before_action :set_account
def index def index
@lists = @account.lists.where(account: current_account) @lists = @account.suspended? ? [] : @account.lists.where(account: current_account)
render json: @lists, each_serializer: REST::ListSerializer render json: @lists, each_serializer: REST::ListSerializer
end end

View File

@ -18,7 +18,7 @@ class Api::V1::Accounts::StatusesController < Api::BaseController
end end
def load_statuses def load_statuses
cached_account_statuses @account.suspended? ? [] : cached_account_statuses
end end
def cached_account_statuses def cached_account_statuses

View File

@ -9,7 +9,6 @@ class Api::V1::AccountsController < Api::BaseController
before_action :require_user!, except: [:show, :create] before_action :require_user!, except: [:show, :create]
before_action :set_account, except: [:create] before_action :set_account, except: [:create]
before_action :check_account_suspension, only: [:show]
before_action :check_enabled_registrations, only: [:create] before_action :check_enabled_registrations, only: [:create]
skip_before_action :require_authenticated_user!, only: :create skip_before_action :require_authenticated_user!, only: :create
@ -31,9 +30,8 @@ class Api::V1::AccountsController < Api::BaseController
end end
def follow def follow
FollowService.new.call(current_user.account, @account, reblogs: truthy_param?(:reblogs), with_rate_limit: true) follow = FollowService.new.call(current_user.account, @account, reblogs: params.key?(:reblogs) ? truthy_param?(:reblogs) : nil, notify: params.key?(:notify) ? truthy_param?(:notify) : nil, with_rate_limit: true)
options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: follow.show_reblogs?, notify: follow.notify? } }, requested_map: { @account.id => false } }
options = @account.locked? || current_user.account.silenced? ? {} : { following_map: { @account.id => { reblogs: truthy_param?(:reblogs) } }, requested_map: { @account.id => false } }
render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options) render json: @account, serializer: REST::RelationshipSerializer, relationships: relationships(options)
end end
@ -73,10 +71,6 @@ class Api::V1::AccountsController < Api::BaseController
AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options) AccountRelationshipsPresenter.new([@account.id], current_user.account_id, options)
end end
def check_account_suspension
gone if @account.suspended?
end
def account_params def account_params
params.permit(:username, :email, :password, :agreement, :locale, :reason) params.permit(:username, :email, :password, :agreement, :locale, :reason)
end end

View File

@ -58,7 +58,13 @@ class Api::V1::Admin::AccountsController < Api::BaseController
def reject def reject
authorize @account.user, :reject? authorize @account.user, :reject?
SuspendAccountService.new.call(@account, reserve_email: false, reserve_username: false) DeleteAccountService.new.call(@account, reserve_email: false, reserve_username: false)
render json: @account, serializer: REST::Admin::AccountSerializer
end
def destroy
authorize @account, :destroy?
Admin::AccountDeletionWorker.perform_async(@account.id)
render json: @account, serializer: REST::Admin::AccountSerializer render json: @account, serializer: REST::Admin::AccountSerializer
end end
@ -72,6 +78,7 @@ class Api::V1::Admin::AccountsController < Api::BaseController
def unsuspend def unsuspend
authorize @account, :unsuspend? authorize @account, :unsuspend?
@account.unsuspend! @account.unsuspend!
Admin::UnsuspensionWorker.perform_async(@account.id)
log_action :unsuspend, @account log_action :unsuspend, @account
render json: @account, serializer: REST::Admin::AccountSerializer render json: @account, serializer: REST::Admin::AccountSerializer
end end

View File

@ -18,6 +18,8 @@ class Api::V1::BlocksController < Api::BaseController
def paginated_blocks def paginated_blocks
@paginated_blocks ||= Block.eager_load(target_account: :account_stat) @paginated_blocks ||= Block.eager_load(target_account: :account_stat)
.joins(:target_account)
.merge(Account.without_suspended)
.where(account: current_account) .where(account: current_account)
.paginate_by_max_id( .paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT), limit_param(DEFAULT_ACCOUNTS_LIMIT),

View File

@ -25,7 +25,7 @@ class Api::V1::EndorsementsController < Api::BaseController
end end
def endorsed_accounts def endorsed_accounts
current_account.endorsed_accounts.includes(:account_stat) current_account.endorsed_accounts.includes(:account_stat).without_suspended
end end
def insert_pagination_headers def insert_pagination_headers

View File

@ -13,7 +13,7 @@ class Api::V1::FollowRequestsController < Api::BaseController
def authorize def authorize
AuthorizeFollowService.new.call(account, current_account) AuthorizeFollowService.new.call(account, current_account)
NotifyService.new.call(current_account, Follow.find_by(account: account, target_account: current_account)) NotifyService.new.call(current_account, :follow, Follow.find_by(account: account, target_account: current_account))
render json: account, serializer: REST::RelationshipSerializer, relationships: relationships render json: account, serializer: REST::RelationshipSerializer, relationships: relationships
end end
@ -37,7 +37,7 @@ class Api::V1::FollowRequestsController < Api::BaseController
end end
def default_accounts def default_accounts
Account.includes(:follow_requests, :account_stat).references(:follow_requests) Account.without_suspended.includes(:follow_requests, :account_stat).references(:follow_requests)
end end
def paginated_follow_requests def paginated_follow_requests

View File

@ -37,9 +37,9 @@ class Api::V1::Lists::AccountsController < Api::BaseController
def load_accounts def load_accounts
if unlimited? if unlimited?
@list.accounts.includes(:account_stat).all @list.accounts.without_suspended.includes(:account_stat).all
else else
@list.accounts.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id]) @list.accounts.without_suspended.includes(:account_stat).paginate_by_max_id(limit_param(DEFAULT_ACCOUNTS_LIMIT), params[:max_id], params[:since_id])
end end
end end

View File

@ -27,6 +27,8 @@ class Api::V1::MutesController < Api::BaseController
def paginated_mutes def paginated_mutes
@paginated_mutes ||= Mute.eager_load(:target_account) @paginated_mutes ||= Mute.eager_load(:target_account)
.joins(:target_account)
.merge(Account.without_suspended)
.where(account: current_account) .where(account: current_account)
.paginate_by_max_id( .paginate_by_max_id(
limit_param(DEFAULT_ACCOUNTS_LIMIT), limit_param(DEFAULT_ACCOUNTS_LIMIT),

View File

@ -14,7 +14,7 @@ class Api::V1::NotificationsController < Api::BaseController
end end
def show def show
@notification = current_account.notifications.find(params[:id]) @notification = current_account.notifications.without_suspended.find(params[:id])
render json: @notification, serializer: REST::NotificationSerializer render json: @notification, serializer: REST::NotificationSerializer
end end
@ -49,7 +49,7 @@ class Api::V1::NotificationsController < Api::BaseController
end end
def browserable_account_notifications def browserable_account_notifications
current_account.notifications.browserable(exclude_types, from_account) current_account.notifications.without_suspended.browserable(exclude_types, from_account)
end end
def target_statuses_from_notifications def target_statuses_from_notifications

View File

@ -52,6 +52,6 @@ class Api::V1::Push::SubscriptionsController < Api::BaseController
def data_params def data_params
return {} if params[:data].blank? return {} if params[:data].blank?
params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
end end
end end

View File

@ -22,6 +22,7 @@ class Api::V1::Statuses::FavouritedByAccountsController < Api::BaseController
def default_accounts def default_accounts
Account Account
.without_suspended
.includes(:favourites, :account_stat) .includes(:favourites, :account_stat)
.references(:favourites) .references(:favourites)
.where(favourites: { status_id: @status.id }) .where(favourites: { status_id: @status.id })

View File

@ -21,7 +21,7 @@ class Api::V1::Statuses::RebloggedByAccountsController < Api::BaseController
end end
def default_accounts def default_accounts
Account.includes(:statuses, :account_stat).references(:statuses) Account.without_suspended.includes(:statuses, :account_stat).references(:statuses)
end end
def paginated_statuses def paginated_statuses

View File

@ -22,6 +22,7 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
reblog: alerts_enabled, reblog: alerts_enabled,
mention: alerts_enabled, mention: alerts_enabled,
poll: alerts_enabled, poll: alerts_enabled,
status: alerts_enabled,
}, },
} }
@ -57,6 +58,6 @@ class Api::Web::PushSubscriptionsController < Api::Web::BaseController
end end
def data_params def data_params
@data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll]) @data_params ||= params.require(:data).permit(alerts: [:follow, :follow_request, :favourite, :reblog, :mention, :poll, :status])
end end
end end

View File

@ -5,7 +5,6 @@ module ExportControllerConcern
included do included do
before_action :authenticate_user! before_action :authenticate_user!
before_action :require_not_suspended!
before_action :load_export before_action :load_export
skip_before_action :require_functional! skip_before_action :require_functional!
@ -30,8 +29,4 @@ module ExportControllerConcern
def export_filename def export_filename
"#{controller_name}.csv" "#{controller_name}.csv"
end end
def require_not_suspended!
forbidden if current_account.suspended?
end
end end

View File

@ -6,6 +6,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
before_action :store_current_location before_action :store_current_location
before_action :authenticate_resource_owner! before_action :authenticate_resource_owner!
before_action :set_pack before_action :set_pack
before_action :require_not_suspended!, only: :destroy
before_action :set_body_classes before_action :set_body_classes
skip_before_action :require_functional! skip_before_action :require_functional!
@ -30,4 +31,8 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
def set_pack def set_pack
use_pack 'settings' use_pack 'settings'
end end
def require_not_suspended!
forbidden if current_account.suspended?
end
end end

View File

@ -1,9 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::AliasesController < Settings::BaseController class Settings::AliasesController < Settings::BaseController
layout 'admin' skip_before_action :require_functional!
before_action :authenticate_user! before_action :require_not_suspended!
before_action :set_aliases, except: :destroy before_action :set_aliases, except: :destroy
before_action :set_alias, only: :destroy before_action :set_alias, only: :destroy

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::ApplicationsController < Settings::BaseController class Settings::ApplicationsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :set_application, only: [:show, :update, :destroy, :regenerate] before_action :set_application, only: [:show, :update, :destroy, :regenerate]
before_action :prepare_scopes, only: [:create, :update] before_action :prepare_scopes, only: [:create, :update]

View File

@ -2,6 +2,9 @@
class Settings::BaseController < ApplicationController class Settings::BaseController < ApplicationController
before_action :set_pack before_action :set_pack
layout 'admin'
before_action :authenticate_user!
before_action :set_body_classes before_action :set_body_classes
before_action :set_cache_headers before_action :set_cache_headers
@ -18,4 +21,8 @@ class Settings::BaseController < ApplicationController
def set_cache_headers def set_cache_headers
response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate' response.headers['Cache-Control'] = 'no-cache, no-store, max-age=0, must-revalidate'
end end
def require_not_suspended!
forbidden if current_account.suspended?
end
end end

View File

@ -1,14 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::DeletesController < Settings::BaseController class Settings::DeletesController < Settings::BaseController
layout 'admin'
before_action :check_enabled_deletion
before_action :authenticate_user!
before_action :require_not_suspended!
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_not_suspended!
before_action :check_enabled_deletion
def show def show
@confirmation = Form::DeleteConfirmation.new @confirmation = Form::DeleteConfirmation.new
end end
@ -46,7 +43,7 @@ class Settings::DeletesController < Settings::BaseController
def destroy_account! def destroy_account!
current_account.suspend! current_account.suspend!
Admin::SuspensionWorker.perform_async(current_user.account_id, true) AccountDeletionWorker.perform_async(current_user.account_id)
sign_out sign_out
end end
end end

View File

@ -2,7 +2,7 @@
module Settings module Settings
module Exports module Exports
class BlockedAccountsController < ApplicationController class BlockedAccountsController < BaseController
include ExportControllerConcern include ExportControllerConcern
def index def index

View File

@ -2,7 +2,7 @@
module Settings module Settings
module Exports module Exports
class BlockedDomainsController < ApplicationController class BlockedDomainsController < BaseController
include ExportControllerConcern include ExportControllerConcern
def index def index

View File

@ -2,7 +2,7 @@
module Settings module Settings
module Exports module Exports
class FollowingAccountsController < ApplicationController class FollowingAccountsController < BaseController
include ExportControllerConcern include ExportControllerConcern
def index def index

View File

@ -2,7 +2,7 @@
module Settings module Settings
module Exports module Exports
class ListsController < ApplicationController class ListsController < BaseController
include ExportControllerConcern include ExportControllerConcern
def index def index

View File

@ -2,7 +2,7 @@
module Settings module Settings
module Exports module Exports
class MutedAccountsController < ApplicationController class MutedAccountsController < BaseController
include ExportControllerConcern include ExportControllerConcern
def index def index

View File

@ -3,11 +3,6 @@
class Settings::ExportsController < Settings::BaseController class Settings::ExportsController < Settings::BaseController
include Authorization include Authorization
layout 'admin'
before_action :authenticate_user!
before_action :require_not_suspended!
skip_before_action :require_functional! skip_before_action :require_functional!
def show def show
@ -16,8 +11,6 @@ class Settings::ExportsController < Settings::BaseController
end end
def create def create
raise Mastodon::NotPermittedError unless user_signed_in?
backup = nil backup = nil
RedisLock.acquire(lock_options) do |lock| RedisLock.acquire(lock_options) do |lock|
@ -37,8 +30,4 @@ class Settings::ExportsController < Settings::BaseController
def lock_options def lock_options
{ redis: Redis.current, key: "backup:#{current_user.id}" } { redis: Redis.current, key: "backup:#{current_user.id}" }
end end
def require_not_suspended!
forbidden if current_account.suspended?
end
end end

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::FeaturedTagsController < Settings::BaseController class Settings::FeaturedTagsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :set_featured_tags, only: :index before_action :set_featured_tags, only: :index
before_action :set_featured_tag, except: [:index, :create] before_action :set_featured_tag, except: [:index, :create]
before_action :set_recently_used_tags, only: :index before_action :set_recently_used_tags, only: :index

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::IdentityProofsController < Settings::BaseController class Settings::IdentityProofsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :check_required_params, only: :new before_action :check_required_params, only: :new
before_action :check_enabled, only: :new before_action :check_enabled, only: :new

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::ImportsController < Settings::BaseController class Settings::ImportsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :set_account before_action :set_account
def show def show

View File

@ -1,13 +1,10 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::Migration::RedirectsController < Settings::BaseController class Settings::Migration::RedirectsController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :require_not_suspended!
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_not_suspended!
def new def new
@redirect = Form::Redirect.new @redirect = Form::Redirect.new
end end
@ -38,8 +35,4 @@ class Settings::Migration::RedirectsController < Settings::BaseController
def resource_params def resource_params
params.require(:form_redirect).permit(:acct, :current_password, :current_username) params.require(:form_redirect).permit(:acct, :current_password, :current_username)
end end
def require_not_suspended!
forbidden if current_account.suspended?
end
end end

View File

@ -1,15 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::MigrationsController < Settings::BaseController class Settings::MigrationsController < Settings::BaseController
layout 'admin' skip_before_action :require_functional!
before_action :authenticate_user!
before_action :require_not_suspended! before_action :require_not_suspended!
before_action :set_migrations before_action :set_migrations
before_action :set_cooldown before_action :set_cooldown
skip_before_action :require_functional!
def show def show
@migration = current_account.migrations.build @migration = current_account.migrations.build
end end
@ -44,8 +41,4 @@ class Settings::MigrationsController < Settings::BaseController
def on_cooldown? def on_cooldown?
@cooldown.present? @cooldown.present?
end end
def require_not_suspended!
forbidden if current_account.suspended?
end
end end

View File

@ -2,7 +2,6 @@
module Settings module Settings
class PicturesController < BaseController class PicturesController < BaseController
before_action :authenticate_user!
before_action :set_account before_action :set_account
before_action :set_picture before_action :set_picture

View File

@ -1,10 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::PreferencesController < Settings::BaseController class Settings::PreferencesController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
def show; end def show; end
def update def update

View File

@ -1,9 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::ProfilesController < Settings::BaseController class Settings::ProfilesController < Settings::BaseController
layout 'admin'
before_action :authenticate_user!
before_action :set_account before_action :set_account
def show def show

View File

@ -1,12 +1,11 @@
# frozen_string_literal: true # frozen_string_literal: true
# Intentionally does not inherit from BaseController class Settings::SessionsController < Settings::BaseController
class Settings::SessionsController < ApplicationController
before_action :authenticate_user!
before_action :set_session, only: :destroy
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_not_suspended!
before_action :set_session, only: :destroy
def destroy def destroy
@session.destroy! @session.destroy!
flash[:notice] = I18n.t('sessions.revoke_success') flash[:notice] = I18n.t('sessions.revoke_success')

View File

@ -5,14 +5,11 @@ module Settings
class ConfirmationsController < BaseController class ConfirmationsController < BaseController
include ChallengableConcern include ChallengableConcern
layout 'admin' skip_before_action :require_functional!
before_action :authenticate_user!
before_action :require_challenge! before_action :require_challenge!
before_action :ensure_otp_secret before_action :ensure_otp_secret
skip_before_action :require_functional!
def new def new
prepare_two_factor_form prepare_two_factor_form
end end

View File

@ -5,14 +5,11 @@ module Settings
class OtpAuthenticationController < BaseController class OtpAuthenticationController < BaseController
include ChallengableConcern include ChallengableConcern
layout 'admin' skip_before_action :require_functional!
before_action :authenticate_user!
before_action :verify_otp_not_enabled, only: [:show] before_action :verify_otp_not_enabled, only: [:show]
before_action :require_challenge!, only: [:create] before_action :require_challenge!, only: [:create]
skip_before_action :require_functional!
def show def show
@confirmation = Form::TwoFactorConfirmation.new @confirmation = Form::TwoFactorConfirmation.new
end end

View File

@ -5,13 +5,10 @@ module Settings
class RecoveryCodesController < BaseController class RecoveryCodesController < BaseController
include ChallengableConcern include ChallengableConcern
layout 'admin'
before_action :authenticate_user!
before_action :require_challenge!, on: :create
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_challenge!, on: :create
def create def create
@recovery_codes = current_user.generate_otp_backup_codes! @recovery_codes = current_user.generate_otp_backup_codes!
current_user.save! current_user.save!

View File

@ -3,9 +3,8 @@
module Settings module Settings
module TwoFactorAuthentication module TwoFactorAuthentication
class WebauthnCredentialsController < BaseController class WebauthnCredentialsController < BaseController
layout 'admin' skip_before_action :require_functional!
before_action :authenticate_user!
before_action :require_otp_enabled before_action :require_otp_enabled
before_action :require_webauthn_enabled, only: [:index, :destroy] before_action :require_webauthn_enabled, only: [:index, :destroy]

View File

@ -4,14 +4,11 @@ module Settings
class TwoFactorAuthenticationMethodsController < BaseController class TwoFactorAuthenticationMethodsController < BaseController
include ChallengableConcern include ChallengableConcern
layout 'admin' skip_before_action :require_functional!
before_action :authenticate_user!
before_action :require_challenge!, only: :disable before_action :require_challenge!, only: :disable
before_action :require_otp_enabled before_action :require_otp_enabled
skip_before_action :require_functional!
def index; end def index; end
def disable def disable

View File

@ -126,15 +126,17 @@ export function fetchAccountFail(id, error) {
}; };
}; };
export function followAccount(id, reblogs = true) { export function followAccount(id, options = { reblogs: true }) {
return (dispatch, getState) => { return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']); const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
dispatch(followAccountRequest(id)); const locked = getState().getIn(['accounts', id, 'locked'], false);
api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { dispatch(followAccountRequest(id, locked));
api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => {
dispatch(followAccountSuccess(response.data, alreadyFollowing)); dispatch(followAccountSuccess(response.data, alreadyFollowing));
}).catch(error => { }).catch(error => {
dispatch(followAccountFail(error)); dispatch(followAccountFail(error, locked));
}); });
}; };
}; };
@ -151,10 +153,12 @@ export function unfollowAccount(id) {
}; };
}; };
export function followAccountRequest(id) { export function followAccountRequest(id, locked) {
return { return {
type: ACCOUNT_FOLLOW_REQUEST, type: ACCOUNT_FOLLOW_REQUEST,
id, id,
locked,
skipLoading: true,
}; };
}; };
@ -163,13 +167,16 @@ export function followAccountSuccess(relationship, alreadyFollowing) {
type: ACCOUNT_FOLLOW_SUCCESS, type: ACCOUNT_FOLLOW_SUCCESS,
relationship, relationship,
alreadyFollowing, alreadyFollowing,
skipLoading: true,
}; };
}; };
export function followAccountFail(error) { export function followAccountFail(error, locked) {
return { return {
type: ACCOUNT_FOLLOW_FAIL, type: ACCOUNT_FOLLOW_FAIL,
error, error,
locked,
skipLoading: true,
}; };
}; };
@ -177,6 +184,7 @@ export function unfollowAccountRequest(id) {
return { return {
type: ACCOUNT_UNFOLLOW_REQUEST, type: ACCOUNT_UNFOLLOW_REQUEST,
id, id,
skipLoading: true,
}; };
}; };
@ -185,6 +193,7 @@ export function unfollowAccountSuccess(relationship, statuses) {
type: ACCOUNT_UNFOLLOW_SUCCESS, type: ACCOUNT_UNFOLLOW_SUCCESS,
relationship, relationship,
statuses, statuses,
skipLoading: true,
}; };
}; };
@ -192,6 +201,7 @@ export function unfollowAccountFail(error) {
return { return {
type: ACCOUNT_UNFOLLOW_FAIL, type: ACCOUNT_UNFOLLOW_FAIL,
error, error,
skipLoading: true,
}; };
}; };

View File

@ -60,7 +60,7 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
const _buildParams = (state) => { const _buildParams = (state) => {
const params = {}; const params = {};
const lastHomeId = state.getIn(['timelines', 'home', 'items', 0]); const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null);
const lastNotificationId = state.getIn(['notifications', 'lastReadId']); const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {

View File

@ -73,7 +73,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false; let filtered = false;
if (notification.type === 'mention') { if (['mention', 'status'].includes(notification.type)) {
const dropRegex = filters[0]; const dropRegex = filters[0];
const regex = filters[1]; const regex = filters[1];
const searchIndex = searchTextFromRawStatus(notification.status); const searchIndex = searchTextFromRawStatus(notification.status);

View File

@ -48,6 +48,8 @@ export default class ErrorBoundary extends React.PureComponent {
if (!hasError) return this.props.children; if (!hasError) return this.props.children;
const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError');
let debugInfo = ''; let debugInfo = '';
if (stackTrace) { if (stackTrace) {
debugInfo += 'Stack trace\n-----------\n\n```\n' + errorMessage + '\n' + stackTrace.toString() + '\n```'; debugInfo += 'Stack trace\n-----------\n\n```\n' + errorMessage + '\n' + stackTrace.toString() + '\n```';
@ -70,6 +72,14 @@ export default class ErrorBoundary extends React.PureComponent {
<FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' /> <FormattedMessage id='web_app_crash.content' defaultMessage='You could try any of the following:' />
</p> </p>
<ul> <ul>
{ likelyBrowserAddonIssue && (
<li>
<FormattedMessage
id='web_app_crash.disable_addons'
defaultMessage='Disable browser add-ons or built-in translation tools'
/>
</li>
) }
<li> <li>
<FormattedMessage <FormattedMessage
id='web_app_crash.report_issue' id='web_app_crash.report_issue'

View File

@ -680,6 +680,7 @@ class Status extends ImmutablePureComponent {
favourite: 'favourited', favourite: 'favourited',
reblog: 'boosted', reblog: 'boosted',
reblogged_by: 'boosted', reblogged_by: 'boosted',
status: 'posted',
}[prepend]; }[prepend];
selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`; selectorAttribs[`data-${notifKind}-by`] = `@${account.get('acct')}`;

View File

@ -64,6 +64,14 @@ export default class StatusPrepend extends React.PureComponent {
values={{ name : link }} values={{ name : link }}
/> />
); );
case 'status':
return (
<FormattedMessage
id='notification.status'
defaultMessage='{name} just posted'
values={{ name: link }}
/>
);
case 'poll': case 'poll':
if (me === account.get('id')) { if (me === account.get('id')) {
return ( return (
@ -88,12 +96,32 @@ export default class StatusPrepend extends React.PureComponent {
const { Message } = this; const { Message } = this;
const { type } = this.props; const { type } = this.props;
let iconId;
switch(type) {
case 'favourite':
iconId = 'star';
break;
case 'featured':
iconId = 'thumb-tack';
break;
case 'poll':
iconId = 'tasks';
break;
case 'reblogged_by':
iconId = 'retweet';
break;
case 'status':
iconId = 'bell';
break;
};
return !type ? null : ( return !type ? null : (
<aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}> <aside className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend' : 'notification__message'}>
<div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}> <div className={type === 'reblogged_by' || type === 'featured' ? 'status__prepend-icon-wrapper' : 'notification__favourite-icon-wrapper'}>
<Icon <Icon
className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`} className={`status__prepend-icon ${type === 'favourite' ? 'star-icon' : ''}`}
id={type === 'favourite' ? 'star' : (type === 'featured' ? 'thumb-tack' : (type === 'poll' ? 'tasks' : 'retweet'))} id={iconId}
/> />
</div> </div>
<Message /> <Message />

View File

@ -26,6 +26,19 @@ class ActionBar extends React.PureComponent {
render () { render () {
const { account, intl } = this.props; const { account, intl } = this.props;
if (account.get('suspended')) {
return (
<div>
<div className='account__disclaimer'>
<Icon id='info-circle' fixedWidth /> <FormattedMessage
id='account.suspended_disclaimer_full'
defaultMessage="This user has been suspended by a moderator."
/>
</div>
</div>
);
}
let extraInfo = ''; let extraInfo = '';
if (account.get('acct') !== account.get('username')) { if (account.get('acct') !== account.get('username')) {

View File

@ -7,6 +7,7 @@ import { autoPlayGif, me, isStaff } from 'flavours/glitch/util/initial_state';
import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links'; import { preferencesLink, profileLink, accountAdminLink } from 'flavours/glitch/util/backend_links';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'flavours/glitch/components/icon'; import Icon from 'flavours/glitch/components/icon';
import IconButton from 'flavours/glitch/components/icon_button';
import Avatar from 'flavours/glitch/components/avatar'; import Avatar from 'flavours/glitch/components/avatar';
import Button from 'flavours/glitch/components/button'; import Button from 'flavours/glitch/components/button';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
@ -34,6 +35,8 @@ const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
@ -68,8 +71,9 @@ class Header extends ImmutablePureComponent {
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired, onReblogToggle: PropTypes.func.isRequired,
onNotifyToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired,
@ -134,8 +138,11 @@ class Header extends ImmutablePureComponent {
const accountNote = account.getIn(['relationship', 'note']); const accountNote = account.getIn(['relationship', 'note']);
const suspended = account.get('suspended');
let info = []; let info = [];
let actionBtn = ''; let actionBtn = '';
let bellBtn = '';
let lockedIcon = ''; let lockedIcon = '';
let menu = []; let menu = [];
@ -166,21 +173,29 @@ class Header extends ImmutablePureComponent {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
} }
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if (account.get('moved') && !account.getIn(['relationship', 'following'])) { if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
actionBtn = ''; actionBtn = '';
} }
if (suspended && !account.getIn(['relationship', 'following'])) {
actionBtn = '';
}
if (account.get('locked')) { if (account.get('locked')) {
lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />; lockedIcon = <Icon id='lock' title={intl.formatMessage(messages.account_locked)} />;
} }
if (account.get('id') !== me) { if (account.get('id') !== me && !suspended) {
menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention }); menu.push({ text: intl.formatMessage(messages.mention, { name: account.get('username') }), action: this.props.onMention });
menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect }); menu.push({ text: intl.formatMessage(messages.direct, { name: account.get('username') }), action: this.props.onDirect });
menu.push(null); menu.push(null);
} }
if ('share' in navigator) { if ('share' in navigator && !suspended) {
menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare }); menu.push({ text: intl.formatMessage(messages.share, { name: account.get('username') }), action: this.handleShare });
menu.push(null); menu.push(null);
} }
@ -283,6 +298,7 @@ class Header extends ImmutablePureComponent {
<div className='account__header__tabs__buttons'> <div className='account__header__tabs__buttons'>
{actionBtn} {actionBtn}
{bellBtn}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div> </div>
@ -297,39 +313,41 @@ class Header extends ImmutablePureComponent {
<AccountNoteContainer account={account} /> <AccountNoteContainer account={account} />
<div className='account__header__extra'> {!suspended && (
<div className='account__header__bio'> <div className='account__header__extra'>
{ (fields.size > 0 || identity_proofs.size > 0) && ( <div className='account__header__bio'>
<div className='account__header__fields'> { (fields.size > 0 || identity_proofs.size > 0) && (
{identity_proofs.map((proof, i) => ( <div className='account__header__fields'>
<dl key={i}> {identity_proofs.map((proof, i) => (
<dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} /> <dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: proof.get('provider') }} />
<dd className='verified'> <dd className='verified'>
<a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}> <a href={proof.get('proof_url')} target='_blank' rel='noopener noreferrer'><span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(proof.get('updated_at'), dateFormatOptions) })}>
<Icon id='check' className='verified__mark' /> <Icon id='check' className='verified__mark' />
</span></a> </span></a>
<a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a> <a href={proof.get('profile_url')} target='_blank' rel='noopener noreferrer'><span dangerouslySetInnerHTML={{ __html: ' '+proof.get('provider_username') }} /></a>
</dd> </dd>
</dl> </dl>
))} ))}
{fields.map((pair, i) => ( {fields.map((pair, i) => (
<dl key={i}> <dl key={i}>
<dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} /> <dt dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} title={pair.get('name')} />
<dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}> <dd className={pair.get('verified_at') && 'verified'} title={pair.get('value_plain')}>
{pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} /> {pair.get('verified_at') && <span title={intl.formatMessage(messages.linkVerifiedOn, { date: intl.formatDate(pair.get('verified_at'), dateFormatOptions) })}><Icon id='check' className='verified__mark' /></span>} <span dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
</dd> </dd>
</dl> </dl>
))} ))}
</div> </div>
)} )}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />} {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
</div>
</div> </div>
</div> )}
</div> </div>
</div> </div>
); );
} }

View File

@ -21,6 +21,7 @@ const mapStateToProps = (state, props) => ({
attachments: getAccountGallery(state, props.params.accountId), attachments: getAccountGallery(state, props.params.accountId),
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false),
}); });
class LoadMoreMedia extends ImmutablePureComponent { class LoadMoreMedia extends ImmutablePureComponent {
@ -56,6 +57,7 @@ class AccountGallery extends ImmutablePureComponent {
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
suspended: PropTypes.bool,
}; };
state = { state = {
@ -131,7 +133,7 @@ class AccountGallery extends ImmutablePureComponent {
} }
render () { render () {
const { attachments, isLoading, hasMore, isAccount, multiColumn } = this.props; const { attachments, isLoading, hasMore, isAccount, multiColumn, suspended } = this.props;
const { width } = this.state; const { width } = this.state;
if (!isAccount) { if (!isAccount) {
@ -164,15 +166,21 @@ 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' ref={this.handleRef}> {suspended ? (
{attachments.map((attachment, index) => attachment === null ? ( <div className='empty-column-indicator'>
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
) : ( </div>
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> ) : (
))} <div role='feed' className='account-gallery__container' ref={this.handleRef}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
) : (
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
))}
{loadOlder} {loadOlder}
</div> </div>
)}
{isLoading && attachments.size === 0 && ( {isLoading && attachments.size === 0 && (
<div className='scrollable__append'> <div className='scrollable__append'>

View File

@ -56,6 +56,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onReblogToggle(this.props.account); this.props.onReblogToggle(this.props.account);
} }
handleNotifyToggle = () => {
this.props.onNotifyToggle(this.props.account);
}
handleMute = () => { handleMute = () => {
this.props.onMute(this.props.account); this.props.onMute(this.props.account);
} }
@ -107,6 +111,7 @@ export default class Header extends ImmutablePureComponent {
onMention={this.handleMention} onMention={this.handleMention}
onDirect={this.handleDirect} onDirect={this.handleDirect}
onReblogToggle={this.handleReblogToggle} onReblogToggle={this.handleReblogToggle}
onNotifyToggle={this.handleNotifyToggle}
onReport={this.handleReport} onReport={this.handleReport}
onMute={this.handleMute} onMute={this.handleMute}
onBlockDomain={this.handleBlockDomain} onBlockDomain={this.handleBlockDomain}

View File

@ -81,9 +81,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onReblogToggle (account) { onReblogToggle (account) {
if (account.getIn(['relationship', 'showing_reblogs'])) { if (account.getIn(['relationship', 'showing_reblogs'])) {
dispatch(followAccount(account.get('id'), false)); dispatch(followAccount(account.get('id'), { reblogs: false }));
} else { } else {
dispatch(followAccount(account.get('id'), true)); dispatch(followAccount(account.get('id'), { reblogs: true }));
} }
}, },
@ -95,6 +95,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onNotifyToggle (account) {
if (account.getIn(['relationship', 'notifying'])) {
dispatch(followAccount(account.get('id'), { notify: false }));
} else {
dispatch(followAccount(account.get('id'), { notify: true }));
}
},
onReport (account) { onReport (account) {
dispatch(initReport(account)); dispatch(initReport(account));
}, },

View File

@ -17,6 +17,8 @@ import { fetchAccountIdentityProofs } from '../../actions/identity_proofs';
import MissingIndicator from 'flavours/glitch/components/missing_indicator'; import MissingIndicator from 'flavours/glitch/components/missing_indicator';
import TimelineHint from 'flavours/glitch/components/timeline_hint'; import TimelineHint from 'flavours/glitch/components/timeline_hint';
const emptyList = ImmutableList();
const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => { const mapStateToProps = (state, { params: { accountId }, withReplies = false }) => {
const path = withReplies ? `${accountId}:with_replies` : accountId; const path = withReplies ? `${accountId}:with_replies` : accountId;
@ -28,6 +30,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], ImmutableList()),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
}; };
}; };
@ -51,6 +54,7 @@ class AccountTimeline extends ImmutablePureComponent {
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
withReplies: PropTypes.bool, withReplies: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool,
remote: PropTypes.bool, remote: PropTypes.bool,
remoteUrl: PropTypes.string, remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -91,7 +95,7 @@ class AccountTimeline extends ImmutablePureComponent {
} }
render () { render () {
const { statusIds, featuredStatusIds, isLoading, hasMore, isAccount, multiColumn, remote, remoteUrl } = this.props; const { statusIds, featuredStatusIds, isLoading, hasMore, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -112,7 +116,9 @@ class AccountTimeline extends ImmutablePureComponent {
let emptyMessage; let emptyMessage;
if (remote && statusIds.isEmpty()) { if (suspended) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />; emptyMessage = <RemoteHint url={remoteUrl} />;
} else { } else {
emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />; emptyMessage = <FormattedMessage id='empty_column.account_timeline' defaultMessage='No toots here!' />;
@ -129,7 +135,7 @@ class AccountTimeline extends ImmutablePureComponent {
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
scrollKey='account_timeline' scrollKey='account_timeline'
statusIds={statusIds} statusIds={suspended ? emptyList : statusIds}
featuredStatusIds={featuredStatusIds} featuredStatusIds={featuredStatusIds}
isLoading={isLoading} isLoading={isLoading}
hasMore={hasMore} hasMore={hasMore}

View File

@ -9,6 +9,7 @@ const tooltips = defineMessages({
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
}); });
export default @injectIntl export default @injectIntl
@ -87,6 +88,13 @@ class FilterBar extends React.PureComponent {
> >
<Icon id='tasks' fixedWidth /> <Icon id='tasks' fixedWidth />
</button> </button>
<button
className={selectedFilter === 'status' ? 'active' : ''}
onClick={this.onClick('status')}
title={intl.formatMessage(tooltips.statuses)}
>
<Icon id='home' fixedWidth />
</button>
<button <button
className={selectedFilter === 'follow' ? 'active' : ''} className={selectedFilter === 'follow' ? 'active' : ''}
onClick={this.onClick('follow')} onClick={this.onClick('follow')}

View File

@ -83,6 +83,28 @@ export default class Notification extends ImmutablePureComponent {
unread={this.props.unread} unread={this.props.unread}
/> />
); );
case 'status':
return (
<StatusContainer
containerId={notification.get('id')}
hidden={hidden}
id={notification.get('status')}
account={notification.get('account')}
prepend='status'
muted
notification={notification}
onMoveDown={onMoveDown}
onMoveUp={onMoveUp}
onMention={onMention}
getScrollPosition={getScrollPosition}
updateScrollBottom={updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
onUnmount={this.props.onUnmount}
withDismiss
unread={this.props.unread}
/>
);
case 'favourite': case 'favourite':
return ( return (
<StatusContainer <StatusContainer

View File

@ -48,7 +48,7 @@ const getNotifications = createSelector([
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
} }
return notifications.filter(item => item !== null && allowedType === item.get('type')); return notifications.filter(item => item === null || allowedType === item.get('type'));
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({

View File

@ -364,21 +364,6 @@ class UI extends React.Component {
} }
componentWillMount () { componentWillMount () {
if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
this.visibilityHiddenProp = 'hidden';
this.visibilityChange = 'visibilitychange';
} else if (typeof document.msHidden !== 'undefined') {
this.visibilityHiddenProp = 'msHidden';
this.visibilityChange = 'msvisibilitychange';
} else if (typeof document.webkitHidden !== 'undefined') {
this.visibilityHiddenProp = 'webkitHidden';
this.visibilityChange = 'webkitvisibilitychange';
}
if (this.visibilityChange !== undefined) {
document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false);
this.handleVisibilityChange();
}
window.addEventListener('beforeunload', this.handleBeforeUnload, false); window.addEventListener('beforeunload', this.handleBeforeUnload, false);
document.addEventListener('dragenter', this.handleDragEnter, false); document.addEventListener('dragenter', this.handleDragEnter, false);
document.addEventListener('dragover', this.handleDragOver, false); document.addEventListener('dragover', this.handleDragOver, false);
@ -402,6 +387,22 @@ class UI extends React.Component {
this.hotkeys.__mousetrap__.stopCallback = (e, element) => { this.hotkeys.__mousetrap__.stopCallback = (e, element) => {
return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey; return ['TEXTAREA', 'SELECT', 'INPUT'].includes(element.tagName) && !e.altKey;
}; };
if (typeof document.hidden !== 'undefined') { // Opera 12.10 and Firefox 18 and later support
this.visibilityHiddenProp = 'hidden';
this.visibilityChange = 'visibilitychange';
} else if (typeof document.msHidden !== 'undefined') {
this.visibilityHiddenProp = 'msHidden';
this.visibilityChange = 'msvisibilitychange';
} else if (typeof document.webkitHidden !== 'undefined') {
this.visibilityHiddenProp = 'webkitHidden';
this.visibilityChange = 'webkitvisibilitychange';
}
if (this.visibilityChange !== undefined) {
document.addEventListener(this.visibilityChange, this.handleVisibilityChange, false);
this.handleVisibilityChange();
}
} }
componentDidUpdate (prevProps) { componentDidUpdate (prevProps) {

View File

@ -1,6 +1,10 @@
import { import {
ACCOUNT_FOLLOW_SUCCESS, ACCOUNT_FOLLOW_SUCCESS,
ACCOUNT_FOLLOW_REQUEST,
ACCOUNT_FOLLOW_FAIL,
ACCOUNT_UNFOLLOW_SUCCESS, ACCOUNT_UNFOLLOW_SUCCESS,
ACCOUNT_UNFOLLOW_REQUEST,
ACCOUNT_UNFOLLOW_FAIL,
ACCOUNT_BLOCK_SUCCESS, ACCOUNT_BLOCK_SUCCESS,
ACCOUNT_UNBLOCK_SUCCESS, ACCOUNT_UNBLOCK_SUCCESS,
ACCOUNT_MUTE_SUCCESS, ACCOUNT_MUTE_SUCCESS,
@ -40,6 +44,14 @@ const initialState = ImmutableMap();
export default function relationships(state = initialState, action) { export default function relationships(state = initialState, action) {
switch(action.type) { switch(action.type) {
case ACCOUNT_FOLLOW_REQUEST:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], true);
case ACCOUNT_FOLLOW_FAIL:
return state.setIn([action.id, action.locked ? 'requested' : 'following'], false);
case ACCOUNT_UNFOLLOW_REQUEST:
return state.setIn([action.id, 'following'], false);
case ACCOUNT_UNFOLLOW_FAIL:
return state.setIn([action.id, 'following'], true);
case ACCOUNT_FOLLOW_SUCCESS: case ACCOUNT_FOLLOW_SUCCESS:
case ACCOUNT_UNFOLLOW_SUCCESS: case ACCOUNT_UNFOLLOW_SUCCESS:
case ACCOUNT_BLOCK_SUCCESS: case ACCOUNT_BLOCK_SUCCESS:

View File

@ -620,6 +620,10 @@
padding: 2px; padding: 2px;
} }
& > .icon-button {
margin-right: 8px;
}
.button { .button {
margin: 0 8px; margin: 0 8px;
} }

View File

@ -75,3 +75,12 @@
.public-layout .public-account-header__tabs__tabs .counter.active::after { .public-layout .public-account-header__tabs__tabs .counter.active::after {
border-bottom: 4px solid $ui-highlight-color; border-bottom: 4px solid $ui-highlight-color;
} }
.composer {
.composer--spoiler input,
.compose-form__autosuggest-wrapper textarea {
&::placeholder {
color: $inverted-text-color;
}
}
}

View File

@ -12,7 +12,7 @@ const emojiFilenames = (emojis) => {
}; };
// Emoji requiring extra borders depending on theme // Emoji requiring extra borders depending on theme
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞']); const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺']);
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
const emojiFilename = (filename) => { const emojiFilename = (filename) => {

View File

@ -109,14 +109,14 @@ export function fetchAccountFail(id, error) {
}; };
}; };
export function followAccount(id, reblogs = true) { export function followAccount(id, options = { reblogs: true }) {
return (dispatch, getState) => { return (dispatch, getState) => {
const alreadyFollowing = getState().getIn(['relationships', id, 'following']); const alreadyFollowing = getState().getIn(['relationships', id, 'following']);
const locked = getState().getIn(['accounts', id, 'locked'], false); const locked = getState().getIn(['accounts', id, 'locked'], false);
dispatch(followAccountRequest(id, locked)); dispatch(followAccountRequest(id, locked));
api(getState).post(`/api/v1/accounts/${id}/follow`, { reblogs }).then(response => { api(getState).post(`/api/v1/accounts/${id}/follow`, options).then(response => {
dispatch(followAccountSuccess(response.data, alreadyFollowing)); dispatch(followAccountSuccess(response.data, alreadyFollowing));
}).catch(error => { }).catch(error => {
dispatch(followAccountFail(error, locked)); dispatch(followAccountFail(error, locked));

View File

@ -3,6 +3,9 @@ import { debounce } from 'lodash';
import compareId from '../compare_id'; import compareId from '../compare_id';
import { showAlertForError } from './alerts'; import { showAlertForError } from './alerts';
export const MARKERS_FETCH_REQUEST = 'MARKERS_FETCH_REQUEST';
export const MARKERS_FETCH_SUCCESS = 'MARKERS_FETCH_SUCCESS';
export const MARKERS_FETCH_FAIL = 'MARKERS_FETCH_FAIL';
export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS'; export const MARKERS_SUBMIT_SUCCESS = 'MARKERS_SUBMIT_SUCCESS';
export const synchronouslySubmitMarkers = () => (dispatch, getState) => { export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
@ -57,8 +60,8 @@ export const synchronouslySubmitMarkers = () => (dispatch, getState) => {
const _buildParams = (state) => { const _buildParams = (state) => {
const params = {}; const params = {};
const lastHomeId = state.getIn(['timelines', 'home', 'items', 0]); const lastHomeId = state.getIn(['timelines', 'home', 'items']).find(item => item !== null);
const lastNotificationId = state.getIn(['notifications', 'items', 0, 'id']); const lastNotificationId = state.getIn(['notifications', 'lastReadId']);
if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) { if (lastHomeId && compareId(lastHomeId, state.getIn(['markers', 'home'])) > 0) {
params.home = { params.home = {
@ -100,3 +103,39 @@ export function submitMarkersSuccess({ home, notifications }) {
export function submitMarkers() { export function submitMarkers() {
return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState); return (dispatch, getState) => debouncedSubmitMarkers(dispatch, getState);
}; };
export const fetchMarkers = () => (dispatch, getState) => {
const params = { timeline: ['notifications'] };
dispatch(fetchMarkersRequest());
api(getState).get('/api/v1/markers', { params }).then(response => {
dispatch(fetchMarkersSuccess(response.data));
}).catch(error => {
dispatch(fetchMarkersFail(error));
});
};
export function fetchMarkersRequest() {
return {
type: MARKERS_FETCH_REQUEST,
skipLoading: true,
};
};
export function fetchMarkersSuccess(markers) {
return {
type: MARKERS_FETCH_SUCCESS,
markers,
skipLoading: true,
};
};
export function fetchMarkersFail(error) {
return {
type: MARKERS_FETCH_FAIL,
error,
skipLoading: true,
skipAlert: true,
};
};

View File

@ -33,6 +33,8 @@ export const NOTIFICATIONS_LOAD_PENDING = 'NOTIFICATIONS_LOAD_PENDING';
export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT'; export const NOTIFICATIONS_MOUNT = 'NOTIFICATIONS_MOUNT';
export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT'; export const NOTIFICATIONS_UNMOUNT = 'NOTIFICATIONS_UNMOUNT';
export const NOTIFICATIONS_MARK_AS_READ = 'NOTIFICATIONS_MARK_AS_READ';
defineMessages({ defineMessages({
mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' }, mention: { id: 'notification.mention', defaultMessage: '{name} mentioned you' },
group: { id: 'notifications.group', defaultMessage: '{count} notifications' }, group: { id: 'notifications.group', defaultMessage: '{count} notifications' },
@ -59,7 +61,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
let filtered = false; let filtered = false;
if (notification.type === 'mention') { if (['mention', 'status'].includes(notification.type)) {
const dropRegex = filters[0]; const dropRegex = filters[0];
const regex = filters[1]; const regex = filters[1];
const searchIndex = searchTextFromRawStatus(notification.status); const searchIndex = searchTextFromRawStatus(notification.status);

View File

@ -0,0 +1,38 @@
// @ts-check
export const PICTURE_IN_PICTURE_DEPLOY = 'PICTURE_IN_PICTURE_DEPLOY';
export const PICTURE_IN_PICTURE_REMOVE = 'PICTURE_IN_PICTURE_REMOVE';
/**
* @typedef MediaProps
* @property {string} src
* @property {boolean} muted
* @property {number} volume
* @property {number} currentTime
* @property {string} poster
* @property {string} backgroundColor
* @property {string} foregroundColor
* @property {string} accentColor
*/
/**
* @param {string} statusId
* @param {string} accountId
* @param {string} playerType
* @param {MediaProps} props
* @return {object}
*/
export const deployPictureInPicture = (statusId, accountId, playerType, props) => ({
type: PICTURE_IN_PICTURE_DEPLOY,
statusId,
accountId,
playerType,
props,
});
/*
* @return {object}
*/
export const removePictureInPicture = () => ({
type: PICTURE_IN_PICTURE_REMOVE,
});

View File

@ -5,10 +5,21 @@ import TransitionMotion from 'react-motion/lib/TransitionMotion';
import spring from 'react-motion/lib/spring'; import spring from 'react-motion/lib/spring';
import { reduceMotion } from 'mastodon/initial_state'; import { reduceMotion } from 'mastodon/initial_state';
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
export default class AnimatedNumber extends React.PureComponent { export default class AnimatedNumber extends React.PureComponent {
static propTypes = { static propTypes = {
value: PropTypes.number.isRequired, value: PropTypes.number.isRequired,
obfuscate: PropTypes.bool,
}; };
state = { state = {
@ -36,11 +47,11 @@ export default class AnimatedNumber extends React.PureComponent {
} }
render () { render () {
const { value } = this.props; const { value, obfuscate } = this.props;
const { direction } = this.state; const { direction } = this.state;
if (reduceMotion) { if (reduceMotion) {
return <FormattedNumber value={value} />; return obfuscate ? obfuscatedCount(value) : <FormattedNumber value={value} />;
} }
const styles = [{ const styles = [{
@ -54,7 +65,7 @@ export default class AnimatedNumber extends React.PureComponent {
{items => ( {items => (
<span className='animated-number'> <span className='animated-number'>
{items.map(({ key, data, style }) => ( {items.map(({ key, data, style }) => (
<span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}><FormattedNumber value={data} /></span> <span key={key} style={{ position: (direction * style.y) > 0 ? 'absolute' : 'static', transform: `translateY(${style.y * 100}%)` }}>{obfuscate ? obfuscatedCount(data) : <FormattedNumber value={data} />}</span>
))} ))}
</span> </span>
)} )}

View File

@ -66,17 +66,31 @@ export default class ErrorBoundary extends React.PureComponent {
} }
render() { render() {
const { hasError, copied } = this.state; const { hasError, copied, errorMessage } = this.state;
if (!hasError) { if (!hasError) {
return this.props.children; return this.props.children;
} }
const likelyBrowserAddonIssue = errorMessage && errorMessage.includes('NotFoundError');
return ( return (
<div className='error-boundary'> <div className='error-boundary'>
<div> <div>
<p className='error-boundary__error'><FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' /></p> <p className='error-boundary__error'>
<p><FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' /></p> { likelyBrowserAddonIssue ? (
<FormattedMessage id='error.unexpected_crash.explanation_addons' defaultMessage='This page could not be displayed correctly. This error is likely caused by a browser add-on or automatic translation tools.' />
) : (
<FormattedMessage id='error.unexpected_crash.explanation' defaultMessage='Due to a bug in our code or a browser compatibility issue, this page could not be displayed correctly.' />
)}
</p>
<p>
{ likelyBrowserAddonIssue ? (
<FormattedMessage id='error.unexpected_crash.next_steps_addons' defaultMessage='Try disabling them and refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
) : (
<FormattedMessage id='error.unexpected_crash.next_steps' defaultMessage='Try refreshing the page. If that does not help, you may still be able to use Mastodon through a different browser or native app.' />
)}
</p>
<p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p> <p className='error-boundary__footer'>Mastodon v{version} · <a href={source_url} rel='noopener noreferrer' target='_blank'><FormattedMessage id='errors.unexpected_crash.report_issue' defaultMessage='Report issue' /></a> · <button onClick={this.handleCopyStackTrace} className={copied ? 'copied' : ''}><FormattedMessage id='errors.unexpected_crash.copy_stacktrace' defaultMessage='Copy stacktrace to clipboard' /></button></p>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number';
export default class IconButton extends React.PureComponent { export default class IconButton extends React.PureComponent {
@ -24,6 +25,8 @@ export default class IconButton extends React.PureComponent {
animate: PropTypes.bool, animate: PropTypes.bool,
overlay: PropTypes.bool, overlay: PropTypes.bool,
tabIndex: PropTypes.string, tabIndex: PropTypes.string,
counter: PropTypes.number,
obfuscateCount: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -97,6 +100,8 @@ export default class IconButton extends React.PureComponent {
pressed, pressed,
tabIndex, tabIndex,
title, title,
counter,
obfuscateCount,
} = this.props; } = this.props;
const { const {
@ -113,6 +118,10 @@ export default class IconButton extends React.PureComponent {
overlayed: overlay, overlayed: overlay,
}); });
if (typeof counter !== 'undefined') {
style.width = 'auto';
}
return ( return (
<button <button
aria-label={title} aria-label={title}
@ -128,7 +137,7 @@ export default class IconButton extends React.PureComponent {
tabIndex={tabIndex} tabIndex={tabIndex}
disabled={disabled} disabled={disabled}
> >
<Icon id={icon} fixedWidth aria-hidden='true' /> <Icon id={icon} fixedWidth aria-hidden='true' /> {typeof counter !== 'undefined' && <span className='icon-button__counter'><AnimatedNumber value={counter} obfuscate={obfuscateCount} /></span>}
</button> </button>
); );
} }

View File

@ -2,10 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
import { is } from 'immutable';
// Diff these props in the "rendered" state
const updateOnPropsForRendered = ['id', 'index', 'listLength'];
// Diff these props in the "unrendered" state // Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
@ -33,9 +30,12 @@ export default class IntersectionObserverArticle extends React.Component {
// If we're going from rendered to unrendered (or vice versa) then update // If we're going from rendered to unrendered (or vice versa) then update
return true; return true;
} }
// Otherwise, diff based on props // If we are and remain hidden, diff based on props
const propsToDiff = isUnrendered ? updateOnPropsForUnrendered : updateOnPropsForRendered; if (isUnrendered) {
return !propsToDiff.every(prop => is(nextProps[prop], this.props[prop])); return !updateOnPropsForUnrendered.every(prop => nextProps[prop] === this.props[prop]);
}
// Else, assume the children have changed
return true;
} }
componentDidMount () { componentDidMount () {

View File

@ -0,0 +1,69 @@
import React from 'react';
import PropTypes from 'prop-types';
import Icon from 'mastodon/components/icon';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import { connect } from 'react-redux';
import { debounce } from 'lodash';
import { FormattedMessage } from 'react-intl';
export default @connect()
class PictureInPicturePlaceholder extends React.PureComponent {
static propTypes = {
width: PropTypes.number,
dispatch: PropTypes.func.isRequired,
};
state = {
width: this.props.width,
height: this.props.width && (this.props.width / (16/9)),
};
handleClick = () => {
const { dispatch } = this.props;
dispatch(removePictureInPicture());
}
setRef = c => {
this.node = c;
if (this.node) {
this._setDimensions();
}
}
_setDimensions () {
const width = this.node.offsetWidth;
const height = width / (16/9);
this.setState({ width, height });
}
componentDidMount () {
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentWillUnmount () {
window.removeEventListener('resize', this.handleResize);
}
handleResize = debounce(() => {
if (this.node) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
render () {
const { height } = this.state;
return (
<div ref={this.setRef} className='picture-in-picture-placeholder' style={{ height }} role='button' tabIndex='0' onClick={this.handleClick}>
<Icon id='window-restore' />
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
</div>
);
}
}

View File

@ -17,6 +17,7 @@ import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import { displayMedia } from '../initial_state'; import { displayMedia } from '../initial_state';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
// We use the component (and not the container) since we do not want // We use the component (and not the container) since we do not want
// to use the progress bar to show download progress // to use the progress bar to show download progress
@ -95,6 +96,8 @@ class Status extends ImmutablePureComponent {
cacheMediaWidth: PropTypes.func, cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number, cachedMediaWidth: PropTypes.number,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
deployPictureInPicture: PropTypes.func,
usingPiP: PropTypes.bool,
}; };
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
@ -104,6 +107,8 @@ class Status extends ImmutablePureComponent {
'account', 'account',
'muted', 'muted',
'hidden', 'hidden',
'unread',
'usingPiP',
]; ];
state = { state = {
@ -205,6 +210,13 @@ class Status extends ImmutablePureComponent {
} }
} }
handleDeployPictureInPicture = (type, mediaProps) => {
const { deployPictureInPicture } = this.props;
const status = this._properStatus();
deployPictureInPicture(status, type, mediaProps);
}
handleHotkeyReply = e => { handleHotkeyReply = e => {
e.preventDefault(); e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history); this.props.onReply(this._properStatus(), this.context.router.history);
@ -265,7 +277,7 @@ class Status extends ImmutablePureComponent {
let media = null; let media = null;
let statusAvatar, prepend, rebloggedByText; let statusAvatar, prepend, rebloggedByText;
const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey } = this.props; const { intl, hidden, featured, otherAccounts, unread, showThread, scrollKey, usingPiP } = this.props;
let { status, account, ...other } = this.props; let { status, account, ...other } = this.props;
@ -336,7 +348,9 @@ class Status extends ImmutablePureComponent {
status = status.get('reblog'); status = status.get('reblog');
} }
if (status.get('media_attachments').size > 0) { if (usingPiP) {
media = <PictureInPicturePlaceholder width={this.props.cachedMediaWidth} />;
} else if (status.get('media_attachments').size > 0) {
if (this.props.muted) { if (this.props.muted) {
media = ( media = (
<AttachmentList <AttachmentList
@ -361,6 +375,7 @@ class Status extends ImmutablePureComponent {
width={this.props.cachedMediaWidth} width={this.props.cachedMediaWidth}
height={110} height={110}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={this.handleDeployPictureInPicture}
/> />
)} )}
</Bundle> </Bundle>
@ -382,6 +397,7 @@ class Status extends ImmutablePureComponent {
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
cacheWidth={this.props.cacheMediaWidth} cacheWidth={this.props.cacheMediaWidth}
deployPictureInPicture={this.handleDeployPictureInPicture}
visible={this.state.showMedia} visible={this.state.showMedia}
onToggleVisibility={this.handleToggleMediaVisibility} onToggleVisibility={this.handleToggleMediaVisibility}
/> />
@ -438,10 +454,10 @@ class Status extends ImmutablePureComponent {
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), read: unread === false, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}> <div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef}>
{prepend} {prepend}
<div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted, read: unread === false })} data-id={status.get('id')}> <div className={classNames('status', `status-${status.get('visibility')}`, { 'status-reply': !!status.get('in_reply_to_id'), muted: this.props.muted })} data-id={status.get('id')}>
<div className='status__expand' onClick={this.handleExpandClick} role='presentation' /> <div className='status__expand' onClick={this.handleExpandClick} role='presentation' />
<div className='status__info'> <div className='status__info'>
<a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a> <a href={status.get('url')} className='status__relative-time' target='_blank' rel='noopener noreferrer'><RelativeTimestamp timestamp={status.get('created_at')} /></a>

View File

@ -43,16 +43,6 @@ const messages = defineMessages({
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
}); });
const obfuscatedCount = count => {
if (count < 0) {
return 0;
} else if (count <= 1) {
return count;
} else {
return '1+';
}
};
const mapStateToProps = (state, { status }) => ({ const mapStateToProps = (state, { status }) => ({
relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]), relationship: state.getIn(['relationships', status.getIn(['account', 'id'])]),
}); });
@ -329,9 +319,10 @@ class StatusActionBar extends ImmutablePureComponent {
return ( return (
<div className='status__action-bar'> <div className='status__action-bar'>
<div className='status__action-bar__counter'><IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} /><span className='status__action-bar__counter__label' >{obfuscatedCount(status.get('replies_count'))}</span></div> <IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} /> <IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} /> <IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} />
{shareButton} {shareButton}
<div className='status__action-bar-dropdown'> <div className='status__action-bar-dropdown'>

View File

@ -37,6 +37,7 @@ import { initMuteModal } from '../actions/mutes';
import { initBlockModal } from '../actions/blocks'; import { initBlockModal } from '../actions/blocks';
import { initReport } from '../actions/reports'; import { initReport } from '../actions/reports';
import { openModal } from '../actions/modal'; import { openModal } from '../actions/modal';
import { deployPictureInPicture } from '../actions/picture_in_picture';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { boostModal, deleteModal } from '../initial_state'; import { boostModal, deleteModal } from '../initial_state';
import { showAlertForError } from '../actions/alerts'; import { showAlertForError } from '../actions/alerts';
@ -56,6 +57,7 @@ const makeMapStateToProps = () => {
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
status: getStatus(state, props), status: getStatus(state, props),
usingPiP: state.get('picture_in_picture').statusId === props.id,
}); });
return mapStateToProps; return mapStateToProps;
@ -207,6 +209,10 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
dispatch(unblockDomain(domain)); dispatch(unblockDomain(domain));
}, },
deployPictureInPicture (status, type, mediaProps) {
dispatch(deployPictureInPicture(status.get('id'), status.getIn(['account', 'id']), type, mediaProps));
},
}); });
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status)); export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(Status));

View File

@ -7,6 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { autoPlayGif, me, isStaff } from 'mastodon/initial_state'; import { autoPlayGif, me, isStaff } from 'mastodon/initial_state';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import IconButton from 'mastodon/components/icon_button';
import Avatar from 'mastodon/components/avatar'; import Avatar from 'mastodon/components/avatar';
import { counterRenderer } from 'mastodon/components/common_counter'; import { counterRenderer } from 'mastodon/components/common_counter';
import ShortNumber from 'mastodon/components/short_number'; import ShortNumber from 'mastodon/components/short_number';
@ -35,6 +36,8 @@ const messages = defineMessages({
unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' }, unblockDomain: { id: 'account.unblock_domain', defaultMessage: 'Unblock domain {domain}' },
hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' }, hideReblogs: { id: 'account.hide_reblogs', defaultMessage: 'Hide boosts from @{name}' },
showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' }, showReblogs: { id: 'account.show_reblogs', defaultMessage: 'Show boosts from @{name}' },
enableNotifications: { id: 'account.enable_notifications', defaultMessage: 'Notify me when @{name} posts' },
disableNotifications: { id: 'account.disable_notifications', defaultMessage: 'Stop notifying me when @{name} posts' },
pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' }, pins: { id: 'navigation_bar.pins', defaultMessage: 'Pinned toots' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' }, preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' }, follow_requests: { id: 'navigation_bar.follow_requests', defaultMessage: 'Follow requests' },
@ -68,8 +71,9 @@ class Header extends ImmutablePureComponent {
onBlock: PropTypes.func.isRequired, onBlock: PropTypes.func.isRequired,
onMention: PropTypes.func.isRequired, onMention: PropTypes.func.isRequired,
onDirect: PropTypes.func.isRequired, onDirect: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onReblogToggle: PropTypes.func.isRequired, onReblogToggle: PropTypes.func.isRequired,
onNotifyToggle: PropTypes.func.isRequired,
onReport: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired, onMute: PropTypes.func.isRequired,
onBlockDomain: PropTypes.func.isRequired, onBlockDomain: PropTypes.func.isRequired,
onUnblockDomain: PropTypes.func.isRequired, onUnblockDomain: PropTypes.func.isRequired,
@ -140,8 +144,11 @@ class Header extends ImmutablePureComponent {
return null; return null;
} }
const suspended = account.get('suspended');
let info = []; let info = [];
let actionBtn = ''; let actionBtn = '';
let bellBtn = '';
let lockedIcon = ''; let lockedIcon = '';
let menu = []; let menu = [];
@ -171,6 +178,10 @@ class Header extends ImmutablePureComponent {
actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />; actionBtn = <Button className='logo-button' text={intl.formatMessage(messages.edit_profile)} onClick={this.openEditProfile} />;
} }
if (account.getIn(['relationship', 'requested']) || account.getIn(['relationship', 'following'])) {
bellBtn = <IconButton icon='bell-o' size={24} active={account.getIn(['relationship', 'notifying'])} title={intl.formatMessage(account.getIn(['relationship', 'notifying']) ? messages.disableNotifications : messages.enableNotifications, { name: account.get('username') })} onClick={this.props.onNotifyToggle} />;
}
if (account.get('moved') && !account.getIn(['relationship', 'following'])) { if (account.get('moved') && !account.getIn(['relationship', 'following'])) {
actionBtn = ''; actionBtn = '';
} }
@ -268,7 +279,7 @@ class Header extends ImmutablePureComponent {
<div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}> <div className={classNames('account__header', { inactive: !!account.get('moved') })} ref={this.setRef}>
<div className='account__header__image'> <div className='account__header__image'>
<div className='account__header__info'> <div className='account__header__info'>
{info} {!suspended && info}
</div> </div>
<img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' /> <img src={autoPlayGif ? account.get('header') : account.get('header_static')} alt='' className='parallax' />
@ -282,11 +293,14 @@ class Header extends ImmutablePureComponent {
<div className='spacer' /> <div className='spacer' />
<div className='account__header__tabs__buttons'> {!suspended && (
{actionBtn} <div className='account__header__tabs__buttons'>
{actionBtn}
{bellBtn}
<DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' /> <DropdownMenuContainer items={menu} icon='ellipsis-v' size={24} direction='right' />
</div> </div>
)}
</div> </div>
<div className='account__header__tabs__name'> <div className='account__header__tabs__name'>
@ -298,7 +312,7 @@ class Header extends ImmutablePureComponent {
<div className='account__header__extra'> <div className='account__header__extra'>
<div className='account__header__bio'> <div className='account__header__bio'>
{ (fields.size > 0 || identity_proofs.size > 0) && ( {(fields.size > 0 || identity_proofs.size > 0) && (
<div className='account__header__fields'> <div className='account__header__fields'>
{identity_proofs.map((proof, i) => ( {identity_proofs.map((proof, i) => (
<dl key={i}> <dl key={i}>
@ -324,33 +338,35 @@ class Header extends ImmutablePureComponent {
</div> </div>
)} )}
{account.get('id') !== me && <AccountNoteContainer account={account} />} {account.get('id') !== me && !suspended && <AccountNoteContainer account={account} />}
{account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />} {account.get('note').length > 0 && account.get('note') !== '<p></p>' && <div className='account__header__content' dangerouslySetInnerHTML={content} />}
</div> </div>
<div className='account__header__extra__links'> {!suspended && (
<NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}> <div className='account__header__extra__links'>
<ShortNumber <NavLink isActive={this.isStatusesPageActive} activeClassName='active' to={`/accounts/${account.get('id')}`} title={intl.formatNumber(account.get('statuses_count'))}>
value={account.get('statuses_count')} <ShortNumber
renderer={counterRenderer('statuses')} value={account.get('statuses_count')}
/> renderer={counterRenderer('statuses')}
</NavLink> />
</NavLink>
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}> <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/following`} title={intl.formatNumber(account.get('following_count'))}>
<ShortNumber <ShortNumber
value={account.get('following_count')} value={account.get('following_count')}
renderer={counterRenderer('following')} renderer={counterRenderer('following')}
/> />
</NavLink> </NavLink>
<NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}> <NavLink exact activeClassName='active' to={`/accounts/${account.get('id')}/followers`} title={intl.formatNumber(account.get('followers_count'))}>
<ShortNumber <ShortNumber
value={account.get('followers_count')} value={account.get('followers_count')}
renderer={counterRenderer('followers')} renderer={counterRenderer('followers')}
/> />
</NavLink> </NavLink>
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -15,12 +15,15 @@ import { ScrollContainer } from 'react-router-scroll-4';
import LoadMore from 'mastodon/components/load_more'; import LoadMore from 'mastodon/components/load_more';
import MissingIndicator from 'mastodon/components/missing_indicator'; import MissingIndicator from 'mastodon/components/missing_indicator';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
import { FormattedMessage } from 'react-intl';
const mapStateToProps = (state, props) => ({ const mapStateToProps = (state, props) => ({
isAccount: !!state.getIn(['accounts', props.params.accountId]), isAccount: !!state.getIn(['accounts', props.params.accountId]),
attachments: getAccountGallery(state, props.params.accountId), attachments: getAccountGallery(state, props.params.accountId),
isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']), hasMore: state.getIn(['timelines', `account:${props.params.accountId}:media`, 'hasMore']),
suspended: state.getIn(['accounts', props.params.accountId, 'suspended'], false),
blockedBy: state.getIn(['relationships', props.params.accountId, 'blocked_by'], false),
}); });
class LoadMoreMedia extends ImmutablePureComponent { class LoadMoreMedia extends ImmutablePureComponent {
@ -56,6 +59,8 @@ class AccountGallery extends ImmutablePureComponent {
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
blockedBy: PropTypes.bool,
suspended: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
}; };
@ -119,7 +124,7 @@ class AccountGallery extends ImmutablePureComponent {
} }
render () { render () {
const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn } = this.props; const { attachments, shouldUpdateScroll, isLoading, hasMore, isAccount, multiColumn, blockedBy, suspended } = this.props;
const { width } = this.state; const { width } = this.state;
if (!isAccount) { if (!isAccount) {
@ -152,15 +157,21 @@ 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' ref={this.handleRef}> {(suspended || blockedBy) ? (
{attachments.map((attachment, index) => attachment === null ? ( <div className='empty-column-indicator'>
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} /> <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />
) : ( </div>
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} /> ) : (
))} <div role='feed' className='account-gallery__container' ref={this.handleRef}>
{attachments.map((attachment, index) => attachment === null ? (
<LoadMoreMedia key={'more:' + attachments.getIn(index + 1, 'id')} maxId={index > 0 ? attachments.getIn(index - 1, 'id') : null} onLoadMore={this.handleLoadMore} />
) : (
<MediaItem key={attachment.get('id')} attachment={attachment} displayWidth={width} onOpenMedia={this.handleOpenMedia} />
))}
{loadOlder} {loadOlder}
</div> </div>
)}
{isLoading && attachments.size === 0 && ( {isLoading && attachments.size === 0 && (
<div className='scrollable__append'> <div className='scrollable__append'>

View File

@ -55,6 +55,10 @@ export default class Header extends ImmutablePureComponent {
this.props.onReblogToggle(this.props.account); this.props.onReblogToggle(this.props.account);
} }
handleNotifyToggle = () => {
this.props.onNotifyToggle(this.props.account);
}
handleMute = () => { handleMute = () => {
this.props.onMute(this.props.account); this.props.onMute(this.props.account);
} }
@ -106,6 +110,7 @@ export default class Header extends ImmutablePureComponent {
onMention={this.handleMention} onMention={this.handleMention}
onDirect={this.handleDirect} onDirect={this.handleDirect}
onReblogToggle={this.handleReblogToggle} onReblogToggle={this.handleReblogToggle}
onNotifyToggle={this.handleNotifyToggle}
onReport={this.handleReport} onReport={this.handleReport}
onMute={this.handleMute} onMute={this.handleMute}
onBlockDomain={this.handleBlockDomain} onBlockDomain={this.handleBlockDomain}

View File

@ -76,9 +76,9 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onReblogToggle (account) { onReblogToggle (account) {
if (account.getIn(['relationship', 'showing_reblogs'])) { if (account.getIn(['relationship', 'showing_reblogs'])) {
dispatch(followAccount(account.get('id'), false)); dispatch(followAccount(account.get('id'), { reblogs: false }));
} else { } else {
dispatch(followAccount(account.get('id'), true)); dispatch(followAccount(account.get('id'), { reblogs: true }));
} }
}, },
@ -90,6 +90,14 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
} }
}, },
onNotifyToggle (account) {
if (account.getIn(['relationship', 'notifying'])) {
dispatch(followAccount(account.get('id'), { notify: false }));
} else {
dispatch(followAccount(account.get('id'), { notify: true }));
}
},
onReport (account) { onReport (account) {
dispatch(initReport(account)); dispatch(initReport(account));
}, },

View File

@ -31,6 +31,7 @@ const mapStateToProps = (state, { params: { accountId }, withReplies = false })
featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList), featuredStatusIds: withReplies ? ImmutableList() : state.getIn(['timelines', `account:${accountId}:pinned`, 'items'], emptyList),
isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']), isLoading: state.getIn(['timelines', `account:${path}`, 'isLoading']),
hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']), hasMore: state.getIn(['timelines', `account:${path}`, 'hasMore']),
suspended: state.getIn(['accounts', accountId, 'suspended'], false),
blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false), blockedBy: state.getIn(['relationships', accountId, 'blocked_by'], false),
}; };
}; };
@ -57,6 +58,7 @@ class AccountTimeline extends ImmutablePureComponent {
withReplies: PropTypes.bool, withReplies: PropTypes.bool,
blockedBy: PropTypes.bool, blockedBy: PropTypes.bool,
isAccount: PropTypes.bool, isAccount: PropTypes.bool,
suspended: PropTypes.bool,
remote: PropTypes.bool, remote: PropTypes.bool,
remoteUrl: PropTypes.string, remoteUrl: PropTypes.string,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
@ -113,7 +115,7 @@ class AccountTimeline extends ImmutablePureComponent {
} }
render () { render () {
const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, isAccount, multiColumn, remote, remoteUrl } = this.props; const { shouldUpdateScroll, statusIds, featuredStatusIds, isLoading, hasMore, blockedBy, suspended, isAccount, multiColumn, remote, remoteUrl } = this.props;
if (!isAccount) { if (!isAccount) {
return ( return (
@ -134,7 +136,7 @@ class AccountTimeline extends ImmutablePureComponent {
let emptyMessage; let emptyMessage;
if (blockedBy) { if (suspended || blockedBy) {
emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />; emptyMessage = <FormattedMessage id='empty_column.account_unavailable' defaultMessage='Profile unavailable' />;
} else if (remote && statusIds.isEmpty()) { } else if (remote && statusIds.isEmpty()) {
emptyMessage = <RemoteHint url={remoteUrl} />; emptyMessage = <RemoteHint url={remoteUrl} />;
@ -153,7 +155,7 @@ class AccountTimeline extends ImmutablePureComponent {
alwaysPrepend alwaysPrepend
append={remoteMessage} append={remoteMessage}
scrollKey='account_timeline' scrollKey='account_timeline'
statusIds={blockedBy ? emptyList : statusIds} statusIds={(suspended || blockedBy) ? emptyList : statusIds}
featuredStatusIds={featuredStatusIds} featuredStatusIds={featuredStatusIds}
isLoading={isLoading} isLoading={isLoading}
hasMore={hasMore} hasMore={hasMore}

View File

@ -37,7 +37,11 @@ class Audio extends React.PureComponent {
backgroundColor: PropTypes.string, backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string, foregroundColor: PropTypes.string,
accentColor: PropTypes.string, accentColor: PropTypes.string,
currentTime: PropTypes.number,
autoPlay: PropTypes.bool, autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
}; };
state = { state = {
@ -64,6 +68,19 @@ class Audio extends React.PureComponent {
} }
} }
_pack() {
return {
src: this.props.src,
volume: this.audio.volume,
muted: this.audio.muted,
currentTime: this.audio.currentTime,
poster: this.props.poster,
backgroundColor: this.props.backgroundColor,
foregroundColor: this.props.foregroundColor,
accentColor: this.props.accentColor,
};
}
_setDimensions () { _setDimensions () {
const width = this.player.offsetWidth; const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9)); const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
@ -112,6 +129,10 @@ class Audio extends React.PureComponent {
componentWillUnmount () { componentWillUnmount () {
window.removeEventListener('scroll', this.handleScroll); window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
} }
togglePlay = () => { togglePlay = () => {
@ -248,7 +269,13 @@ class Audio extends React.PureComponent {
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0); const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) { if (!this.state.paused && !inView) {
this.setState({ paused: true }, () => this.audio.pause()); this.audio.pause();
if (this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
this.setState({ paused: true });
} }
}, 150, { trailing: true }); }, 150, { trailing: true });
@ -261,10 +288,22 @@ class Audio extends React.PureComponent {
} }
handleLoadedData = () => { handleLoadedData = () => {
const { autoPlay } = this.props; const { autoPlay, currentTime, volume, muted } = this.props;
if (currentTime) {
this.audio.currentTime = currentTime;
}
if (volume !== undefined) {
this.audio.volume = volume;
}
if (muted !== undefined) {
this.audio.muted = muted;
}
if (autoPlay) { if (autoPlay) {
this.audio.play(); this.togglePlay();
} }
} }
@ -350,7 +389,7 @@ class Audio extends React.PureComponent {
render () { render () {
const { src, intl, alt, editable, autoPlay } = this.props; const { src, intl, alt, editable, autoPlay } = this.props;
const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state; const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
const progress = (currentTime / duration) * 100; const progress = Math.min((currentTime / duration) * 100, 100);
return ( return (
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>

View File

@ -12,7 +12,7 @@ const emojiFilenames = (emojis) => {
}; };
// Emoji requiring extra borders depending on theme // Emoji requiring extra borders depending on theme
const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞']); const darkEmoji = emojiFilenames(['🎱', '🐜', '⚫', '🖤', '⬛', '◼️', '◾', '◼️', '✒️', '▪️', '💣', '🎳', '📷', '📸', '♣️', '🕶️', '✴️', '🔌', '💂‍♀️', '📽️', '🍳', '🦍', '💂', '🔪', '🕳️', '🕹️', '🕋', '🖊️', '🖋️', '💂‍♂️', '🎤', '🎓', '🎥', '🎼', '♠️', '🎩', '🦃', '📼', '📹', '🎮', '🐃', '🏴', '🐞', '🕺']);
const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']); const lightEmoji = emojiFilenames(['👽', '⚾', '🐔', '☁️', '💨', '🕊️', '👀', '🍥', '👻', '🐐', '❕', '❔', '⛸️', '🌩️', '🔊', '🔇', '📃', '🌧️', '🐏', '🍚', '🍙', '🐓', '🐑', '💀', '☠️', '🌨️', '🔉', '🔈', '💬', '💭', '🏐', '🏳️', '⚪', '⬜', '◽', '◻️', '▫️']);
const emojiFilename = (filename) => { const emojiFilename = (filename) => {

View File

@ -9,6 +9,7 @@ const tooltips = defineMessages({
boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' }, boosts: { id: 'notifications.filter.boosts', defaultMessage: 'Boosts' },
polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' }, polls: { id: 'notifications.filter.polls', defaultMessage: 'Poll results' },
follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' }, follows: { id: 'notifications.filter.follows', defaultMessage: 'Follows' },
statuses: { id: 'notifications.filter.statuses', defaultMessage: 'Updates from people you follow' },
}); });
export default @injectIntl export default @injectIntl
@ -87,6 +88,13 @@ class FilterBar extends React.PureComponent {
> >
<Icon id='tasks' fixedWidth /> <Icon id='tasks' fixedWidth />
</button> </button>
<button
className={selectedFilter === 'status' ? 'active' : ''}
onClick={this.onClick('status')}
title={intl.formatMessage(tooltips.statuses)}
>
<Icon id='home' fixedWidth />
</button>
<button <button
className={selectedFilter === 'follow' ? 'active' : ''} className={selectedFilter === 'follow' ? 'active' : ''}
onClick={this.onClick('follow')} onClick={this.onClick('follow')}

View File

@ -10,6 +10,7 @@ import AccountContainer from 'mastodon/containers/account_container';
import FollowRequestContainer from '../containers/follow_request_container'; import FollowRequestContainer from '../containers/follow_request_container';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import Permalink from 'mastodon/components/permalink'; import Permalink from 'mastodon/components/permalink';
import classNames from 'classnames';
const messages = defineMessages({ const messages = defineMessages({
favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' }, favourite: { id: 'notification.favourite', defaultMessage: '{name} favourited your status' },
@ -17,6 +18,7 @@ const messages = defineMessages({
ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' }, ownPoll: { id: 'notification.own_poll', defaultMessage: 'Your poll has ended' },
poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' }, poll: { id: 'notification.poll', defaultMessage: 'A poll you have voted in has ended' },
reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' }, reblog: { id: 'notification.reblog', defaultMessage: '{name} boosted your status' },
status: { id: 'notification.status', defaultMessage: '{name} just posted' },
}); });
const notificationForScreenReader = (intl, message, timestamp) => { const notificationForScreenReader = (intl, message, timestamp) => {
@ -49,6 +51,7 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom: PropTypes.func, updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func, cacheMediaWidth: PropTypes.func,
cachedMediaWidth: PropTypes.number, cachedMediaWidth: PropTypes.number,
unread: PropTypes.bool,
}; };
handleMoveUp = () => { handleMoveUp = () => {
@ -113,11 +116,11 @@ class Notification extends ImmutablePureComponent {
} }
renderFollow (notification, account, link) { renderFollow (notification, account, link) {
const { intl } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}> <div className={classNames('notification notification-follow focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.follow, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__favourite-icon-wrapper'>
<Icon id='user-plus' fixedWidth /> <Icon id='user-plus' fixedWidth />
@ -135,11 +138,11 @@ class Notification extends ImmutablePureComponent {
} }
renderFollowRequest (notification, account, link) { renderFollowRequest (notification, account, link) {
const { intl } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <HotKeys handlers={this.getHandlers()}>
<div className='notification notification-follow-request focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}> <div className={classNames('notification notification-follow-request focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage({ id: 'notification.follow_request', defaultMessage: '{name} has requested to follow you' }, { name: account.get('acct') }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__favourite-icon-wrapper'>
<Icon id='user' fixedWidth /> <Icon id='user' fixedWidth />
@ -169,16 +172,17 @@ class Notification extends ImmutablePureComponent {
updateScrollBottom={this.props.updateScrollBottom} updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth} cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth} cacheMediaWidth={this.props.cacheMediaWidth}
unread={this.props.unread}
/> />
); );
} }
renderFavourite (notification, link) { renderFavourite (notification, link) {
const { intl } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <HotKeys handlers={this.getHandlers()}>
<div className='notification notification-favourite focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className={classNames('notification notification-favourite focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.favourite, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__favourite-icon-wrapper'>
<Icon id='star' className='star-icon' fixedWidth /> <Icon id='star' className='star-icon' fixedWidth />
@ -206,11 +210,11 @@ class Notification extends ImmutablePureComponent {
} }
renderReblog (notification, link) { renderReblog (notification, link) {
const { intl } = this.props; const { intl, unread } = this.props;
return ( return (
<HotKeys handlers={this.getHandlers()}> <HotKeys handlers={this.getHandlers()}>
<div className='notification notification-reblog focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}> <div className={classNames('notification notification-reblog focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.reblog, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__favourite-icon-wrapper'>
<Icon id='retweet' fixedWidth /> <Icon id='retweet' fixedWidth />
@ -237,14 +241,46 @@ class Notification extends ImmutablePureComponent {
); );
} }
renderStatus (notification, link) {
const { intl, unread } = this.props;
return (
<HotKeys handlers={this.getHandlers()}>
<div className={classNames('notification notification-status focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, intl.formatMessage(messages.status, { name: notification.getIn(['account', 'acct']) }), notification.get('created_at'))}>
<div className='notification__message'>
<div className='notification__favourite-icon-wrapper'>
<Icon id='home' fixedWidth />
</div>
<span title={notification.get('created_at')}>
<FormattedMessage id='notification.status' defaultMessage='{name} just posted' values={{ name: link }} />
</span>
</div>
<StatusContainer
id={notification.get('status')}
account={notification.get('account')}
muted
withDismiss
hidden={this.props.hidden}
getScrollPosition={this.props.getScrollPosition}
updateScrollBottom={this.props.updateScrollBottom}
cachedMediaWidth={this.props.cachedMediaWidth}
cacheMediaWidth={this.props.cacheMediaWidth}
/>
</div>
</HotKeys>
);
}
renderPoll (notification, account) { renderPoll (notification, account) {
const { intl } = this.props; const { intl, unread } = this.props;
const ownPoll = me === account.get('id'); const ownPoll = me === account.get('id');
const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll); const message = ownPoll ? intl.formatMessage(messages.ownPoll) : intl.formatMessage(messages.poll);
return ( return (
<HotKeys handlers={this.getHandlers()}> <HotKeys handlers={this.getHandlers()}>
<div className='notification notification-poll focusable' tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}> <div className={classNames('notification notification-poll focusable', { unread })} tabIndex='0' aria-label={notificationForScreenReader(intl, message, notification.get('created_at'))}>
<div className='notification__message'> <div className='notification__message'>
<div className='notification__favourite-icon-wrapper'> <div className='notification__favourite-icon-wrapper'>
<Icon id='tasks' fixedWidth /> <Icon id='tasks' fixedWidth />
@ -292,6 +328,8 @@ class Notification extends ImmutablePureComponent {
return this.renderFavourite(notification, link); return this.renderFavourite(notification, link);
case 'reblog': case 'reblog':
return this.renderReblog(notification, link); return this.renderReblog(notification, link);
case 'status':
return this.renderStatus(notification, link);
case 'poll': case 'poll':
return this.renderPoll(notification, account); return this.renderPoll(notification, account);
} }

View File

@ -4,7 +4,14 @@ import PropTypes from 'prop-types';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import Column from '../../components/column'; import Column from '../../components/column';
import ColumnHeader from '../../components/column_header'; import ColumnHeader from '../../components/column_header';
import { expandNotifications, scrollTopNotifications, loadPending, mountNotifications, unmountNotifications } from '../../actions/notifications'; import {
expandNotifications,
scrollTopNotifications,
loadPending,
mountNotifications,
unmountNotifications,
markNotificationsAsRead,
} from '../../actions/notifications';
import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import { addColumn, removeColumn, moveColumn } from '../../actions/columns';
import NotificationContainer from './containers/notification_container'; import NotificationContainer from './containers/notification_container';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
@ -15,9 +22,12 @@ import { List as ImmutableList } from 'immutable';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import ScrollableList from '../../components/scrollable_list'; import ScrollableList from '../../components/scrollable_list';
import LoadGap from '../../components/load_gap'; import LoadGap from '../../components/load_gap';
import Icon from 'mastodon/components/icon';
import compareId from 'mastodon/compare_id';
const messages = defineMessages({ const messages = defineMessages({
title: { id: 'column.notifications', defaultMessage: 'Notifications' }, title: { id: 'column.notifications', defaultMessage: 'Notifications' },
markAsRead : { id: 'notifications.mark_as_read', defaultMessage: 'Mark every notification as read' },
}); });
const getNotifications = createSelector([ const getNotifications = createSelector([
@ -32,7 +42,7 @@ const getNotifications = createSelector([
// we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category // we need to turn it off for FilterBar in order not to block ourselves from seeing a specific category
return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type'))); return notifications.filterNot(item => item !== null && excludedTypes.includes(item.get('type')));
} }
return notifications.filter(item => item !== null && allowedType === item.get('type')); return notifications.filter(item => item === null || allowedType === item.get('type'));
}); });
const mapStateToProps = state => ({ const mapStateToProps = state => ({
@ -42,6 +52,8 @@ const mapStateToProps = state => ({
isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0, isUnread: state.getIn(['notifications', 'unread']) > 0 || state.getIn(['notifications', 'pendingItems']).size > 0,
hasMore: state.getIn(['notifications', 'hasMore']), hasMore: state.getIn(['notifications', 'hasMore']),
numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size, numPending: state.getIn(['notifications', 'pendingItems'], ImmutableList()).size,
lastReadId: state.getIn(['notifications', 'readMarkerId']),
canMarkAsRead: state.getIn(['notifications', 'readMarkerId']) !== '0' && getNotifications(state).some(item => item !== null && compareId(item.get('id'), state.getIn(['notifications', 'readMarkerId'])) > 0),
}); });
export default @connect(mapStateToProps) export default @connect(mapStateToProps)
@ -60,6 +72,8 @@ class Notifications extends React.PureComponent {
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
hasMore: PropTypes.bool, hasMore: PropTypes.bool,
numPending: PropTypes.number, numPending: PropTypes.number,
lastReadId: PropTypes.string,
canMarkAsRead: PropTypes.bool,
}; };
static defaultProps = { static defaultProps = {
@ -146,8 +160,12 @@ class Notifications extends React.PureComponent {
} }
} }
handleMarkAsRead = () => {
this.props.dispatch(markNotificationsAsRead());
};
render () { render () {
const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar } = this.props; const { intl, notifications, shouldUpdateScroll, isLoading, isUnread, columnId, multiColumn, hasMore, numPending, showFilterBar, lastReadId, canMarkAsRead } = this.props;
const pinned = !!columnId; const pinned = !!columnId;
const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />; const emptyMessage = <FormattedMessage id='empty_column.notifications' defaultMessage="You don't have any notifications yet. Interact with others to start the conversation." />;
@ -174,6 +192,7 @@ class Notifications extends React.PureComponent {
accountId={item.get('account')} accountId={item.get('account')}
onMoveUp={this.handleMoveUp} onMoveUp={this.handleMoveUp}
onMoveDown={this.handleMoveDown} onMoveDown={this.handleMoveDown}
unread={lastReadId !== '0' && compareId(item.get('id'), lastReadId) > 0}
/> />
)); ));
} else { } else {
@ -202,6 +221,21 @@ class Notifications extends React.PureComponent {
</ScrollableList> </ScrollableList>
); );
let extraButton = null;
if (canMarkAsRead) {
extraButton = (
<button
aria-label={intl.formatMessage(messages.markAsRead)}
title={intl.formatMessage(messages.markAsRead)}
onClick={this.handleMarkAsRead}
className='column-header__button'
>
<Icon id='check' />
</button>
);
}
return ( return (
<Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}> <Column bindToDocument={!multiColumn} ref={this.setColumnRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader <ColumnHeader
@ -213,6 +247,7 @@ class Notifications extends React.PureComponent {
onClick={this.handleHeaderClick} onClick={this.handleHeaderClick}
pinned={pinned} pinned={pinned}
multiColumn={multiColumn} multiColumn={multiColumn}
extraButton={extraButton}
> >
<ColumnSettingsContainer /> <ColumnSettingsContainer />
</ColumnHeader> </ColumnHeader>

View File

@ -0,0 +1,137 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button';
import classNames from 'classnames';
import { me, boostModal } from 'mastodon/initial_state';
import { defineMessages, injectIntl } from 'react-intl';
import { replyCompose } from 'mastodon/actions/compose';
import { reblog, favourite, unreblog, unfavourite } from 'mastodon/actions/interactions';
import { makeGetStatus } from 'mastodon/selectors';
import { openModal } from 'mastodon/actions/modal';
const messages = defineMessages({
reply: { id: 'status.reply', defaultMessage: 'Reply' },
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
favourite: { id: 'status.favourite', defaultMessage: 'Favourite' },
replyConfirm: { id: 'confirmations.reply.confirm', defaultMessage: 'Reply' },
replyMessage: { id: 'confirmations.reply.message', defaultMessage: 'Replying now will overwrite the message you are currently composing. Are you sure you want to proceed?' },
});
const makeMapStateToProps = () => {
const getStatus = makeGetStatus();
const mapStateToProps = (state, { statusId }) => ({
status: getStatus(state, { id: statusId }),
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
});
return mapStateToProps;
};
export default @connect(makeMapStateToProps)
@injectIntl
class Footer extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = {
statusId: PropTypes.string.isRequired,
status: ImmutablePropTypes.map.isRequired,
intl: PropTypes.object.isRequired,
dispatch: PropTypes.func.isRequired,
askReplyConfirmation: PropTypes.bool,
};
_performReply = () => {
const { dispatch, status } = this.props;
dispatch(replyCompose(status, this.context.router.history));
};
handleReplyClick = () => {
const { dispatch, askReplyConfirmation, intl } = this.props;
if (askReplyConfirmation) {
dispatch(openModal('CONFIRM', {
message: intl.formatMessage(messages.replyMessage),
confirm: intl.formatMessage(messages.replyConfirm),
onConfirm: this._performReply,
}));
} else {
this._performReply();
}
};
handleFavouriteClick = () => {
const { dispatch, status } = this.props;
if (status.get('favourited')) {
dispatch(unfavourite(status));
} else {
dispatch(favourite(status));
}
};
_performReblog = () => {
const { dispatch, status } = this.props;
dispatch(reblog(status));
}
handleReblogClick = e => {
const { dispatch, status } = this.props;
if (status.get('reblogged')) {
dispatch(unreblog(status));
} else if ((e && e.shiftKey) || !boostModal) {
this._performReblog();
} else {
dispatch(openModal('BOOST', { status, onReblog: this._performReblog }));
}
};
render () {
const { status, intl } = this.props;
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
let replyIcon, replyTitle;
if (status.get('in_reply_to_id', null) === null) {
replyIcon = 'reply';
replyTitle = intl.formatMessage(messages.reply);
} else {
replyIcon = 'reply-all';
replyTitle = intl.formatMessage(messages.replyAll);
}
let reblogTitle = '';
if (status.get('reblogged')) {
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
} else if (publicStatus) {
reblogTitle = intl.formatMessage(messages.reblog);
} else if (reblogPrivate) {
reblogTitle = intl.formatMessage(messages.reblog_private);
} else {
reblogTitle = intl.formatMessage(messages.cannot_reblog);
}
return (
<div className='picture-in-picture__footer'>
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} onClick={this.handleReplyClick} counter={status.get('replies_count')} obfuscateCount />
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} pressed={status.get('reblogged')} title={reblogTitle} icon='retweet' onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} pressed={status.get('favourited')} title={intl.formatMessage(messages.favourite)} icon='star' onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
</div>
);
}
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { connect } from 'react-redux';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import PropTypes from 'prop-types';
import IconButton from 'mastodon/components/icon_button';
import { Link } from 'react-router-dom';
import Avatar from 'mastodon/components/avatar';
import DisplayName from 'mastodon/components/display_name';
const mapStateToProps = (state, { accountId }) => ({
account: state.getIn(['accounts', accountId]),
});
export default @connect(mapStateToProps)
class Header extends ImmutablePureComponent {
static propTypes = {
accountId: PropTypes.string.isRequired,
statusId: PropTypes.string.isRequired,
account: ImmutablePropTypes.map.isRequired,
onClose: PropTypes.func.isRequired,
};
render () {
const { account, statusId, onClose } = this.props;
return (
<div className='picture-in-picture__header'>
<Link to={`/statuses/${statusId}`} className='picture-in-picture__header__account'>
<Avatar account={account} size={36} />
<DisplayName account={account} />
</Link>
<IconButton icon='times' onClick={onClose} title='Close' />
</div>
);
}
}

View File

@ -0,0 +1,85 @@
import React from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import Video from 'mastodon/features/video';
import Audio from 'mastodon/features/audio';
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
import Header from './components/header';
import Footer from './components/footer';
const mapStateToProps = state => ({
...state.get('picture_in_picture'),
});
export default @connect(mapStateToProps)
class PictureInPicture extends React.Component {
static propTypes = {
statusId: PropTypes.string,
accountId: PropTypes.string,
type: PropTypes.string,
src: PropTypes.string,
muted: PropTypes.bool,
volume: PropTypes.number,
currentTime: PropTypes.number,
poster: PropTypes.string,
backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string,
accentColor: PropTypes.string,
dispatch: PropTypes.func.isRequired,
};
handleClose = () => {
const { dispatch } = this.props;
dispatch(removePictureInPicture());
}
render () {
const { type, src, currentTime, accountId, statusId } = this.props;
if (!currentTime) {
return null;
}
let player;
if (type === 'video') {
player = (
<Video
src={src}
currentTime={this.props.currentTime}
volume={this.props.volume}
muted={this.props.muted}
autoPlay
inline
alwaysVisible
/>
);
} else if (type === 'audio') {
player = (
<Audio
src={src}
currentTime={this.props.currentTime}
volume={this.props.volume}
muted={this.props.muted}
poster={this.props.poster}
backgroundColor={this.props.backgroundColor}
foregroundColor={this.props.foregroundColor}
accentColor={this.props.accentColor}
autoPlay
/>
);
}
return (
<div className='picture-in-picture'>
<Header accountId={accountId} statusId={statusId} onClose={this.handleClose} />
{player}
<Footer statusId={statusId} />
</div>
);
}
}

View File

@ -15,6 +15,7 @@ import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
import AnimatedNumber from 'mastodon/components/animated_number'; import AnimatedNumber from 'mastodon/components/animated_number';
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
const messages = defineMessages({ const messages = defineMessages({
public_short: { id: 'privacy.public.short', defaultMessage: 'Public' }, public_short: { id: 'privacy.public.short', defaultMessage: 'Public' },
@ -40,6 +41,7 @@ class DetailedStatus extends ImmutablePureComponent {
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
compact: PropTypes.bool, compact: PropTypes.bool,
showMedia: PropTypes.bool, showMedia: PropTypes.bool,
usingPiP: PropTypes.bool,
onToggleMediaVisibility: PropTypes.func, onToggleMediaVisibility: PropTypes.func,
}; };
@ -100,7 +102,7 @@ class DetailedStatus extends ImmutablePureComponent {
render () { render () {
const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status; const status = (this.props.status && this.props.status.get('reblog')) ? this.props.status.get('reblog') : this.props.status;
const outerStyle = { boxSizing: 'border-box' }; const outerStyle = { boxSizing: 'border-box' };
const { intl, compact } = this.props; const { intl, compact, usingPiP } = this.props;
if (!status) { if (!status) {
return null; return null;
@ -116,7 +118,9 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`; outerStyle.height = `${this.state.height}px`;
} }
if (status.get('media_attachments').size > 0) { if (usingPiP) {
media = <PictureInPicturePlaceholder />;
} else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);

View File

@ -143,6 +143,7 @@ const makeMapStateToProps = () => {
descendantsIds, descendantsIds,
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0, askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
domain: state.getIn(['meta', 'domain']), domain: state.getIn(['meta', 'domain']),
usingPiP: state.get('picture_in_picture').statusId === props.params.statusId,
}; };
}; };
@ -167,6 +168,7 @@ class Status extends ImmutablePureComponent {
askReplyConfirmation: PropTypes.bool, askReplyConfirmation: PropTypes.bool,
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
domain: PropTypes.string.isRequired, domain: PropTypes.string.isRequired,
usingPiP: PropTypes.bool,
}; };
state = { state = {
@ -492,7 +494,7 @@ class Status extends ImmutablePureComponent {
render () { render () {
let ancestors, descendants; let ancestors, descendants;
const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn } = this.props; const { shouldUpdateScroll, status, ancestorsIds, descendantsIds, intl, domain, multiColumn, usingPiP } = this.props;
const { fullscreen } = this.state; const { fullscreen } = this.state;
if (status === null) { if (status === null) {
@ -550,6 +552,7 @@ class Status extends ImmutablePureComponent {
domain={domain} domain={domain}
showMedia={this.state.showMedia} showMedia={this.state.showMedia}
onToggleMediaVisibility={this.handleToggleMediaVisibility} onToggleMediaVisibility={this.handleToggleMediaVisibility}
usingPiP={usingPiP}
/> />
<ActionBar <ActionBar

View File

@ -160,7 +160,7 @@ class MediaModal extends ImmutablePureComponent {
src={image.get('url')} src={image.get('url')}
width={image.get('width')} width={image.get('width')}
height={image.get('height')} height={image.get('height')}
startTime={time || 0} currentTime={time || 0}
onCloseVideo={onClose} onCloseVideo={onClose}
detailed detailed
alt={image.get('description')} alt={image.get('description')}

View File

@ -66,9 +66,9 @@ export default class VideoModal extends ImmutablePureComponent {
preview={media.get('preview_url')} preview={media.get('preview_url')}
blurhash={media.get('blurhash')} blurhash={media.get('blurhash')}
src={media.get('url')} src={media.get('url')}
startTime={options.startTime} currentTime={options.startTime}
autoPlay={options.autoPlay} autoPlay={options.autoPlay}
defaultVolume={options.defaultVolume} volume={options.defaultVolume}
onCloseVideo={onClose} onCloseVideo={onClose}
detailed detailed
alt={media.get('description')} alt={media.get('description')}

View File

@ -16,11 +16,12 @@ import { expandNotifications } from '../../actions/notifications';
import { fetchFilters } from '../../actions/filters'; import { fetchFilters } from '../../actions/filters';
import { clearHeight } from '../../actions/height_cache'; import { clearHeight } from '../../actions/height_cache';
import { focusApp, unfocusApp } from 'mastodon/actions/app'; import { focusApp, unfocusApp } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers } from 'mastodon/actions/markers'; import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers'; import { WrappedSwitch, WrappedRoute } from './util/react_router_helpers';
import UploadArea from './components/upload_area'; import UploadArea from './components/upload_area';
import ColumnsAreaContainer from './containers/columns_area_container'; import ColumnsAreaContainer from './containers/columns_area_container';
import DocumentTitle from './components/document_title'; import DocumentTitle from './components/document_title';
import PictureInPicture from 'mastodon/features/picture_in_picture';
import { import {
Compose, Compose,
Status, Status,
@ -265,6 +266,7 @@ class UI extends React.PureComponent {
handleWindowFocus = () => { handleWindowFocus = () => {
this.props.dispatch(focusApp()); this.props.dispatch(focusApp());
this.props.dispatch(submitMarkers());
} }
handleWindowBlur = () => { handleWindowBlur = () => {
@ -368,6 +370,7 @@ class UI extends React.PureComponent {
window.setTimeout(() => Notification.requestPermission(), 120 * 1000); window.setTimeout(() => Notification.requestPermission(), 120 * 1000);
} }
this.props.dispatch(fetchMarkers());
this.props.dispatch(expandHomeTimeline()); this.props.dispatch(expandHomeTimeline());
this.props.dispatch(expandNotifications()); this.props.dispatch(expandNotifications());
@ -545,6 +548,7 @@ class UI extends React.PureComponent {
{children} {children}
</SwitchingColumnsArea> </SwitchingColumnsArea>
<PictureInPicture />
<NotificationsContainer /> <NotificationsContainer />
<LoadingBarContainer className='loading-bar' /> <LoadingBarContainer className='loading-bar' />
<ModalContainer /> <ModalContainer />

Some files were not shown because too many files have changed in this diff Show More