mirror of
https://github.com/mastodon/mastodon.git
synced 2025-11-29 19:03:41 +00:00
Merge branch 'main' into compose-language-detection
This commit is contained in:
commit
2c1ae4a16d
7
Gemfile
7
Gemfile
|
|
@ -4,12 +4,12 @@ source 'https://rubygems.org'
|
||||||
ruby '>= 3.2.0', '< 3.5.0'
|
ruby '>= 3.2.0', '< 3.5.0'
|
||||||
|
|
||||||
gem 'propshaft'
|
gem 'propshaft'
|
||||||
gem 'puma', '~> 6.3'
|
gem 'puma', '~> 7.0'
|
||||||
gem 'rails', '~> 8.0'
|
gem 'rails', '~> 8.0'
|
||||||
gem 'thor', '~> 1.2'
|
gem 'thor', '~> 1.2'
|
||||||
|
|
||||||
gem 'dotenv'
|
gem 'dotenv'
|
||||||
gem 'haml-rails', '~>2.0'
|
gem 'haml-rails', '~>3.0'
|
||||||
gem 'pg', '~> 1.5'
|
gem 'pg', '~> 1.5'
|
||||||
gem 'pghero'
|
gem 'pghero'
|
||||||
|
|
||||||
|
|
@ -160,6 +160,9 @@ group :test do
|
||||||
|
|
||||||
# Stub web requests for specs
|
# Stub web requests for specs
|
||||||
gem 'webmock', '~> 3.18'
|
gem 'webmock', '~> 3.18'
|
||||||
|
|
||||||
|
# Websocket driver for testing integration between rails/sidekiq and streaming
|
||||||
|
gem 'websocket-driver', '~> 0.8', require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
|
|
|
||||||
163
Gemfile.lock
163
Gemfile.lock
|
|
@ -10,29 +10,29 @@ GIT
|
||||||
GEM
|
GEM
|
||||||
remote: https://rubygems.org/
|
remote: https://rubygems.org/
|
||||||
specs:
|
specs:
|
||||||
actioncable (8.0.2.1)
|
actioncable (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
websocket-driver (>= 0.6.1)
|
websocket-driver (>= 0.6.1)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
actionmailbox (8.0.2.1)
|
actionmailbox (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activejob (= 8.0.2.1)
|
activejob (= 8.0.3)
|
||||||
activerecord (= 8.0.2.1)
|
activerecord (= 8.0.3)
|
||||||
activestorage (= 8.0.2.1)
|
activestorage (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
actionmailer (8.0.2.1)
|
actionmailer (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
actionview (= 8.0.2.1)
|
actionview (= 8.0.3)
|
||||||
activejob (= 8.0.2.1)
|
activejob (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
mail (>= 2.8.0)
|
mail (>= 2.8.0)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
actionpack (8.0.2.1)
|
actionpack (8.0.3)
|
||||||
actionview (= 8.0.2.1)
|
actionview (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
rack (>= 2.2.4)
|
rack (>= 2.2.4)
|
||||||
rack-session (>= 1.0.1)
|
rack-session (>= 1.0.1)
|
||||||
|
|
@ -40,15 +40,15 @@ GEM
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
rails-html-sanitizer (~> 1.6)
|
rails-html-sanitizer (~> 1.6)
|
||||||
useragent (~> 0.16)
|
useragent (~> 0.16)
|
||||||
actiontext (8.0.2.1)
|
actiontext (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activerecord (= 8.0.2.1)
|
activerecord (= 8.0.3)
|
||||||
activestorage (= 8.0.2.1)
|
activestorage (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
globalid (>= 0.6.0)
|
globalid (>= 0.6.0)
|
||||||
nokogiri (>= 1.8.5)
|
nokogiri (>= 1.8.5)
|
||||||
actionview (8.0.2.1)
|
actionview (8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
builder (~> 3.1)
|
builder (~> 3.1)
|
||||||
erubi (~> 1.11)
|
erubi (~> 1.11)
|
||||||
rails-dom-testing (~> 2.2)
|
rails-dom-testing (~> 2.2)
|
||||||
|
|
@ -58,22 +58,22 @@ GEM
|
||||||
activemodel (>= 4.1)
|
activemodel (>= 4.1)
|
||||||
case_transform (>= 0.2)
|
case_transform (>= 0.2)
|
||||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||||
activejob (8.0.2.1)
|
activejob (8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
globalid (>= 0.3.6)
|
globalid (>= 0.3.6)
|
||||||
activemodel (8.0.2.1)
|
activemodel (8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
activerecord (8.0.2.1)
|
activerecord (8.0.3)
|
||||||
activemodel (= 8.0.2.1)
|
activemodel (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
timeout (>= 0.4.0)
|
timeout (>= 0.4.0)
|
||||||
activestorage (8.0.2.1)
|
activestorage (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activejob (= 8.0.2.1)
|
activejob (= 8.0.3)
|
||||||
activerecord (= 8.0.2.1)
|
activerecord (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
marcel (~> 1.0)
|
marcel (~> 1.0)
|
||||||
activesupport (8.0.2.1)
|
activesupport (8.0.3)
|
||||||
base64
|
base64
|
||||||
benchmark (>= 0.3)
|
benchmark (>= 0.3)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
|
|
@ -150,7 +150,7 @@ GEM
|
||||||
playwright-ruby-client (>= 1.16.0)
|
playwright-ruby-client (>= 1.16.0)
|
||||||
case_transform (0.2)
|
case_transform (0.2)
|
||||||
activesupport
|
activesupport
|
||||||
cbor (0.5.9.8)
|
cbor (0.5.10.1)
|
||||||
cgi (0.4.2)
|
cgi (0.4.2)
|
||||||
charlock_holmes (0.7.9)
|
charlock_holmes (0.7.9)
|
||||||
chewy (7.6.0)
|
chewy (7.6.0)
|
||||||
|
|
@ -282,7 +282,7 @@ GEM
|
||||||
temple (>= 0.8.2)
|
temple (>= 0.8.2)
|
||||||
thor
|
thor
|
||||||
tilt
|
tilt
|
||||||
haml-rails (2.1.0)
|
haml-rails (3.0.0)
|
||||||
actionpack (>= 5.1)
|
actionpack (>= 5.1)
|
||||||
activesupport (>= 5.1)
|
activesupport (>= 5.1)
|
||||||
haml (>= 4.0.6)
|
haml (>= 4.0.6)
|
||||||
|
|
@ -300,8 +300,8 @@ GEM
|
||||||
highline (3.1.2)
|
highline (3.1.2)
|
||||||
reline
|
reline
|
||||||
hiredis (0.6.3)
|
hiredis (0.6.3)
|
||||||
hiredis-client (0.25.3)
|
hiredis-client (0.26.1)
|
||||||
redis-client (= 0.25.3)
|
redis-client (= 0.26.1)
|
||||||
hkdf (0.3.0)
|
hkdf (0.3.0)
|
||||||
htmlentities (4.3.4)
|
htmlentities (4.3.4)
|
||||||
http (5.3.1)
|
http (5.3.1)
|
||||||
|
|
@ -345,7 +345,7 @@ GEM
|
||||||
azure-blob (~> 0.5.2)
|
azure-blob (~> 0.5.2)
|
||||||
hashie (~> 5.0)
|
hashie (~> 5.0)
|
||||||
jmespath (1.6.2)
|
jmespath (1.6.2)
|
||||||
json (2.13.2)
|
json (2.15.0)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.16.7)
|
json-jwt (1.16.7)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
|
|
@ -447,7 +447,7 @@ GEM
|
||||||
mutex_m (0.3.0)
|
mutex_m (0.3.0)
|
||||||
net-http (0.6.0)
|
net-http (0.6.0)
|
||||||
uri
|
uri
|
||||||
net-imap (0.5.9)
|
net-imap (0.5.10)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.20.0)
|
net-ldap (0.20.0)
|
||||||
|
|
@ -626,10 +626,10 @@ GEM
|
||||||
net-smtp
|
net-smtp
|
||||||
premailer (~> 1.7, >= 1.7.9)
|
premailer (~> 1.7, >= 1.7.9)
|
||||||
prettyprint (0.2.0)
|
prettyprint (0.2.0)
|
||||||
prism (1.4.0)
|
prism (1.5.1)
|
||||||
prometheus_exporter (2.3.0)
|
prometheus_exporter (2.3.0)
|
||||||
webrick
|
webrick
|
||||||
propshaft (1.2.1)
|
propshaft (1.3.1)
|
||||||
actionpack (>= 7.0.0)
|
actionpack (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
rack
|
rack
|
||||||
|
|
@ -637,13 +637,13 @@ GEM
|
||||||
date
|
date
|
||||||
stringio
|
stringio
|
||||||
public_suffix (6.0.2)
|
public_suffix (6.0.2)
|
||||||
puma (6.6.1)
|
puma (7.0.4)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.5.1)
|
pundit (2.5.2)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
raabro (1.4.0)
|
raabro (1.4.0)
|
||||||
racc (1.8.1)
|
racc (1.8.1)
|
||||||
rack (3.1.16)
|
rack (3.2.1)
|
||||||
rack-attack (6.7.0)
|
rack-attack (6.7.0)
|
||||||
rack (>= 1.0, < 4)
|
rack (>= 1.0, < 4)
|
||||||
rack-cors (3.0.0)
|
rack-cors (3.0.0)
|
||||||
|
|
@ -669,20 +669,20 @@ GEM
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
rackup (2.2.1)
|
rackup (2.2.1)
|
||||||
rack (>= 3)
|
rack (>= 3)
|
||||||
rails (8.0.2.1)
|
rails (8.0.3)
|
||||||
actioncable (= 8.0.2.1)
|
actioncable (= 8.0.3)
|
||||||
actionmailbox (= 8.0.2.1)
|
actionmailbox (= 8.0.3)
|
||||||
actionmailer (= 8.0.2.1)
|
actionmailer (= 8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
actiontext (= 8.0.2.1)
|
actiontext (= 8.0.3)
|
||||||
actionview (= 8.0.2.1)
|
actionview (= 8.0.3)
|
||||||
activejob (= 8.0.2.1)
|
activejob (= 8.0.3)
|
||||||
activemodel (= 8.0.2.1)
|
activemodel (= 8.0.3)
|
||||||
activerecord (= 8.0.2.1)
|
activerecord (= 8.0.3)
|
||||||
activestorage (= 8.0.2.1)
|
activestorage (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.0.2.1)
|
railties (= 8.0.3)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
|
|
@ -693,13 +693,14 @@ GEM
|
||||||
rails-i18n (8.0.2)
|
rails-i18n (8.0.2)
|
||||||
i18n (>= 0.7, < 2)
|
i18n (>= 0.7, < 2)
|
||||||
railties (>= 8.0.0, < 9)
|
railties (>= 8.0.0, < 9)
|
||||||
railties (8.0.2.1)
|
railties (8.0.3)
|
||||||
actionpack (= 8.0.2.1)
|
actionpack (= 8.0.3)
|
||||||
activesupport (= 8.0.2.1)
|
activesupport (= 8.0.3)
|
||||||
irb (~> 1.13)
|
irb (~> 1.13)
|
||||||
rackup (>= 1.0.0)
|
rackup (>= 1.0.0)
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0, >= 1.2.2)
|
thor (~> 1.0, >= 1.2.2)
|
||||||
|
tsort (>= 0.2)
|
||||||
zeitwerk (~> 2.6)
|
zeitwerk (~> 2.6)
|
||||||
rainbow (3.1.1)
|
rainbow (3.1.1)
|
||||||
rake (13.3.0)
|
rake (13.3.0)
|
||||||
|
|
@ -719,9 +720,9 @@ GEM
|
||||||
reline
|
reline
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
redis-client (0.25.3)
|
redis-client (0.26.1)
|
||||||
connection_pool
|
connection_pool
|
||||||
regexp_parser (2.11.2)
|
regexp_parser (2.11.3)
|
||||||
reline (0.6.2)
|
reline (0.6.2)
|
||||||
io-console (~> 0.5)
|
io-console (~> 0.5)
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
|
|
@ -765,7 +766,7 @@ GEM
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.4)
|
||||||
rubocop (1.80.2)
|
rubocop (1.81.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
lint_roller (~> 1.1.0)
|
lint_roller (~> 1.1.0)
|
||||||
|
|
@ -773,10 +774,10 @@ GEM
|
||||||
parser (>= 3.3.0.2)
|
parser (>= 3.3.0.2)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 2.9.3, < 3.0)
|
regexp_parser (>= 2.9.3, < 3.0)
|
||||||
rubocop-ast (>= 1.46.0, < 2.0)
|
rubocop-ast (>= 1.47.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 2.4.0, < 4.0)
|
unicode-display_width (>= 2.4.0, < 4.0)
|
||||||
rubocop-ast (1.46.0)
|
rubocop-ast (1.47.1)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.7.2)
|
||||||
prism (~> 1.4)
|
prism (~> 1.4)
|
||||||
rubocop-capybara (2.22.1)
|
rubocop-capybara (2.22.1)
|
||||||
|
|
@ -789,7 +790,7 @@ GEM
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rubocop (>= 1.75.0, < 2.0)
|
rubocop (>= 1.75.0, < 2.0)
|
||||||
rubocop-ast (>= 1.44.0, < 2.0)
|
rubocop-ast (>= 1.44.0, < 2.0)
|
||||||
rubocop-rails (2.33.3)
|
rubocop-rails (2.33.4)
|
||||||
activesupport (>= 4.2.0)
|
activesupport (>= 4.2.0)
|
||||||
lint_roller (~> 1.1)
|
lint_roller (~> 1.1)
|
||||||
rack (>= 1.1)
|
rack (>= 1.1)
|
||||||
|
|
@ -811,11 +812,11 @@ GEM
|
||||||
ruby-vips (2.2.5)
|
ruby-vips (2.2.5)
|
||||||
ffi (~> 1.12)
|
ffi (~> 1.12)
|
||||||
logger
|
logger
|
||||||
rubyzip (3.1.0)
|
rubyzip (3.1.1)
|
||||||
rufus-scheduler (3.9.2)
|
rufus-scheduler (3.9.2)
|
||||||
fugit (~> 1.1, >= 1.11.1)
|
fugit (~> 1.1, >= 1.11.1)
|
||||||
safety_net_attestation (0.4.0)
|
safety_net_attestation (0.5.0)
|
||||||
jwt (~> 2.0)
|
jwt (>= 2.0, < 4.0)
|
||||||
sanitize (7.0.0)
|
sanitize (7.0.0)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.16.8)
|
nokogiri (>= 1.16.8)
|
||||||
|
|
@ -879,6 +880,7 @@ GEM
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
openssl-signature_algorithm (~> 1.0)
|
openssl-signature_algorithm (~> 1.0)
|
||||||
|
tsort (0.2.0)
|
||||||
tty-color (0.6.0)
|
tty-color (0.6.0)
|
||||||
tty-cursor (0.7.1)
|
tty-cursor (0.7.1)
|
||||||
tty-prompt (0.23.1)
|
tty-prompt (0.23.1)
|
||||||
|
|
@ -899,9 +901,9 @@ GEM
|
||||||
unf (0.1.4)
|
unf (0.1.4)
|
||||||
unf_ext
|
unf_ext
|
||||||
unf_ext (0.0.9.1)
|
unf_ext (0.0.9.1)
|
||||||
unicode-display_width (3.1.5)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.0, >= 4.0.4)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.0.4)
|
unicode-emoji (4.1.0)
|
||||||
uri (1.0.3)
|
uri (1.0.3)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
validate_url (1.0.15)
|
validate_url (1.0.15)
|
||||||
|
|
@ -918,13 +920,13 @@ GEM
|
||||||
zeitwerk (~> 2.2)
|
zeitwerk (~> 2.2)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
rack (>= 2.0.9)
|
rack (>= 2.0.9)
|
||||||
webauthn (3.4.1)
|
webauthn (3.4.2)
|
||||||
android_key_attestation (~> 0.3.0)
|
android_key_attestation (~> 0.3.0)
|
||||||
bindata (~> 2.4)
|
bindata (~> 2.4)
|
||||||
cbor (~> 0.5.9)
|
cbor (~> 0.5.9)
|
||||||
cose (~> 1.1)
|
cose (~> 1.1)
|
||||||
openssl (>= 2.2)
|
openssl (>= 2.2)
|
||||||
safety_net_attestation (~> 0.4.0)
|
safety_net_attestation (~> 0.5.0)
|
||||||
tpm-key_attestation (~> 0.14.0)
|
tpm-key_attestation (~> 0.14.0)
|
||||||
webfinger (2.1.3)
|
webfinger (2.1.3)
|
||||||
activesupport
|
activesupport
|
||||||
|
|
@ -988,7 +990,7 @@ DEPENDENCIES
|
||||||
flatware-rspec
|
flatware-rspec
|
||||||
fog-core (<= 2.6.0)
|
fog-core (<= 2.6.0)
|
||||||
fog-openstack (~> 1.0)
|
fog-openstack (~> 1.0)
|
||||||
haml-rails (~> 2.0)
|
haml-rails (~> 3.0)
|
||||||
haml_lint
|
haml_lint
|
||||||
hcaptcha (~> 7.1)
|
hcaptcha (~> 7.1)
|
||||||
hiredis (~> 0.6)
|
hiredis (~> 0.6)
|
||||||
|
|
@ -1052,7 +1054,7 @@ DEPENDENCIES
|
||||||
prometheus_exporter (~> 2.2)
|
prometheus_exporter (~> 2.2)
|
||||||
propshaft
|
propshaft
|
||||||
public_suffix (~> 6.0)
|
public_suffix (~> 6.0)
|
||||||
puma (~> 6.3)
|
puma (~> 7.0)
|
||||||
pundit (~> 2.3)
|
pundit (~> 2.3)
|
||||||
rack-attack (~> 6.6)
|
rack-attack (~> 6.6)
|
||||||
rack-cors
|
rack-cors
|
||||||
|
|
@ -1100,6 +1102,7 @@ DEPENDENCIES
|
||||||
webauthn (~> 3.0)
|
webauthn (~> 3.0)
|
||||||
webmock (~> 3.18)
|
webmock (~> 3.18)
|
||||||
webpush!
|
webpush!
|
||||||
|
websocket-driver (~> 0.8)
|
||||||
xorcist (~> 1.1)
|
xorcist (~> 1.1)
|
||||||
|
|
||||||
RUBY VERSION
|
RUBY VERSION
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,10 @@ class AccountsController < ApplicationController
|
||||||
params[:username]
|
params[:username]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def account_id_param
|
||||||
|
params[:id]
|
||||||
|
end
|
||||||
|
|
||||||
def skip_temporary_suspension_response?
|
def skip_temporary_suspension_response?
|
||||||
request.format == :json
|
request.format == :json
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class ActivityPub::LikesController < ActivityPub::BaseController
|
||||||
|
|
||||||
def likes_collection_presenter
|
def likes_collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_status_likes_url(@account, @status),
|
id: ActivityPub::TagManager.instance.likes_uri_for(@status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
size: @status.favourites_count
|
size: @status.favourites_count
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,8 @@ class ActivityPub::OutboxesController < ActivityPub::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
@account = params[:account_username].present? ? Account.find_local!(username_param) : Account.representative
|
return super if params[:account_username].present? || params[:account_id].present?
|
||||||
|
|
||||||
|
@account = Account.representative
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||||
|
|
||||||
def replies_collection_presenter
|
def replies_collection_presenter
|
||||||
page = ActivityPub::CollectionPresenter.new(
|
page = ActivityPub::CollectionPresenter.new(
|
||||||
id: account_status_replies_url(@account, @status, page_params),
|
id: ActivityPub::TagManager.instance.replies_uri_for(@status, page_params),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
part_of: account_status_replies_url(@account, @status),
|
part_of: account_status_replies_url(@account, @status),
|
||||||
next: next_page,
|
next: next_page,
|
||||||
|
|
@ -47,7 +47,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||||
return page if page_requested?
|
return page if page_requested?
|
||||||
|
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_status_replies_url(@account, @status),
|
id: ActivityPub::TagManager.instance.replies_uri_for(@status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
first: page
|
first: page
|
||||||
)
|
)
|
||||||
|
|
@ -66,8 +66,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||||
# Only consider remote accounts
|
# Only consider remote accounts
|
||||||
return nil if @replies.size < DESCENDANTS_LIMIT
|
return nil if @replies.size < DESCENDANTS_LIMIT
|
||||||
|
|
||||||
account_status_replies_url(
|
ActivityPub::TagManager.instance.replies_uri_for(
|
||||||
@account,
|
|
||||||
@status,
|
@status,
|
||||||
page: true,
|
page: true,
|
||||||
min_id: @replies&.last&.id,
|
min_id: @replies&.last&.id,
|
||||||
|
|
@ -77,8 +76,7 @@ class ActivityPub::RepliesController < ActivityPub::BaseController
|
||||||
# For now, we're serving only self-replies, but next page might be other accounts
|
# For now, we're serving only self-replies, but next page might be other accounts
|
||||||
next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
|
next_only_other_accounts = @replies&.last&.account_id != @account.id || @replies.size < DESCENDANTS_LIMIT
|
||||||
|
|
||||||
account_status_replies_url(
|
ActivityPub::TagManager.instance.replies_uri_for(
|
||||||
@account,
|
|
||||||
@status,
|
@status,
|
||||||
page: true,
|
page: true,
|
||||||
min_id: next_only_other_accounts ? nil : @replies&.last&.id,
|
min_id: next_only_other_accounts ? nil : @replies&.last&.id,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ class ActivityPub::SharesController < ActivityPub::BaseController
|
||||||
|
|
||||||
def shares_collection_presenter
|
def shares_collection_presenter
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_status_shares_url(@account, @status),
|
id: ActivityPub::TagManager.instance.shares_uri_for(@status),
|
||||||
type: :unordered,
|
type: :unordered,
|
||||||
size: @status.reblogs_count
|
size: @status.reblogs_count
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
|
||||||
include Api::InteractionPoliciesConcern
|
include Api::InteractionPoliciesConcern
|
||||||
|
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }
|
||||||
before_action -> { check_feature_enabled }
|
|
||||||
|
|
||||||
def update
|
def update
|
||||||
authorize @status, :update?
|
authorize @status, :update?
|
||||||
|
|
@ -22,12 +21,8 @@ class Api::V1::Statuses::InteractionPoliciesController < Api::V1::Statuses::Base
|
||||||
params.permit(:quote_approval_policy)
|
params.permit(:quote_approval_policy)
|
||||||
end
|
end
|
||||||
|
|
||||||
def check_feature_enabled
|
|
||||||
raise ActionController::RoutingError unless Mastodon::Feature.outgoing_quotes_enabled?
|
|
||||||
end
|
|
||||||
|
|
||||||
def broadcast_updates!
|
def broadcast_updates!
|
||||||
DistributionWorker.perform_async(@status.id, { 'update' => true })
|
DistributionWorker.perform_async(@status.id, { 'update' => true, 'skip_notifications' => true })
|
||||||
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
|
ActivityPub::StatusUpdateDistributionWorker.perform_async(@status.id, { 'updated_at' => Time.now.utc.iso8601 })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,13 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
||||||
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
|
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :index
|
||||||
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
|
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: :revoke
|
||||||
|
|
||||||
before_action :check_owner!
|
before_action :set_statuses, only: :index
|
||||||
|
|
||||||
before_action :set_quote, only: :revoke
|
before_action :set_quote, only: :revoke
|
||||||
after_action :insert_pagination_headers, only: :index
|
after_action :insert_pagination_headers, only: :index
|
||||||
|
|
||||||
def index
|
def index
|
||||||
cache_if_unauthenticated!
|
cache_if_unauthenticated!
|
||||||
@statuses = load_statuses
|
|
||||||
render json: @statuses, each_serializer: REST::StatusSerializer
|
render json: @statuses, each_serializer: REST::StatusSerializer
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -24,18 +24,26 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def check_owner!
|
|
||||||
authorize @status, :list_quotes?
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_quote
|
def set_quote
|
||||||
@quote = @status.quotes.find_by!(status_id: params[:id])
|
@quote = @status.quotes.find_by!(status_id: params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_statuses
|
def set_statuses
|
||||||
scope = default_statuses
|
scope = default_statuses
|
||||||
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
|
scope = scope.not_excluded_by_account(current_account) unless current_account.nil?
|
||||||
scope.merge(paginated_quotes).to_a
|
@statuses = scope.merge(paginated_quotes).to_a
|
||||||
|
|
||||||
|
# Store next page info before filtering
|
||||||
|
@records_continue = @statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
||||||
|
@pagination_since_id = @statuses.first.quote.id unless @statuses.empty?
|
||||||
|
@pagination_max_id = @statuses.last.quote.id if @records_continue
|
||||||
|
|
||||||
|
if current_account&.id != @status.account_id
|
||||||
|
domains = @statuses.filter_map(&:account_domain).uniq
|
||||||
|
account_ids = @statuses.map(&:account_id).uniq
|
||||||
|
relations = current_account&.relations_map(account_ids, domains) || {}
|
||||||
|
@statuses.reject! { |status| StatusFilter.new(status, current_account, relations).filtered? }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_statuses
|
def default_statuses
|
||||||
|
|
@ -58,15 +66,9 @@ class Api::V1::Statuses::QuotesController < Api::V1::Statuses::BaseController
|
||||||
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
|
api_v1_status_quotes_url pagination_params(since_id: pagination_since_id) unless @statuses.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
def pagination_max_id
|
attr_reader :pagination_max_id, :pagination_since_id
|
||||||
@statuses.last.quote.id
|
|
||||||
end
|
|
||||||
|
|
||||||
def pagination_since_id
|
|
||||||
@statuses.first.quote.id
|
|
||||||
end
|
|
||||||
|
|
||||||
def records_continue?
|
def records_continue?
|
||||||
@statuses.size == limit_param(DEFAULT_STATUSES_LIMIT)
|
@records_continue
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -157,8 +157,6 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_quoted_status
|
def set_quoted_status
|
||||||
return unless Mastodon::Feature.outgoing_quotes_enabled?
|
|
||||||
|
|
||||||
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
|
@quoted_status = Status.find(status_params[:quoted_status_id]) if status_params[:quoted_status_id].present?
|
||||||
authorize(@quoted_status, :quote?) if @quoted_status.present?
|
authorize(@quoted_status, :quote?) if @quoted_status.present?
|
||||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,11 @@ module AccountOwnedConcern
|
||||||
end
|
end
|
||||||
|
|
||||||
def set_account
|
def set_account
|
||||||
@account = Account.find_local!(username_param)
|
@account = username_param.present? ? Account.find_local!(username_param) : Account.local.find(account_id_param)
|
||||||
|
end
|
||||||
|
|
||||||
|
def account_id_param
|
||||||
|
params[:account_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def username_param
|
def username_param
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ module Api::InteractionPoliciesConcern
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
def quote_approval_policy
|
def quote_approval_policy
|
||||||
return nil unless Mastodon::Feature.outgoing_quotes_enabled?
|
|
||||||
|
|
||||||
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
|
case status_params[:quote_approval_policy].presence || current_user.setting_default_quote_policy
|
||||||
when 'public'
|
when 'public'
|
||||||
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
Status::QUOTE_APPROVAL_POLICY_FLAGS[:public] << 16
|
||||||
|
|
|
||||||
|
|
@ -60,17 +60,17 @@ class FollowerAccountsController < ApplicationController
|
||||||
def collection_presenter
|
def collection_presenter
|
||||||
if page_requested?
|
if page_requested?
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_followers_url(@account, page: params.fetch(:page, 1)),
|
id: page_url(params.fetch(:page, 1)),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.followers_count,
|
size: @account.followers_count,
|
||||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
|
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.account) },
|
||||||
part_of: account_followers_url(@account),
|
part_of: ActivityPub::TagManager.instance.followers_uri_for(@account),
|
||||||
next: next_page_url,
|
next: next_page_url,
|
||||||
prev: prev_page_url
|
prev: prev_page_url
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_followers_url(@account),
|
id: ActivityPub::TagManager.instance.followers_uri_for(@account),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.followers_count,
|
size: @account.followers_count,
|
||||||
first: page_url(1)
|
first: page_url(1)
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ class FollowingAccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def page_url(page)
|
def page_url(page)
|
||||||
account_following_index_url(@account, page: page) unless page.nil?
|
ActivityPub::TagManager.instance.following_uri_for(@account, page: page) unless page.nil?
|
||||||
end
|
end
|
||||||
|
|
||||||
def next_page_url
|
def next_page_url
|
||||||
|
|
@ -63,17 +63,17 @@ class FollowingAccountsController < ApplicationController
|
||||||
def collection_presenter
|
def collection_presenter
|
||||||
if page_requested?
|
if page_requested?
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_following_index_url(@account, page: params.fetch(:page, 1)),
|
id: page_url(params.fetch(:page, 1)),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.following_count,
|
size: @account.following_count,
|
||||||
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
|
items: follows.map { |follow| ActivityPub::TagManager.instance.uri_for(follow.target_account) },
|
||||||
part_of: account_following_index_url(@account),
|
part_of: ActivityPub::TagManager.instance.following_uri_for(@account),
|
||||||
next: next_page_url,
|
next: next_page_url,
|
||||||
prev: prev_page_url
|
prev: prev_page_url
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
ActivityPub::CollectionPresenter.new(
|
ActivityPub::CollectionPresenter.new(
|
||||||
id: account_following_index_url(@account),
|
id: ActivityPub::TagManager.instance.following_uri_for(@account),
|
||||||
type: :ordered,
|
type: :ordered,
|
||||||
size: @account.following_count,
|
size: @account.following_count,
|
||||||
first: page_url(1)
|
first: page_url(1)
|
||||||
|
|
|
||||||
61
app/javascript/config/html-tags.json
Normal file
61
app/javascript/config/html-tags.json
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"global": {
|
||||||
|
"class": "className",
|
||||||
|
"id": true,
|
||||||
|
"title": true,
|
||||||
|
"dir": true,
|
||||||
|
"lang": true
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"p": {},
|
||||||
|
"br": {
|
||||||
|
"children": false
|
||||||
|
},
|
||||||
|
"span": {
|
||||||
|
"attributes": {
|
||||||
|
"translate": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"a": {
|
||||||
|
"attributes": {
|
||||||
|
"href": true,
|
||||||
|
"rel": true,
|
||||||
|
"translate": true,
|
||||||
|
"target": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"del": {},
|
||||||
|
"s": {},
|
||||||
|
"pre": {},
|
||||||
|
"blockquote": {},
|
||||||
|
"code": {},
|
||||||
|
"b": {},
|
||||||
|
"strong": {},
|
||||||
|
"u": {},
|
||||||
|
"i": {},
|
||||||
|
"img": {
|
||||||
|
"children": false,
|
||||||
|
"attributes": {
|
||||||
|
"src": true,
|
||||||
|
"alt": true,
|
||||||
|
"title": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"em": {},
|
||||||
|
"ul": {},
|
||||||
|
"ol": {
|
||||||
|
"attributes": {
|
||||||
|
"start": true,
|
||||||
|
"reversed": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"li": {
|
||||||
|
"attributes": {
|
||||||
|
"value": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ruby": {},
|
||||||
|
"rt": {},
|
||||||
|
"rp": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -194,8 +194,10 @@ export function submitCompose(successCallback) {
|
||||||
const status = getState().getIn(['compose', 'text'], '');
|
const status = getState().getIn(['compose', 'text'], '');
|
||||||
const media = getState().getIn(['compose', 'media_attachments']);
|
const media = getState().getIn(['compose', 'media_attachments']);
|
||||||
const statusId = getState().getIn(['compose', 'id'], null);
|
const statusId = getState().getIn(['compose', 'id'], null);
|
||||||
|
const hasQuote = !!getState().getIn(['compose', 'quoted_status_id']);
|
||||||
|
const spoiler_text = getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '';
|
||||||
|
|
||||||
if ((!status || !status.length) && media.size === 0) {
|
if (!(status?.length || media.size !== 0 || (hasQuote && spoiler_text?.length))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,11 +229,11 @@ export function submitCompose(successCallback) {
|
||||||
method: statusId === null ? 'post' : 'put',
|
method: statusId === null ? 'post' : 'put',
|
||||||
data: {
|
data: {
|
||||||
status,
|
status,
|
||||||
|
spoiler_text,
|
||||||
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null),
|
||||||
media_ids: media.map(item => item.get('id')),
|
media_ids: media.map(item => item.get('id')),
|
||||||
media_attributes,
|
media_attributes,
|
||||||
sensitive: getState().getIn(['compose', 'sensitive']),
|
sensitive: getState().getIn(['compose', 'sensitive']),
|
||||||
spoiler_text: getState().getIn(['compose', 'spoiler']) ? getState().getIn(['compose', 'spoiler_text'], '') : '',
|
|
||||||
visibility: visibility,
|
visibility: visibility,
|
||||||
poll: getState().getIn(['compose', 'poll'], null),
|
poll: getState().getIn(['compose', 'poll'], null),
|
||||||
language: getState().getIn(['compose', 'language']),
|
language: getState().getIn(['compose', 'language']),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
import { useLinks } from 'mastodon/hooks/useLinks';
|
||||||
|
|
||||||
import { EmojiHTML } from '../features/emoji/emoji_html';
|
|
||||||
import { useAppSelector } from '../store';
|
import { useAppSelector } from '../store';
|
||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
|
import { AnimateEmojiProvider } from './emoji/context';
|
||||||
|
import { EmojiHTML } from './emoji/html';
|
||||||
|
|
||||||
interface AccountBioProps {
|
interface AccountBioProps {
|
||||||
className: string;
|
className: string;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
|
|
@ -44,13 +48,13 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AnimateEmojiProvider
|
||||||
className={`${className} translate`}
|
className={classNames(className, 'translate')}
|
||||||
onClickCapture={handleClick}
|
onClickCapture={handleClick}
|
||||||
ref={handleNodeChange}
|
ref={handleNodeChange}
|
||||||
>
|
>
|
||||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
||||||
</div>
|
</AnimateEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const meta = {
|
||||||
component: Alert,
|
component: Alert,
|
||||||
args: {
|
args: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
isLoading: false,
|
||||||
animateFrom: 'side',
|
animateFrom: 'side',
|
||||||
title: '',
|
title: '',
|
||||||
message: '',
|
message: '',
|
||||||
|
|
@ -20,6 +21,12 @@ const meta = {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
description: 'Animate to the active (displayed) state of the alert',
|
description: 'Animate to the active (displayed) state of the alert',
|
||||||
},
|
},
|
||||||
|
isLoading: {
|
||||||
|
control: 'boolean',
|
||||||
|
type: 'boolean',
|
||||||
|
description:
|
||||||
|
'Display a loading indicator in the alert, replacing the dismiss button if present',
|
||||||
|
},
|
||||||
animateFrom: {
|
animateFrom: {
|
||||||
control: 'radio',
|
control: 'radio',
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
|
@ -108,3 +115,11 @@ export const InSizedContainer: Story = {
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const WithLoadingIndicator: Story = {
|
||||||
|
args: {
|
||||||
|
...WithDismissButton.args,
|
||||||
|
isLoading: true,
|
||||||
|
},
|
||||||
|
render: InSizedContainer.render,
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useIntl } from 'react-intl';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
|
|
||||||
import { IconButton } from '../icon_button';
|
import { IconButton } from '../icon_button';
|
||||||
|
|
||||||
|
|
@ -10,21 +11,23 @@ import { IconButton } from '../icon_button';
|
||||||
* Snackbar/Toast-style notification component.
|
* Snackbar/Toast-style notification component.
|
||||||
*/
|
*/
|
||||||
export const Alert: React.FC<{
|
export const Alert: React.FC<{
|
||||||
isActive?: boolean;
|
|
||||||
animateFrom?: 'side' | 'below';
|
|
||||||
title?: string;
|
title?: string;
|
||||||
message: string;
|
message: string;
|
||||||
action?: string;
|
action?: string;
|
||||||
onActionClick?: () => void;
|
onActionClick?: () => void;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
isLoading?: boolean;
|
||||||
|
animateFrom?: 'side' | 'below';
|
||||||
}> = ({
|
}> = ({
|
||||||
isActive,
|
|
||||||
animateFrom = 'side',
|
|
||||||
title,
|
title,
|
||||||
message,
|
message,
|
||||||
action,
|
action,
|
||||||
onActionClick,
|
onActionClick,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
isActive,
|
||||||
|
isLoading,
|
||||||
|
animateFrom = 'side',
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
|
@ -51,7 +54,13 @@ export const Alert: React.FC<{
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onDismiss && (
|
{isLoading && (
|
||||||
|
<span className='notification-bar__loading-indicator'>
|
||||||
|
<LoadingIndicator />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onDismiss && !isLoading && (
|
||||||
<IconButton
|
<IconButton
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
id: 'dismissable_banner.dismiss',
|
id: 'dismissable_banner.dismiss',
|
||||||
|
|
|
||||||
|
|
@ -30,9 +30,12 @@ const Blurhash: React.FC<Props> = ({
|
||||||
try {
|
try {
|
||||||
const pixels = decode(hash, width, height);
|
const pixels = decode(hash, width, height);
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const imageData = new ImageData(pixels, width, height);
|
const imageData = ctx?.createImageData(width, height);
|
||||||
|
imageData?.data.set(pixels);
|
||||||
|
|
||||||
|
if (imageData) {
|
||||||
ctx?.putImageData(imageData, 0, 0);
|
ctx?.putImageData(imageData, 0, 0);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Blurhash decoding failure', { err, hash });
|
console.error('Blurhash decoding failure', { err, hash });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,10 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
|
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
|
import { AnimateEmojiProvider } from '../emoji/context';
|
||||||
|
import { EmojiHTML } from '../emoji/html';
|
||||||
import { Skeleton } from '../skeleton';
|
import { Skeleton } from '../skeleton';
|
||||||
|
|
||||||
import type { DisplayNameProps } from './index';
|
import type { DisplayNameProps } from './index';
|
||||||
|
|
@ -14,9 +15,10 @@ export const DisplayNameWithoutDomain: FC<
|
||||||
ComponentPropsWithoutRef<'span'>
|
ComponentPropsWithoutRef<'span'>
|
||||||
> = ({ account, className, children, ...props }) => {
|
> = ({ account, className, children, ...props }) => {
|
||||||
return (
|
return (
|
||||||
<span
|
<AnimateEmojiProvider
|
||||||
{...props}
|
{...props}
|
||||||
className={classNames('display-name animate-parent', className)}
|
as='span'
|
||||||
|
className={classNames('display-name', className)}
|
||||||
>
|
>
|
||||||
<bdi>
|
<bdi>
|
||||||
{account ? (
|
{account ? (
|
||||||
|
|
@ -27,8 +29,8 @@ export const DisplayNameWithoutDomain: FC<
|
||||||
? account.get('display_name')
|
? account.get('display_name')
|
||||||
: account.get('display_name_html')
|
: account.get('display_name_html')
|
||||||
}
|
}
|
||||||
shallow
|
|
||||||
as='strong'
|
as='strong'
|
||||||
|
extraEmojis={account.get('emojis')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<strong className='display-name__html'>
|
<strong className='display-name__html'>
|
||||||
|
|
@ -37,6 +39,6 @@ export const DisplayNameWithoutDomain: FC<
|
||||||
)}
|
)}
|
||||||
</bdi>
|
</bdi>
|
||||||
{children}
|
{children}
|
||||||
</span>
|
</AnimateEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
import { EmojiHTML } from '@/mastodon/features/emoji/emoji_html';
|
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
|
import { EmojiHTML } from '../emoji/html';
|
||||||
|
|
||||||
import type { DisplayNameProps } from './index';
|
import type { DisplayNameProps } from './index';
|
||||||
|
|
||||||
export const DisplayNameSimple: FC<
|
export const DisplayNameSimple: FC<
|
||||||
|
|
@ -12,12 +13,19 @@ export const DisplayNameSimple: FC<
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const accountName = isModernEmojiEnabled()
|
|
||||||
? account.get('display_name')
|
|
||||||
: account.get('display_name_html');
|
|
||||||
return (
|
return (
|
||||||
<bdi>
|
<bdi>
|
||||||
<EmojiHTML {...props} htmlString={accountName} shallow as='span' />
|
<EmojiHTML
|
||||||
|
{...props}
|
||||||
|
as='span'
|
||||||
|
htmlString={
|
||||||
|
isModernEmojiEnabled()
|
||||||
|
? account.get('display_name')
|
||||||
|
: account.get('display_name_html')
|
||||||
|
}
|
||||||
|
extraEmojis={account.get('emojis')}
|
||||||
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
108
app/javascript/mastodon/components/emoji/context.tsx
Normal file
108
app/javascript/mastodon/components/emoji/context.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import type { MouseEventHandler, PropsWithChildren } from 'react';
|
||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||||
|
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||||
|
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||||
|
import type {
|
||||||
|
CustomEmojiMapArg,
|
||||||
|
ExtraCustomEmojiMap,
|
||||||
|
} from 'mastodon/features/emoji/types';
|
||||||
|
|
||||||
|
// Animation context
|
||||||
|
export const AnimateEmojiContext = createContext<boolean | null>(null);
|
||||||
|
|
||||||
|
// Polymorphic provider component
|
||||||
|
type AnimateEmojiProviderProps = Required<PropsWithChildren> & {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AnimateEmojiProvider = polymorphicForwardRef<
|
||||||
|
'div',
|
||||||
|
AnimateEmojiProviderProps
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
children,
|
||||||
|
as: Wrapper = 'div',
|
||||||
|
className,
|
||||||
|
onMouseEnter,
|
||||||
|
onMouseLeave,
|
||||||
|
...props
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const [animate, setAnimate] = useState(autoPlayGif ?? false);
|
||||||
|
|
||||||
|
const handleEnter: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
onMouseEnter?.(event);
|
||||||
|
if (!autoPlayGif) {
|
||||||
|
setAnimate(true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onMouseEnter],
|
||||||
|
);
|
||||||
|
const handleLeave: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||||
|
(event) => {
|
||||||
|
onMouseLeave?.(event);
|
||||||
|
if (!autoPlayGif) {
|
||||||
|
setAnimate(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onMouseLeave],
|
||||||
|
);
|
||||||
|
|
||||||
|
// If there's a parent context or GIFs autoplay, we don't need handlers.
|
||||||
|
const parentContext = useContext(AnimateEmojiContext);
|
||||||
|
if (parentContext !== null || autoPlayGif === true) {
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
{...props}
|
||||||
|
className={classNames(className, 'animate-parent')}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Wrapper
|
||||||
|
{...props}
|
||||||
|
className={classNames(className, 'animate-parent')}
|
||||||
|
onMouseEnter={handleEnter}
|
||||||
|
onMouseLeave={handleLeave}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<AnimateEmojiContext.Provider value={animate}>
|
||||||
|
{children}
|
||||||
|
</AnimateEmojiContext.Provider>
|
||||||
|
</Wrapper>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
AnimateEmojiProvider.displayName = 'AnimateEmojiProvider';
|
||||||
|
|
||||||
|
// Handle custom emoji
|
||||||
|
export const CustomEmojiContext = createContext<ExtraCustomEmojiMap>({});
|
||||||
|
|
||||||
|
export const CustomEmojiProvider = ({
|
||||||
|
children,
|
||||||
|
emojis: rawEmojis,
|
||||||
|
}: PropsWithChildren<{ emojis?: CustomEmojiMapArg }>) => {
|
||||||
|
const emojis = useMemo(() => cleanExtraEmojis(rawEmojis) ?? {}, [rawEmojis]);
|
||||||
|
return (
|
||||||
|
<CustomEmojiContext.Provider value={emojis}>
|
||||||
|
{children}
|
||||||
|
</CustomEmojiContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
import { htmlStringToComponents } from '@/mastodon/utils/html';
|
||||||
|
|
||||||
import { useEmojify } from './hooks';
|
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
||||||
import type { CustomEmojiMapArg } from './types';
|
import { textToEmojis } from './index';
|
||||||
|
|
||||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||||
ComponentPropsWithoutRef<Element>,
|
ComponentPropsWithoutRef<Element>,
|
||||||
|
|
@ -14,43 +17,34 @@ type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
as?: Element;
|
as?: Element;
|
||||||
shallow?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ModernEmojiHTML = ({
|
export const ModernEmojiHTML = ({
|
||||||
extraEmojis,
|
extraEmojis,
|
||||||
htmlString,
|
htmlString,
|
||||||
as: Wrapper = 'div', // Rename for syntax highlighting
|
as: asProp = 'div', // Rename for syntax highlighting
|
||||||
shallow,
|
shallow,
|
||||||
className = '',
|
className = '',
|
||||||
...props
|
...props
|
||||||
}: EmojiHTMLProps<ElementType>) => {
|
}: EmojiHTMLProps<ElementType>) => {
|
||||||
const emojifiedHtml = useEmojify({
|
const contents = useMemo(
|
||||||
text: htmlString,
|
() => htmlStringToComponents(htmlString, { onText: textToEmojis }),
|
||||||
extraEmojis,
|
[htmlString],
|
||||||
deep: !shallow,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (emojifiedHtml === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper
|
<CustomEmojiProvider emojis={extraEmojis}>
|
||||||
{...props}
|
<AnimateEmojiProvider {...props} as={asProp} className={className}>
|
||||||
className={classNames(className, 'animate-parent')}
|
{contents}
|
||||||
dangerouslySetInnerHTML={{ __html: emojifiedHtml }}
|
</AnimateEmojiProvider>
|
||||||
/>
|
</CustomEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const EmojiHTML = <Element extends ElementType>(
|
export const LegacyEmojiHTML = <Element extends ElementType>(
|
||||||
props: EmojiHTMLProps<Element>,
|
props: EmojiHTMLProps<Element>,
|
||||||
) => {
|
) => {
|
||||||
if (isModernEmojiEnabled()) {
|
|
||||||
return <ModernEmojiHTML {...props} />;
|
|
||||||
}
|
|
||||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
||||||
const Wrapper = asElement ?? 'div';
|
const Wrapper = asElement ?? 'div';
|
||||||
return (
|
return (
|
||||||
|
|
@ -61,3 +55,7 @@ export const EmojiHTML = <Element extends ElementType>(
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const EmojiHTML = isModernEmojiEnabled()
|
||||||
|
? ModernEmojiHTML
|
||||||
|
: LegacyEmojiHTML;
|
||||||
99
app/javascript/mastodon/components/emoji/index.tsx
Normal file
99
app/javascript/mastodon/components/emoji/index.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import type { FC } from 'react';
|
||||||
|
import { useContext, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import { EMOJI_TYPE_CUSTOM } from '@/mastodon/features/emoji/constants';
|
||||||
|
import { useEmojiAppState } from '@/mastodon/features/emoji/hooks';
|
||||||
|
import { unicodeHexToUrl } from '@/mastodon/features/emoji/normalize';
|
||||||
|
import {
|
||||||
|
isStateLoaded,
|
||||||
|
loadEmojiDataToState,
|
||||||
|
shouldRenderImage,
|
||||||
|
stringToEmojiState,
|
||||||
|
tokenizeText,
|
||||||
|
} from '@/mastodon/features/emoji/render';
|
||||||
|
|
||||||
|
import { AnimateEmojiContext, CustomEmojiContext } from './context';
|
||||||
|
|
||||||
|
interface EmojiProps {
|
||||||
|
code: string;
|
||||||
|
showFallback?: boolean;
|
||||||
|
showLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Emoji: FC<EmojiProps> = ({
|
||||||
|
code,
|
||||||
|
showFallback = true,
|
||||||
|
showLoading = true,
|
||||||
|
}) => {
|
||||||
|
const customEmoji = useContext(CustomEmojiContext);
|
||||||
|
|
||||||
|
// First, set the emoji state based on the input code.
|
||||||
|
const [state, setState] = useState(() =>
|
||||||
|
stringToEmojiState(code, customEmoji),
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we don't have data, then load emoji data asynchronously.
|
||||||
|
const appState = useEmojiAppState();
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== null) {
|
||||||
|
void loadEmojiDataToState(state, appState.currentLocale).then(setState);
|
||||||
|
}
|
||||||
|
}, [appState.currentLocale, state]);
|
||||||
|
|
||||||
|
const animate = useContext(AnimateEmojiContext);
|
||||||
|
const fallback = showFallback ? code : null;
|
||||||
|
|
||||||
|
// If the code is invalid or we otherwise know it's not valid, show the fallback.
|
||||||
|
if (!state) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldRenderImage(state, appState.mode)) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isStateLoaded(state)) {
|
||||||
|
if (showLoading) {
|
||||||
|
return <span className='emojione emoji-loading' title={code} />;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.type === EMOJI_TYPE_CUSTOM) {
|
||||||
|
const shortcode = `:${state.code}:`;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={animate ? state.data.url : state.data.static_url}
|
||||||
|
alt={shortcode}
|
||||||
|
title={shortcode}
|
||||||
|
className='emojione custom-emoji'
|
||||||
|
loading='lazy'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = unicodeHexToUrl(state.code, appState.darkTheme);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={state.data.unicode}
|
||||||
|
title={state.data.label}
|
||||||
|
className='emojione'
|
||||||
|
loading='lazy'
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Takes a text string and converts it to an array of React nodes.
|
||||||
|
* @param text The text to be tokenized and converted.
|
||||||
|
*/
|
||||||
|
export function textToEmojis(text: string) {
|
||||||
|
return tokenizeText(text).map((token, index) => {
|
||||||
|
if (typeof token === 'string') {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
return <Emoji code={token.code} key={`emoji-${token.code}-${index}`} />;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A helper component for managing the rendering of components that
|
||||||
|
* need to stay in the DOM a bit longer to finish their CSS exit animation.
|
||||||
|
*
|
||||||
|
* In the future, replace this component with plain CSS once that is feasible.
|
||||||
|
* This will require broader support for `transition-behavior: allow-discrete`
|
||||||
|
* and https://developer.mozilla.org/en-US/docs/Web/CSS/overlay.
|
||||||
|
*/
|
||||||
|
export const ExitAnimationWrapper: React.FC<{
|
||||||
|
/**
|
||||||
|
* Set this to true to indicate that the nested component should be rendered
|
||||||
|
*/
|
||||||
|
isActive: boolean;
|
||||||
|
/**
|
||||||
|
* How long the component should be rendered after `isActive` was set to `false`
|
||||||
|
*/
|
||||||
|
delayMs?: number;
|
||||||
|
/**
|
||||||
|
* Set this to true to also delay the entry of the nested component until after
|
||||||
|
* another one has exited full.
|
||||||
|
*/
|
||||||
|
withEntryDelay?: boolean;
|
||||||
|
/**
|
||||||
|
* Render prop that provides the nested component with the `delayedIsActive` flag
|
||||||
|
*/
|
||||||
|
children: (delayedIsActive: boolean) => React.ReactNode;
|
||||||
|
}> = ({ isActive = false, delayMs = 500, withEntryDelay, children }) => {
|
||||||
|
const [delayedIsActive, setDelayedIsActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isActive && !withEntryDelay) {
|
||||||
|
setDelayedIsActive(true);
|
||||||
|
|
||||||
|
return () => '';
|
||||||
|
} else {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setDelayedIsActive(isActive);
|
||||||
|
}, delayMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [isActive, delayMs, withEntryDelay]);
|
||||||
|
|
||||||
|
if (!isActive && !delayedIsActive) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children(isActive && delayedIsActive);
|
||||||
|
};
|
||||||
|
|
@ -5,24 +5,61 @@ import { useIntl, defineMessages } from 'react-intl';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { useIdentity } from '@/mastodon/identity_context';
|
import { useIdentity } from '@/mastodon/identity_context';
|
||||||
import { fetchRelationships, followAccount } from 'mastodon/actions/accounts';
|
import {
|
||||||
|
fetchRelationships,
|
||||||
|
followAccount,
|
||||||
|
unmuteAccount,
|
||||||
|
} from 'mastodon/actions/accounts';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { Button } from 'mastodon/components/button';
|
import { Button } from 'mastodon/components/button';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
import { useBreakpoint } from '../features/ui/hooks/useBreakpoint';
|
||||||
|
|
||||||
|
const longMessages = defineMessages({
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
||||||
|
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
||||||
|
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
|
||||||
|
followRequest: {
|
||||||
|
id: 'account.follow_request',
|
||||||
|
defaultMessage: 'Request to follow',
|
||||||
|
},
|
||||||
|
followRequestCancel: {
|
||||||
|
id: 'account.follow_request_cancel',
|
||||||
|
defaultMessage: 'Cancel request',
|
||||||
|
},
|
||||||
editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
editProfile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shortMessages = {
|
||||||
|
...longMessages, // Align type signature of shortMessages and longMessages
|
||||||
|
...defineMessages({
|
||||||
|
followBack: {
|
||||||
|
id: 'account.follow_back_short',
|
||||||
|
defaultMessage: 'Follow back',
|
||||||
|
},
|
||||||
|
followRequest: {
|
||||||
|
id: 'account.follow_request_short',
|
||||||
|
defaultMessage: 'Request',
|
||||||
|
},
|
||||||
|
followRequestCancel: {
|
||||||
|
id: 'account.follow_request_cancel_short',
|
||||||
|
defaultMessage: 'Cancel',
|
||||||
|
},
|
||||||
|
editProfile: { id: 'account.edit_profile_short', defaultMessage: 'Edit' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
export const FollowButton: React.FC<{
|
export const FollowButton: React.FC<{
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}> = ({ accountId, compact }) => {
|
labelLength?: 'auto' | 'short' | 'long';
|
||||||
|
className?: string;
|
||||||
|
}> = ({ accountId, compact, labelLength = 'auto', className }) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { signedIn } = useIdentity();
|
const { signedIn } = useIdentity();
|
||||||
|
|
@ -57,29 +94,60 @@ export const FollowButton: React.FC<{
|
||||||
|
|
||||||
if (accountId === me) {
|
if (accountId === me) {
|
||||||
return;
|
return;
|
||||||
} else if (account && (relationship.following || relationship.requested)) {
|
} else if (relationship.muting) {
|
||||||
|
dispatch(unmuteAccount(accountId));
|
||||||
|
} else if (account && relationship.following) {
|
||||||
dispatch(
|
dispatch(
|
||||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
||||||
);
|
);
|
||||||
|
} else if (account && relationship.requested) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM_WITHDRAW_REQUEST',
|
||||||
|
modalProps: { account },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (relationship.blocking) {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'CONFIRM_UNBLOCK',
|
||||||
|
modalProps: { account },
|
||||||
|
}),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(followAccount(accountId));
|
dispatch(followAccount(accountId));
|
||||||
}
|
}
|
||||||
}, [dispatch, accountId, relationship, account, signedIn]);
|
}, [dispatch, accountId, relationship, account, signedIn]);
|
||||||
|
|
||||||
|
const isNarrow = useBreakpoint('narrow');
|
||||||
|
const useShortLabel =
|
||||||
|
labelLength === 'short' || (labelLength === 'auto' && isNarrow);
|
||||||
|
const messages = useShortLabel ? shortMessages : longMessages;
|
||||||
|
|
||||||
|
const followMessage = account?.locked
|
||||||
|
? messages.followRequest
|
||||||
|
: messages.follow;
|
||||||
|
|
||||||
let label;
|
let label;
|
||||||
|
|
||||||
if (!signedIn) {
|
if (!signedIn) {
|
||||||
label = intl.formatMessage(messages.follow);
|
label = intl.formatMessage(followMessage);
|
||||||
} else if (accountId === me) {
|
} else if (accountId === me) {
|
||||||
label = intl.formatMessage(messages.editProfile);
|
label = intl.formatMessage(messages.editProfile);
|
||||||
} else if (!relationship) {
|
} else if (!relationship) {
|
||||||
label = <LoadingIndicator />;
|
label = <LoadingIndicator />;
|
||||||
} else if (relationship.following || relationship.requested) {
|
} else if (relationship.muting) {
|
||||||
|
label = intl.formatMessage(messages.unmute);
|
||||||
|
} else if (relationship.following) {
|
||||||
label = intl.formatMessage(messages.unfollow);
|
label = intl.formatMessage(messages.unfollow);
|
||||||
} else if (relationship.followed_by) {
|
} else if (relationship.blocking) {
|
||||||
|
label = intl.formatMessage(messages.unblock);
|
||||||
|
} else if (relationship.requested) {
|
||||||
|
label = intl.formatMessage(messages.followRequestCancel);
|
||||||
|
} else if (relationship.followed_by && !account?.locked) {
|
||||||
label = intl.formatMessage(messages.followBack);
|
label = intl.formatMessage(messages.followBack);
|
||||||
} else {
|
} else {
|
||||||
label = intl.formatMessage(messages.follow);
|
label = intl.formatMessage(followMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountId === me) {
|
if (accountId === me) {
|
||||||
|
|
@ -88,7 +156,7 @@ export const FollowButton: React.FC<{
|
||||||
href='/settings/profile'
|
href='/settings/profile'
|
||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener'
|
rel='noopener'
|
||||||
className={classNames('button button-secondary', {
|
className={classNames(className, 'button button-secondary', {
|
||||||
'button--compact': compact,
|
'button--compact': compact,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
@ -102,13 +170,12 @@ export const FollowButton: React.FC<{
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
disabled={
|
disabled={
|
||||||
relationship?.blocked_by ||
|
relationship?.blocked_by ||
|
||||||
relationship?.blocking ||
|
|
||||||
(!(relationship?.following || relationship?.requested) &&
|
(!(relationship?.following || relationship?.requested) &&
|
||||||
(account?.suspended || !!account?.moved))
|
(account?.suspended || !!account?.moved))
|
||||||
}
|
}
|
||||||
secondary={following}
|
secondary={following}
|
||||||
compact={compact}
|
compact={compact}
|
||||||
className={following ? 'button--destructive' : undefined}
|
className={classNames(className, { 'button--destructive': following })}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
|
||||||
return (
|
return (
|
||||||
element instanceof HTMLAnchorElement &&
|
element instanceof HTMLAnchorElement &&
|
||||||
// it may be a <a> starting with a hashtag
|
// it may be a <a> starting with a hashtag
|
||||||
(element.textContent?.[0] === '#' ||
|
(element.textContent.startsWith('#') ||
|
||||||
// or a #<a>
|
// or a #<a>
|
||||||
element.previousSibling?.textContent?.[
|
element.previousSibling?.textContent?.[
|
||||||
element.previousSibling.textContent.length - 1
|
element.previousSibling.textContent.length - 1
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
import { expect } from 'storybook/test';
|
||||||
|
|
||||||
|
import { HTMLBlock } from './index';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/HTMLBlock',
|
||||||
|
component: HTMLBlock,
|
||||||
|
args: {
|
||||||
|
contents:
|
||||||
|
'<p>Hello, world!</p>\n<p><a href="#">A link</a></p>\n<p>This should be filtered out: <button>Bye!</button></p>',
|
||||||
|
},
|
||||||
|
render(args) {
|
||||||
|
return (
|
||||||
|
// Just for visual clarity in Storybook.
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
border: '1px solid black',
|
||||||
|
padding: '1rem',
|
||||||
|
minWidth: '300px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HTMLBlock {...args} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
} satisfies Meta<typeof HTMLBlock>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
async play({ canvas }) {
|
||||||
|
const link = canvas.queryByRole('link');
|
||||||
|
await expect(link).toBeInTheDocument();
|
||||||
|
const button = canvas.queryByRole('button');
|
||||||
|
await expect(button).not.toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
50
app/javascript/mastodon/components/html_block/index.tsx
Normal file
50
app/javascript/mastodon/components/html_block/index.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import type { FC, ReactNode } from 'react';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
|
import { cleanExtraEmojis } from '@/mastodon/features/emoji/normalize';
|
||||||
|
import type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||||
|
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||||
|
|
||||||
|
import { htmlStringToComponents } from '../../utils/html';
|
||||||
|
|
||||||
|
// Use a module-level cache to avoid re-rendering the same HTML multiple times.
|
||||||
|
const cache = createLimitedCache<ReactNode>({ maxSize: 1000 });
|
||||||
|
|
||||||
|
interface HTMLBlockProps {
|
||||||
|
contents: string;
|
||||||
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HTMLBlock: FC<HTMLBlockProps> = ({
|
||||||
|
contents: raw,
|
||||||
|
extraEmojis,
|
||||||
|
}) => {
|
||||||
|
const customEmojis = useMemo(
|
||||||
|
() => cleanExtraEmojis(extraEmojis),
|
||||||
|
[extraEmojis],
|
||||||
|
);
|
||||||
|
const contents = useMemo(() => {
|
||||||
|
const key = JSON.stringify({ raw, customEmojis });
|
||||||
|
if (cache.has(key)) {
|
||||||
|
return cache.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendered = htmlStringToComponents(raw, {
|
||||||
|
onText,
|
||||||
|
extraArgs: { customEmojis },
|
||||||
|
});
|
||||||
|
|
||||||
|
cache.set(key, rendered);
|
||||||
|
return rendered;
|
||||||
|
}, [raw, customEmojis]);
|
||||||
|
|
||||||
|
return contents;
|
||||||
|
};
|
||||||
|
|
||||||
|
function onText(
|
||||||
|
text: string,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Doesn't do anything, just showing how typing would work.
|
||||||
|
{ customEmojis }: { customEmojis: CustomEmojiMapArg | null },
|
||||||
|
) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { PropsWithChildren } from 'react';
|
import type { PropsWithChildren } from 'react';
|
||||||
import type React from 'react';
|
import type React from 'react';
|
||||||
|
|
||||||
|
import type { useLocation } from 'react-router';
|
||||||
import { Router as OriginalRouter, useHistory } from 'react-router';
|
import { Router as OriginalRouter, useHistory } from 'react-router';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -18,7 +19,9 @@ interface MastodonLocationState {
|
||||||
mastodonModalKey?: string;
|
mastodonModalKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type LocationState = MastodonLocationState | null | undefined;
|
export type LocationState = MastodonLocationState | null | undefined;
|
||||||
|
|
||||||
|
export type MastodonLocation = ReturnType<typeof useLocation<LocationState>>;
|
||||||
|
|
||||||
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
type HistoryPath = Path | LocationDescriptor<LocationState>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { connect } from 'react-redux';
|
||||||
import { supportsPassiveEvents } from 'detect-passive-events';
|
import { supportsPassiveEvents } from 'detect-passive-events';
|
||||||
import { throttle } from 'lodash';
|
import { throttle } from 'lodash';
|
||||||
|
|
||||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
import { ScrollContainer } from 'mastodon/containers/scroll_container';
|
||||||
|
|
||||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||||
|
|
@ -399,7 +399,7 @@ class ScrollableList extends PureComponent {
|
||||||
|
|
||||||
if (trackScroll) {
|
if (trackScroll) {
|
||||||
return (
|
return (
|
||||||
<ScrollContainer scrollKey={scrollKey}>
|
<ScrollContainer scrollKey={scrollKey} childRef={this.setRef}>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,7 @@ class Status extends ImmutablePureComponent {
|
||||||
unread: PropTypes.bool,
|
unread: PropTypes.bool,
|
||||||
showThread: PropTypes.bool,
|
showThread: PropTypes.bool,
|
||||||
isQuotedPost: PropTypes.bool,
|
isQuotedPost: PropTypes.bool,
|
||||||
|
shouldHighlightOnMount: PropTypes.bool,
|
||||||
getScrollPosition: PropTypes.func,
|
getScrollPosition: PropTypes.func,
|
||||||
updateScrollBottom: PropTypes.func,
|
updateScrollBottom: PropTypes.func,
|
||||||
cacheMediaWidth: PropTypes.func,
|
cacheMediaWidth: PropTypes.func,
|
||||||
|
|
@ -567,6 +568,7 @@ class Status extends ImmutablePureComponent {
|
||||||
'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted,
|
'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted,
|
||||||
'status--is-quote': isQuotedPost,
|
'status--is-quote': isQuotedPost,
|
||||||
'status--has-quote': !!status.get('quote'),
|
'status--has-quote': !!status.get('quote'),
|
||||||
|
'status--highlighted-entry': this.props.shouldHighlightOnMount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
data-id={status.get('id')}
|
data-id={status.get('id')}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||||
import { statusFactoryState } from '@/testing/factories';
|
import { statusFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
import { LegacyReblogButton, StatusBoostButton } from './boost_button';
|
import { BoostButton } from './boost_button';
|
||||||
|
|
||||||
interface StoryProps {
|
interface StoryProps {
|
||||||
visibility: StatusVisibility;
|
visibility: StatusVisibility;
|
||||||
|
|
@ -38,10 +38,7 @@ const meta = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
render: (args) => (
|
render: (args) => (
|
||||||
<StatusBoostButton
|
<BoostButton status={argsToStatus(args)} counters={args.reblogCount > 0} />
|
||||||
status={argsToStatus(args)}
|
|
||||||
counters={args.reblogCount > 0}
|
|
||||||
/>
|
|
||||||
),
|
),
|
||||||
} satisfies Meta<StoryProps>;
|
} satisfies Meta<StoryProps>;
|
||||||
|
|
||||||
|
|
@ -78,12 +75,3 @@ export const Mine: Story = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Legacy: Story = {
|
|
||||||
render: (args) => (
|
|
||||||
<LegacyReblogButton
|
|
||||||
status={argsToStatus(args)}
|
|
||||||
counters={args.reblogCount > 0}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
import type { FC, KeyboardEvent, MouseEvent, MouseEventHandler } from 'react';
|
import type { FC, KeyboardEvent, MouseEvent } from 'react';
|
||||||
|
|
||||||
import { useIntl } from 'react-intl';
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
|
@ -11,7 +11,6 @@ import { openModal } from '@/mastodon/actions/modal';
|
||||||
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
|
import type { ActionMenuItem } from '@/mastodon/models/dropdown_menu';
|
||||||
import type { Status } from '@/mastodon/models/status';
|
import type { Status } from '@/mastodon/models/status';
|
||||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
|
||||||
import type { SomeRequired } from '@/mastodon/utils/types';
|
import type { SomeRequired } from '@/mastodon/utils/types';
|
||||||
|
|
||||||
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
import type { RenderItemFn, RenderItemFnHandlers } from '../dropdown_menu';
|
||||||
|
|
@ -47,10 +46,7 @@ interface ReblogButtonProps {
|
||||||
|
|
||||||
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
|
type ActionMenuItemWithIcon = SomeRequired<ActionMenuItem, 'icon'>;
|
||||||
|
|
||||||
export const StatusBoostButton: FC<ReblogButtonProps> = ({
|
export const BoostButton: FC<ReblogButtonProps> = ({ status, counters }) => {
|
||||||
status,
|
|
||||||
counters,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const statusState = useAppSelector((state) =>
|
const statusState = useAppSelector((state) =>
|
||||||
|
|
@ -192,65 +188,3 @@ const ReblogMenuItem: FC<ReblogMenuItemProps> = ({
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Legacy helpers
|
|
||||||
|
|
||||||
// Switch between the legacy and new reblog button based on feature flag.
|
|
||||||
export const BoostButton: FC<ReblogButtonProps> = (props) => {
|
|
||||||
if (isFeatureEnabled('outgoing_quotes')) {
|
|
||||||
return <StatusBoostButton {...props} />;
|
|
||||||
}
|
|
||||||
return <LegacyReblogButton {...props} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const LegacyReblogButton: FC<ReblogButtonProps> = ({
|
|
||||||
status,
|
|
||||||
counters,
|
|
||||||
}) => {
|
|
||||||
const intl = useIntl();
|
|
||||||
const statusState = useAppSelector((state) =>
|
|
||||||
selectStatusState(state, status),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { title, meta, iconComponent, disabled } = useMemo(
|
|
||||||
() => boostItemState(statusState),
|
|
||||||
[statusState],
|
|
||||||
);
|
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const handleClick: MouseEventHandler = useCallback(
|
|
||||||
(event) => {
|
|
||||||
if (statusState.isLoggedIn) {
|
|
||||||
dispatch(toggleReblog(status.get('id') as string, event.shiftKey));
|
|
||||||
} else {
|
|
||||||
dispatch(
|
|
||||||
openModal({
|
|
||||||
modalType: 'INTERACTION',
|
|
||||||
modalProps: {
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
|
||||||
url: status.get('uri'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dispatch, status, statusState.isLoggedIn],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
disabled={disabled}
|
|
||||||
active={!!status.get('reblogged')}
|
|
||||||
title={intl.formatMessage(meta ?? title)}
|
|
||||||
icon='retweet'
|
|
||||||
iconComponent={iconComponent}
|
|
||||||
onClick={!disabled ? handleClick : undefined}
|
|
||||||
counter={
|
|
||||||
counters
|
|
||||||
? (status.get('reblogs_count') as number) +
|
|
||||||
(status.get('quotes_count') as number)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { me } from '../../initial_state';
|
import { me } from '../../initial_state';
|
||||||
|
|
||||||
import { IconButton } from '../icon_button';
|
import { IconButton } from '../icon_button';
|
||||||
import { isFeatureEnabled } from '../../utils/environment';
|
|
||||||
import { BoostButton } from '../status/boost_button';
|
import { BoostButton } from '../status/boost_button';
|
||||||
import { RemoveQuoteHint } from './remove_quote_hint';
|
import { RemoveQuoteHint } from './remove_quote_hint';
|
||||||
|
|
||||||
|
|
@ -281,7 +280,7 @@ class StatusActionBar extends ImmutablePureComponent {
|
||||||
|
|
||||||
if (writtenByMe || withDismiss) {
|
if (writtenByMe || withDismiss) {
|
||||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||||
if (writtenByMe && isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
|
if (writtenByMe && !['private', 'direct'].includes(status.get('visibility'))) {
|
||||||
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
||||||
}
|
}
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,12 @@ import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react'
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { Poll } from 'mastodon/components/poll';
|
import { Poll } from 'mastodon/components/poll';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { autoPlayGif, languages as preloadedLanguages } from 'mastodon/initial_state';
|
import { languages as preloadedLanguages } from 'mastodon/initial_state';
|
||||||
import { EmojiHTML } from '../features/emoji/emoji_html';
|
|
||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
|
import { EmojiHTML } from './emoji/html';
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { fetchServer } from 'mastodon/actions/server';
|
||||||
import { hydrateStore } from 'mastodon/actions/store';
|
import { hydrateStore } from 'mastodon/actions/store';
|
||||||
import { Router } from 'mastodon/components/router';
|
import { Router } from 'mastodon/components/router';
|
||||||
import Compose from 'mastodon/features/standalone/compose';
|
import Compose from 'mastodon/features/standalone/compose';
|
||||||
import initialState from 'mastodon/initial_state';
|
import { initialState } from 'mastodon/initial_state';
|
||||||
import { IntlProvider } from 'mastodon/locales';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
import { store } from 'mastodon/store';
|
import { store } from 'mastodon/store';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { Route } from 'react-router-dom';
|
||||||
|
|
||||||
import { Provider as ReduxProvider } from 'react-redux';
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
|
|
||||||
import { ScrollContext } from 'react-router-scroll-4';
|
|
||||||
|
|
||||||
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
import { fetchCustomEmojis } from 'mastodon/actions/custom_emojis';
|
||||||
import { hydrateStore } from 'mastodon/actions/store';
|
import { hydrateStore } from 'mastodon/actions/store';
|
||||||
|
|
@ -14,12 +13,14 @@ import ErrorBoundary from 'mastodon/components/error_boundary';
|
||||||
import { Router } from 'mastodon/components/router';
|
import { Router } from 'mastodon/components/router';
|
||||||
import UI from 'mastodon/features/ui';
|
import UI from 'mastodon/features/ui';
|
||||||
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
|
import { IdentityContext, createIdentityContext } from 'mastodon/identity_context';
|
||||||
import initialState, { title as siteTitle } from 'mastodon/initial_state';
|
import { initialState, title as siteTitle } from 'mastodon/initial_state';
|
||||||
import { IntlProvider } from 'mastodon/locales';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
import { store } from 'mastodon/store';
|
import { store } from 'mastodon/store';
|
||||||
import { isProduction } from 'mastodon/utils/environment';
|
import { isProduction } from 'mastodon/utils/environment';
|
||||||
import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock';
|
import { BodyScrollLock } from 'mastodon/features/ui/components/body_scroll_lock';
|
||||||
|
|
||||||
|
import { ScrollContext } from './scroll_container/scroll_context';
|
||||||
|
|
||||||
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
const title = isProduction() ? siteTitle : `${siteTitle} (Dev)`;
|
||||||
|
|
||||||
const hydrateAction = hydrateStore(initialState);
|
const hydrateAction = hydrateStore(initialState);
|
||||||
|
|
@ -45,10 +46,6 @@ export default class Mastodon extends PureComponent {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldUpdateScroll (prevRouterProps, { location }) {
|
|
||||||
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<IdentityContext.Provider value={this.identity}>
|
<IdentityContext.Provider value={this.identity}>
|
||||||
|
|
@ -56,7 +53,7 @@ export default class Mastodon extends PureComponent {
|
||||||
<ReduxProvider store={store}>
|
<ReduxProvider store={store}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Router>
|
<Router>
|
||||||
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContext>
|
||||||
<Route path='/' component={UI} />
|
<Route path='/' component={UI} />
|
||||||
</ScrollContext>
|
</ScrollContext>
|
||||||
<BodyScrollLock />
|
<BodyScrollLock />
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
import { ScrollContainer as OriginalScrollContainer } from 'react-router-scroll-4';
|
|
||||||
|
|
||||||
// ScrollContainer is used to automatically scroll to the top when pushing a
|
|
||||||
// new history state and remembering the scroll position when going back.
|
|
||||||
// There are a few things we need to do differently, though.
|
|
||||||
const defaultShouldUpdateScroll = (prevRouterProps, { location }) => {
|
|
||||||
// If the change is caused by opening a modal, do not scroll to top
|
|
||||||
return !(location.state?.mastodonModalKey && location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default
|
|
||||||
class ScrollContainer extends OriginalScrollContainer {
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
shouldUpdateScroll: defaultShouldUpdateScroll,
|
|
||||||
};
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import type { MastodonLocation } from 'mastodon/components/router';
|
||||||
|
|
||||||
|
export type ShouldUpdateScrollFn = (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) => boolean;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ScrollBehavior will automatically scroll to the top on navigations
|
||||||
|
* or restore saved scroll positions, but on some location changes we
|
||||||
|
* need to prevent this.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const defaultShouldUpdateScroll: ShouldUpdateScrollFn = (
|
||||||
|
prevLocation,
|
||||||
|
location,
|
||||||
|
) => {
|
||||||
|
// If the change is caused by opening a modal, do not scroll to top
|
||||||
|
const shouldUpdateScroll = !(
|
||||||
|
location.state?.mastodonModalKey &&
|
||||||
|
location.state.mastodonModalKey !== prevLocation?.state?.mastodonModalKey
|
||||||
|
);
|
||||||
|
|
||||||
|
return shouldUpdateScroll;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import React, {
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useImperativeHandle,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
|
||||||
|
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
|
||||||
|
import { ScrollBehaviorContext } from './scroll_context';
|
||||||
|
|
||||||
|
interface ScrollContainerProps {
|
||||||
|
/**
|
||||||
|
* This key must be static for the element & not change
|
||||||
|
* while the component is mounted.
|
||||||
|
*/
|
||||||
|
scrollKey: string;
|
||||||
|
shouldUpdateScroll?: ShouldUpdateScrollFn;
|
||||||
|
childRef?: React.ForwardedRef<HTMLElement | undefined>;
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ScrollContainer` is used to manage the scroll position of elements on the page
|
||||||
|
* that can be scrolled independently of the page body.
|
||||||
|
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ScrollContainer: React.FC<ScrollContainerProps> = ({
|
||||||
|
children,
|
||||||
|
scrollKey,
|
||||||
|
childRef,
|
||||||
|
shouldUpdateScroll = defaultShouldUpdateScroll,
|
||||||
|
}) => {
|
||||||
|
const scrollBehaviorContext = useContext(ScrollBehaviorContext);
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLElement>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If a childRef is passed, sync it with the containerRef. This
|
||||||
|
* is necessary because in this component's return statement,
|
||||||
|
* we're overwriting the immediate child component's ref prop.
|
||||||
|
*/
|
||||||
|
useImperativeHandle(childRef, () => containerRef.current, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register/unregister scrollable element with ScrollBehavior
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scrollBehaviorContext || !containerRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollBehaviorContext.registerElement(
|
||||||
|
scrollKey,
|
||||||
|
containerRef.current,
|
||||||
|
(prevLocation, location) => {
|
||||||
|
// Hack to allow accessing scrollBehavior._stateStorage
|
||||||
|
return shouldUpdateScroll.call(
|
||||||
|
scrollBehaviorContext.scrollBehavior,
|
||||||
|
prevLocation,
|
||||||
|
location,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollBehaviorContext.unregisterElement(scrollKey);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return React.Children.only(
|
||||||
|
React.cloneElement(children, { ref: containerRef }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { useLocation, useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { LocationBase } from 'scroll-behavior';
|
||||||
|
import ScrollBehavior from 'scroll-behavior';
|
||||||
|
|
||||||
|
import type {
|
||||||
|
LocationState,
|
||||||
|
MastodonLocation,
|
||||||
|
} from 'mastodon/components/router';
|
||||||
|
import { usePrevious } from 'mastodon/hooks/usePrevious';
|
||||||
|
|
||||||
|
import { defaultShouldUpdateScroll } from './default_should_update_scroll';
|
||||||
|
import type { ShouldUpdateScrollFn } from './default_should_update_scroll';
|
||||||
|
import { SessionStorage } from './state_storage';
|
||||||
|
|
||||||
|
type ScrollBehaviorInstance = InstanceType<
|
||||||
|
typeof ScrollBehavior<LocationBase, MastodonLocation>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export interface ScrollBehaviorContextType {
|
||||||
|
registerElement: (
|
||||||
|
key: string,
|
||||||
|
element: HTMLElement,
|
||||||
|
shouldUpdateScroll: (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) => boolean,
|
||||||
|
) => void;
|
||||||
|
unregisterElement: (key: string) => void;
|
||||||
|
scrollBehavior?: ScrollBehaviorInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScrollBehaviorContext =
|
||||||
|
React.createContext<ScrollBehaviorContextType | null>(null);
|
||||||
|
|
||||||
|
interface ScrollContextProps {
|
||||||
|
shouldUpdateScroll?: ShouldUpdateScrollFn;
|
||||||
|
children: React.ReactElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A top-level wrapper that provides the app with an instance of the
|
||||||
|
* ScrollBehavior object. scroll-behavior is a library for managing the
|
||||||
|
* scroll position of a single-page app in the same way the browser would
|
||||||
|
* normally do for a multi-page app. This means it'll scroll back to top
|
||||||
|
* when navigating to a new page, and will restore the scroll position
|
||||||
|
* when navigating e.g. using `history.back`.
|
||||||
|
* The library keeps a record of scroll positions in session storage.
|
||||||
|
*
|
||||||
|
* This component is a port of the unmaintained https://github.com/ytase/react-router-scroll/
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ScrollContext: React.FC<ScrollContextProps> = ({
|
||||||
|
children,
|
||||||
|
shouldUpdateScroll = defaultShouldUpdateScroll,
|
||||||
|
}) => {
|
||||||
|
const location = useLocation<LocationState>();
|
||||||
|
const history = useHistory<LocationState>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep the current location in a mutable ref so that ScrollBehavior's
|
||||||
|
* `getCurrentLocation` can access it without having to recreate the
|
||||||
|
* whole ScrollBehavior object
|
||||||
|
*/
|
||||||
|
const currentLocationRef = useRef(location);
|
||||||
|
useEffect(() => {
|
||||||
|
currentLocationRef.current = location;
|
||||||
|
}, [location]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise ScrollBehavior object once – using state rather
|
||||||
|
* than a ref to simplify the types and ensure it's defined immediately.
|
||||||
|
*/
|
||||||
|
const [scrollBehavior] = useState(
|
||||||
|
(): ScrollBehaviorInstance =>
|
||||||
|
new ScrollBehavior({
|
||||||
|
addNavigationListener: history.listen.bind(history),
|
||||||
|
stateStorage: new SessionStorage(),
|
||||||
|
getCurrentLocation: () =>
|
||||||
|
currentLocationRef.current as unknown as LocationBase,
|
||||||
|
shouldUpdateScroll: (
|
||||||
|
prevLocationContext: MastodonLocation | null,
|
||||||
|
locationContext: MastodonLocation,
|
||||||
|
) =>
|
||||||
|
// Hack to allow accessing scrollBehavior._stateStorage
|
||||||
|
shouldUpdateScroll.call(
|
||||||
|
scrollBehavior,
|
||||||
|
prevLocationContext,
|
||||||
|
locationContext,
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle scroll update when location changes
|
||||||
|
*/
|
||||||
|
const prevLocation = usePrevious(location) ?? null;
|
||||||
|
useEffect(() => {
|
||||||
|
scrollBehavior.updateScroll(prevLocation, location);
|
||||||
|
}, [location, prevLocation, scrollBehavior]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop Scrollbehavior on unmount
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
scrollBehavior.stop();
|
||||||
|
};
|
||||||
|
}, [scrollBehavior]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provide the app with a way to register separately scrollable
|
||||||
|
* elements to also be tracked by ScrollBehavior. (By default
|
||||||
|
* ScrollBehavior only handles scrolling on the main document body.)
|
||||||
|
*/
|
||||||
|
const contextValue = useMemo<ScrollBehaviorContextType>(
|
||||||
|
() => ({
|
||||||
|
registerElement: (key, element, shouldUpdateScroll) => {
|
||||||
|
scrollBehavior.registerElement(
|
||||||
|
key,
|
||||||
|
element,
|
||||||
|
shouldUpdateScroll,
|
||||||
|
location,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
unregisterElement: (key) => {
|
||||||
|
scrollBehavior.unregisterElement(key);
|
||||||
|
},
|
||||||
|
scrollBehavior,
|
||||||
|
}),
|
||||||
|
[location, scrollBehavior],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollBehaviorContext.Provider value={contextValue}>
|
||||||
|
{React.Children.only(children)}
|
||||||
|
</ScrollBehaviorContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import type { LocationBase, ScrollPosition } from 'scroll-behavior';
|
||||||
|
|
||||||
|
const STATE_KEY_PREFIX = '@@scroll|';
|
||||||
|
|
||||||
|
interface LocationBaseWithKey extends LocationBase {
|
||||||
|
key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This module is part of our port of https://github.com/ytase/react-router-scroll/
|
||||||
|
* and handles storing scroll positions in SessionStorage.
|
||||||
|
* Stored positions (`[x, y]`) are keyed by the location key and an optional
|
||||||
|
* `scrollKey` that's used for to track separately scrollable elements other
|
||||||
|
* than the document body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SessionStorage {
|
||||||
|
read(
|
||||||
|
location: LocationBaseWithKey,
|
||||||
|
key: string | null,
|
||||||
|
): ScrollPosition | null {
|
||||||
|
const stateKey = this.getStateKey(location, key);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const value = sessionStorage.getItem(stateKey);
|
||||||
|
return value ? (JSON.parse(value) as ScrollPosition) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save(location: LocationBaseWithKey, key: string | null, value: unknown) {
|
||||||
|
const stateKey = this.getStateKey(location, key);
|
||||||
|
const storedValue = JSON.stringify(value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(stateKey, storedValue);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStateKey(location: LocationBaseWithKey, key: string | null) {
|
||||||
|
const locationKey = location.key;
|
||||||
|
const stateKeyBase = `${STATE_KEY_PREFIX}${locationKey}`;
|
||||||
|
return key == null ? stateKeyBase : `${stateKeyBase}|${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -47,8 +47,6 @@ import Status from '../components/status';
|
||||||
import { deleteModal } from '../initial_state';
|
import { deleteModal } from '../initial_state';
|
||||||
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
import { makeGetStatus, makeGetPictureInPicture } from '../selectors';
|
||||||
|
|
||||||
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
const makeMapStateToProps = () => {
|
||||||
const getStatus = makeGetStatus();
|
const getStatus = makeGetStatus();
|
||||||
const getPictureInPicture = makeGetPictureInPicture();
|
const getPictureInPicture = makeGetPictureInPicture();
|
||||||
|
|
@ -81,9 +79,7 @@ const mapDispatchToProps = (dispatch, { contextType }) => ({
|
||||||
},
|
},
|
||||||
|
|
||||||
onQuote (status) {
|
onQuote (status) {
|
||||||
if (isFeatureEnabled('outgoing_quotes')) {
|
|
||||||
dispatch(quoteComposeById(status.get('id')));
|
dispatch(quoteComposeById(status.get('id')));
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
onFavourite (status) {
|
onFavourite (status) {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||||
import { DisplayName } from '@/mastodon/components/display_name';
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
|
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
|
|
@ -33,7 +34,6 @@ import { initMuteModal } from 'mastodon/actions/mutes';
|
||||||
import { initReport } from 'mastodon/actions/reports';
|
import { initReport } from 'mastodon/actions/reports';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
|
import { Badge, AutomatedBadge, GroupBadge } from 'mastodon/components/badge';
|
||||||
import { Button } from 'mastodon/components/button';
|
|
||||||
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
|
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
|
||||||
import {
|
import {
|
||||||
FollowersCounter,
|
FollowersCounter,
|
||||||
|
|
@ -383,7 +383,7 @@ export const AccountHeader: React.FC<{
|
||||||
const isRemote = account?.acct !== account?.username;
|
const isRemote = account?.acct !== account?.username;
|
||||||
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
const remoteDomain = isRemote ? account?.acct.split('@')[1] : null;
|
||||||
|
|
||||||
const menu = useMemo(() => {
|
const menuItems = useMemo(() => {
|
||||||
const arr: MenuItem[] = [];
|
const arr: MenuItem[] = [];
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
|
|
@ -605,6 +605,15 @@ export const AccountHeader: React.FC<{
|
||||||
handleUnblockDomain,
|
handleUnblockDomain,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const menu = accountId !== me && (
|
||||||
|
<Dropdown
|
||||||
|
disabled={menuItems.length === 0}
|
||||||
|
items={menuItems}
|
||||||
|
icon='ellipsis-v'
|
||||||
|
iconComponent={MoreHorizIcon}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -718,21 +727,16 @@ export const AccountHeader: React.FC<{
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (relationship?.blocking) {
|
const isMovedAndUnfollowedAccount = account.moved && !relationship?.following;
|
||||||
|
|
||||||
|
if (!isMovedAndUnfollowedAccount) {
|
||||||
actionBtn = (
|
actionBtn = (
|
||||||
<Button
|
<FollowButton
|
||||||
text={intl.formatMessage(messages.unblock, {
|
accountId={accountId}
|
||||||
name: account.username,
|
className='account__header__follow-button'
|
||||||
})}
|
labelLength='long'
|
||||||
onClick={handleBlock}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
actionBtn = <FollowButton accountId={accountId} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (account.moved && !relationship?.following) {
|
|
||||||
actionBtn = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.locked) {
|
if (account.locked) {
|
||||||
|
|
@ -777,8 +781,8 @@ export const AccountHeader: React.FC<{
|
||||||
<MovedNote accountId={account.id} targetAccountId={account.moved} />
|
<MovedNote accountId={account.id} targetAccountId={account.moved} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<AnimateEmojiProvider
|
||||||
className={classNames('account__header animate-parent', {
|
className={classNames('account__header', {
|
||||||
inactive: !!account.moved,
|
inactive: !!account.moved,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
|
@ -814,18 +818,11 @@ export const AccountHeader: React.FC<{
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div className='account__header__tabs__buttons'>
|
<div className='account__header__buttons account__header__buttons--desktop'>
|
||||||
|
{!hidden && actionBtn}
|
||||||
{!hidden && bellBtn}
|
{!hidden && bellBtn}
|
||||||
{!hidden && shareBtn}
|
{!hidden && shareBtn}
|
||||||
{accountId !== me && (
|
{menu}
|
||||||
<Dropdown
|
|
||||||
disabled={menu.length === 0}
|
|
||||||
items={menu}
|
|
||||||
icon='ellipsis-v'
|
|
||||||
iconComponent={MoreHorizIcon}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{!hidden && actionBtn}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -855,6 +852,12 @@ export const AccountHeader: React.FC<{
|
||||||
<FamiliarFollowers accountId={accountId} />
|
<FamiliarFollowers accountId={accountId} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className='account__header__buttons account__header__buttons--mobile'>
|
||||||
|
{!hidden && actionBtn}
|
||||||
|
{!hidden && bellBtn}
|
||||||
|
{menu}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!(suspended || hidden) && (
|
{!(suspended || hidden) && (
|
||||||
<div className='account__header__extra'>
|
<div className='account__header__extra'>
|
||||||
<div
|
<div
|
||||||
|
|
@ -967,7 +970,7 @@ export const AccountHeader: React.FC<{
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</AnimateEmojiProvider>
|
||||||
|
|
||||||
{!(hideTabs || hidden) && (
|
{!(hideTabs || hidden) && (
|
||||||
<div className='account__section-headline'>
|
<div className='account__section-headline'>
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,12 @@ import type { ApiQuotePolicy } from '@/mastodon/api_types/quotes';
|
||||||
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
import type { StatusVisibility } from '@/mastodon/api_types/statuses';
|
||||||
import { Icon } from '@/mastodon/components/icon';
|
import { Icon } from '@/mastodon/components/icon';
|
||||||
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
|
import { useAppSelector, useAppDispatch } from '@/mastodon/store';
|
||||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
import LockIcon from '@/material-icons/400-24px/lock.svg?react';
|
||||||
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
import PublicIcon from '@/material-icons/400-24px/public.svg?react';
|
||||||
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
import QuietTimeIcon from '@/material-icons/400-24px/quiet_time.svg?react';
|
||||||
|
|
||||||
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
|
import type { VisibilityModalCallback } from '../../ui/components/visibility_modal';
|
||||||
import PrivacyDropdownContainer from '../containers/privacy_dropdown_container';
|
|
||||||
|
|
||||||
import { messages as privacyMessages } from './privacy_dropdown';
|
import { messages as privacyMessages } from './privacy_dropdown';
|
||||||
|
|
||||||
|
|
@ -43,9 +41,6 @@ interface PrivacyDropdownProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
|
export const VisibilityButton: FC<PrivacyDropdownProps> = (props) => {
|
||||||
if (!isFeatureEnabled('outgoing_quotes')) {
|
|
||||||
return <PrivacyDropdownContainer {...props} />;
|
|
||||||
}
|
|
||||||
return <PrivacyModalButton {...props} />;
|
return <PrivacyModalButton {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ import StatusContent from 'mastodon/components/status_content';
|
||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { makeGetStatus } from 'mastodon/selectors';
|
import { makeGetStatus } from 'mastodon/selectors';
|
||||||
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
import { LinkedDisplayName } from '@/mastodon/components/display_name';
|
||||||
|
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
more: { id: 'status.more', defaultMessage: 'More' },
|
more: { id: 'status.more', defaultMessage: 'More' },
|
||||||
|
|
@ -136,9 +137,9 @@ export const Conversation = ({ conversation, scrollKey }) => {
|
||||||
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
{unread && <span className='conversation__unread' />} <RelativeTimestamp timestamp={lastStatus.get('created_at')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='conversation__content__names animate-parent' onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
<AnimateEmojiProvider className='conversation__content__names'>
|
||||||
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
<FormattedMessage id='conversation.with' defaultMessage='With {names}' values={{ names: <span>{names}</span> }} />
|
||||||
</div>
|
</AnimateEmojiProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<StatusContent
|
<StatusContent
|
||||||
|
|
|
||||||
|
|
@ -1,134 +1,23 @@
|
||||||
import { useCallback } from 'react';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import {
|
|
||||||
followAccount,
|
|
||||||
unblockAccount,
|
|
||||||
unmuteAccount,
|
|
||||||
} from 'mastodon/actions/accounts';
|
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { Button } from 'mastodon/components/button';
|
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
import { ShortNumber } from 'mastodon/components/short_number';
|
import { ShortNumber } from 'mastodon/components/short_number';
|
||||||
import { autoPlayGif, me } from 'mastodon/initial_state';
|
import { autoPlayGif } from 'mastodon/initial_state';
|
||||||
import type { Account } from 'mastodon/models/account';
|
import type { Account } from 'mastodon/models/account';
|
||||||
import { makeGetAccount } from 'mastodon/selectors';
|
import { makeGetAccount } from 'mastodon/selectors';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
cancel_follow_request: {
|
|
||||||
id: 'account.cancel_follow_request',
|
|
||||||
defaultMessage: 'Withdraw follow request',
|
|
||||||
},
|
|
||||||
requested: {
|
|
||||||
id: 'account.requested',
|
|
||||||
defaultMessage: 'Awaiting approval. Click to cancel follow request',
|
|
||||||
},
|
|
||||||
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
|
|
||||||
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
|
|
||||||
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const getAccount = makeGetAccount();
|
const getAccount = makeGetAccount();
|
||||||
|
|
||||||
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
const intl = useIntl();
|
|
||||||
const account = useAppSelector((s) => getAccount(s, accountId));
|
const account = useAppSelector((s) => getAccount(s, accountId));
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
|
|
||||||
const handleFollow = useCallback(() => {
|
|
||||||
if (!account) return;
|
|
||||||
|
|
||||||
if (
|
|
||||||
account.getIn(['relationship', 'following']) ||
|
|
||||||
account.getIn(['relationship', 'requested'])
|
|
||||||
) {
|
|
||||||
dispatch(
|
|
||||||
openModal({ modalType: 'CONFIRM_UNFOLLOW', modalProps: { account } }),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
dispatch(followAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
}, [account, dispatch]);
|
|
||||||
|
|
||||||
const handleBlock = useCallback(() => {
|
|
||||||
if (account?.relationship?.blocking) {
|
|
||||||
dispatch(unblockAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
}, [account, dispatch]);
|
|
||||||
|
|
||||||
const handleMute = useCallback(() => {
|
|
||||||
if (account?.relationship?.muting) {
|
|
||||||
dispatch(unmuteAccount(account.get('id')));
|
|
||||||
}
|
|
||||||
}, [account, dispatch]);
|
|
||||||
|
|
||||||
const handleEditProfile = useCallback(() => {
|
|
||||||
window.open('/settings/profile', '_blank');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!account) return null;
|
if (!account) return null;
|
||||||
|
|
||||||
let actionBtn;
|
|
||||||
|
|
||||||
if (me !== account.get('id')) {
|
|
||||||
if (!account.get('relationship')) {
|
|
||||||
// Wait until the relationship is loaded
|
|
||||||
actionBtn = '';
|
|
||||||
} else if (account.getIn(['relationship', 'requested'])) {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
text={intl.formatMessage(messages.cancel_follow_request)}
|
|
||||||
title={intl.formatMessage(messages.requested)}
|
|
||||||
onClick={handleFollow}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (account.getIn(['relationship', 'muting'])) {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
text={intl.formatMessage(messages.unmute)}
|
|
||||||
onClick={handleMute}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (!account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
disabled={account.relationship?.blocked_by}
|
|
||||||
className={classNames({
|
|
||||||
'button--destructive': account.getIn(['relationship', 'following']),
|
|
||||||
})}
|
|
||||||
text={intl.formatMessage(
|
|
||||||
account.getIn(['relationship', 'following'])
|
|
||||||
? messages.unfollow
|
|
||||||
: messages.follow,
|
|
||||||
)}
|
|
||||||
onClick={handleFollow}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (account.getIn(['relationship', 'blocking'])) {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
text={intl.formatMessage(messages.unblock)}
|
|
||||||
onClick={handleBlock}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
actionBtn = (
|
|
||||||
<Button
|
|
||||||
text={intl.formatMessage(messages.edit_profile)}
|
|
||||||
onClick={handleEditProfile}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-card'>
|
<div className='account-card'>
|
||||||
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
|
||||||
|
|
@ -186,7 +75,9 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='account-card__actions__button'>{actionBtn}</div>
|
<div className='account-card__actions__button'>
|
||||||
|
<FollowButton accountId={account.get('id')} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { LoadMore } from 'mastodon/components/load_more';
|
import { LoadMore } from 'mastodon/components/load_more';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { RadioButton } from 'mastodon/components/radio_button';
|
import { RadioButton } from 'mastodon/components/radio_button';
|
||||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
import { ScrollContainer } from 'mastodon/containers/scroll_container';
|
||||||
import { useSearchParam } from 'mastodon/hooks/useSearchParam';
|
import { useSearchParam } from 'mastodon/hooks/useSearchParam';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
|
@ -206,7 +206,6 @@ export const Directory: React.FC<{
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{multiColumn && !pinned ? (
|
{multiColumn && !pinned ? (
|
||||||
// @ts-expect-error ScrollContainer is not properly typed yet
|
|
||||||
<ScrollContainer scrollKey='directory'>
|
<ScrollContainer scrollKey='directory'>
|
||||||
{scrollableArea}
|
{scrollableArea}
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ export const EMOJI_MODE_TWEMOJI = 'twemoji';
|
||||||
export const EMOJI_TYPE_UNICODE = 'unicode';
|
export const EMOJI_TYPE_UNICODE = 'unicode';
|
||||||
export const EMOJI_TYPE_CUSTOM = 'custom';
|
export const EMOJI_TYPE_CUSTOM = 'custom';
|
||||||
|
|
||||||
export const EMOJI_STATE_MISSING = 'missing';
|
|
||||||
|
|
||||||
export const EMOJIS_WITH_DARK_BORDER = [
|
export const EMOJIS_WITH_DARK_BORDER = [
|
||||||
'🎱', // 1F3B1
|
'🎱', // 1F3B1
|
||||||
'🐜', // 1F41C
|
'🐜', // 1F41C
|
||||||
|
|
|
||||||
|
|
@ -197,11 +197,18 @@ function toLoadedLocale(localeString: string) {
|
||||||
log(`Locale ${locale} is different from provided ${localeString}`);
|
log(`Locale ${locale} is different from provided ${localeString}`);
|
||||||
}
|
}
|
||||||
if (!loadedLocales.has(locale)) {
|
if (!loadedLocales.has(locale)) {
|
||||||
throw new Error(`Locale ${locale} is not loaded in emoji database`);
|
throw new LocaleNotLoadedError(locale);
|
||||||
}
|
}
|
||||||
return locale;
|
return locale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class LocaleNotLoadedError extends Error {
|
||||||
|
constructor(locale: Locale) {
|
||||||
|
super(`Locale ${locale} is not loaded in emoji database`);
|
||||||
|
this.name = 'LocaleNotLoadedError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
|
async function hasLocale(locale: Locale, db: Database): Promise<boolean> {
|
||||||
if (loadedLocales.has(locale)) {
|
if (loadedLocales.has(locale)) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { isList } from 'immutable';
|
|
||||||
|
|
||||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
|
||||||
import { useAppSelector } from '@/mastodon/store';
|
import { useAppSelector } from '@/mastodon/store';
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
import { determineEmojiMode } from './mode';
|
import { determineEmojiMode } from './mode';
|
||||||
|
import { cleanExtraEmojis } from './normalize';
|
||||||
import { emojifyElement, emojifyText } from './render';
|
import { emojifyElement, emojifyText } from './render';
|
||||||
import type {
|
import type { CustomEmojiMapArg, EmojiAppState } from './types';
|
||||||
CustomEmojiMapArg,
|
|
||||||
EmojiAppState,
|
|
||||||
ExtraCustomEmojiMap,
|
|
||||||
} from './types';
|
|
||||||
import { stringHasAnyEmoji } from './utils';
|
import { stringHasAnyEmoji } from './utils';
|
||||||
|
|
||||||
interface UseEmojifyOptions {
|
interface UseEmojifyOptions {
|
||||||
|
|
@ -30,20 +24,7 @@ export function useEmojify({
|
||||||
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
const [emojifiedText, setEmojifiedText] = useState<string | null>(null);
|
||||||
|
|
||||||
const appState = useEmojiAppState();
|
const appState = useEmojiAppState();
|
||||||
const extra: ExtraCustomEmojiMap = useMemo(() => {
|
const extra = useMemo(() => cleanExtraEmojis(extraEmojis), [extraEmojis]);
|
||||||
if (!extraEmojis) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (isList(extraEmojis)) {
|
|
||||||
return (
|
|
||||||
extraEmojis.toJS() as ApiCustomEmojiJSON[]
|
|
||||||
).reduce<ExtraCustomEmojiMap>(
|
|
||||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return extraEmojis;
|
|
||||||
}, [extraEmojis]);
|
|
||||||
|
|
||||||
const emojify = useCallback(
|
const emojify = useCallback(
|
||||||
async (input: string) => {
|
async (input: string) => {
|
||||||
|
|
@ -51,11 +32,11 @@ export function useEmojify({
|
||||||
if (deep) {
|
if (deep) {
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = input;
|
wrapper.innerHTML = input;
|
||||||
if (await emojifyElement(wrapper, appState, extra)) {
|
if (await emojifyElement(wrapper, appState, extra ?? {})) {
|
||||||
result = wrapper.innerHTML;
|
result = wrapper.innerHTML;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = await emojifyText(text, appState, extra);
|
result = await emojifyText(text, appState, extra ?? {});
|
||||||
}
|
}
|
||||||
if (result) {
|
if (result) {
|
||||||
setEmojifiedText(result);
|
setEmojifiedText(result);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import initialState from '@/mastodon/initial_state';
|
import { initialState } from '@/mastodon/initial_state';
|
||||||
import { loadWorker } from '@/mastodon/utils/workers';
|
import { loadWorker } from '@/mastodon/utils/workers';
|
||||||
|
|
||||||
import { toSupportedLocale } from './locale';
|
import { toSupportedLocale } from './locale';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import { flattenEmojiData } from 'emojibase';
|
import { flattenEmojiData } from 'emojibase';
|
||||||
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
import type { CompactEmoji, FlatCompactEmoji } from 'emojibase';
|
||||||
|
|
||||||
import type { ApiCustomEmojiJSON } from '@/mastodon/api_types/custom_emoji';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
putEmojiData,
|
putEmojiData,
|
||||||
putCustomEmojiData,
|
putCustomEmojiData,
|
||||||
|
|
@ -10,7 +8,7 @@ import {
|
||||||
putLatestEtag,
|
putLatestEtag,
|
||||||
} from './database';
|
} from './database';
|
||||||
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
import { toSupportedLocale, toSupportedLocaleOrCustom } from './locale';
|
||||||
import type { LocaleOrCustom } from './types';
|
import type { CustomEmojiData, LocaleOrCustom } from './types';
|
||||||
import { emojiLogger } from './utils';
|
import { emojiLogger } from './utils';
|
||||||
|
|
||||||
const log = emojiLogger('loader');
|
const log = emojiLogger('loader');
|
||||||
|
|
@ -27,7 +25,7 @@ export async function importEmojiData(localeString: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importCustomEmojiData() {
|
export async function importCustomEmojiData() {
|
||||||
const emojis = await fetchAndCheckEtag<ApiCustomEmojiJSON[]>('custom');
|
const emojis = await fetchAndCheckEtag<CustomEmojiData[]>('custom');
|
||||||
if (!emojis) {
|
if (!emojis) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,8 @@ import { flattenEmojiData } from 'emojibase';
|
||||||
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
import unicodeRawEmojis from 'emojibase-data/en/data.json';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
twemojiHasBorder,
|
|
||||||
twemojiToUnicodeInfo,
|
twemojiToUnicodeInfo,
|
||||||
unicodeToTwemojiHex,
|
unicodeToTwemojiHex,
|
||||||
CODES_WITH_DARK_BORDER,
|
|
||||||
CODES_WITH_LIGHT_BORDER,
|
|
||||||
emojiToUnicodeHex,
|
emojiToUnicodeHex,
|
||||||
} from './normalize';
|
} from './normalize';
|
||||||
|
|
||||||
|
|
@ -57,26 +54,6 @@ describe('unicodeToTwemojiHex', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('twemojiHasBorder', () => {
|
|
||||||
test.concurrent.for(
|
|
||||||
svgFileNames
|
|
||||||
.filter((file) => file.endsWith('_border'))
|
|
||||||
.map((file) => {
|
|
||||||
const hexCode = file.replace('_border', '');
|
|
||||||
return [
|
|
||||||
hexCode,
|
|
||||||
CODES_WITH_LIGHT_BORDER.includes(hexCode.toUpperCase()),
|
|
||||||
CODES_WITH_DARK_BORDER.includes(hexCode.toUpperCase()),
|
|
||||||
] as const;
|
|
||||||
}),
|
|
||||||
)('twemojiHasBorder for %s', ([hexCode, isLight, isDark], { expect }) => {
|
|
||||||
const result = twemojiHasBorder(hexCode);
|
|
||||||
expect(result).toHaveProperty('hexCode', hexCode);
|
|
||||||
expect(result).toHaveProperty('hasLightBorder', isLight);
|
|
||||||
expect(result).toHaveProperty('hasDarkBorder', isDark);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('twemojiToUnicodeInfo', () => {
|
describe('twemojiToUnicodeInfo', () => {
|
||||||
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));
|
const unicodeCodeSet = new Set(unicodeEmojis.map((emoji) => emoji.hexcode));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,7 @@
|
||||||
|
import { isList } from 'immutable';
|
||||||
|
|
||||||
|
import { assetHost } from '@/mastodon/utils/config';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
VARIATION_SELECTOR_CODE,
|
VARIATION_SELECTOR_CODE,
|
||||||
KEYCAP_CODE,
|
KEYCAP_CODE,
|
||||||
|
|
@ -7,7 +11,7 @@ import {
|
||||||
EMOJIS_WITH_DARK_BORDER,
|
EMOJIS_WITH_DARK_BORDER,
|
||||||
EMOJIS_WITH_LIGHT_BORDER,
|
EMOJIS_WITH_LIGHT_BORDER,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import type { TwemojiBorderInfo } from './types';
|
import type { CustomEmojiMapArg, ExtraCustomEmojiMap } from './types';
|
||||||
|
|
||||||
// Misc codes that have special handling
|
// Misc codes that have special handling
|
||||||
const SKIER_CODE = 0x26f7;
|
const SKIER_CODE = 0x26f7;
|
||||||
|
|
@ -61,21 +65,17 @@ export const CODES_WITH_DARK_BORDER =
|
||||||
export const CODES_WITH_LIGHT_BORDER =
|
export const CODES_WITH_LIGHT_BORDER =
|
||||||
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
EMOJIS_WITH_LIGHT_BORDER.map(emojiToUnicodeHex);
|
||||||
|
|
||||||
export function twemojiHasBorder(twemojiHex: string): TwemojiBorderInfo {
|
export function unicodeHexToUrl(unicodeHex: string, darkMode: boolean): string {
|
||||||
const normalizedHex = twemojiHex.toUpperCase();
|
const normalizedHex = unicodeToTwemojiHex(unicodeHex);
|
||||||
let hasLightBorder = false;
|
let url = `${assetHost}/emoji/${normalizedHex}`;
|
||||||
let hasDarkBorder = false;
|
if (darkMode && CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
|
||||||
if (CODES_WITH_LIGHT_BORDER.includes(normalizedHex)) {
|
url += '_border';
|
||||||
hasLightBorder = true;
|
|
||||||
}
|
}
|
||||||
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
|
if (CODES_WITH_DARK_BORDER.includes(normalizedHex)) {
|
||||||
hasDarkBorder = true;
|
url += '_border';
|
||||||
}
|
}
|
||||||
return {
|
url += '.svg';
|
||||||
hexCode: twemojiHex,
|
return url;
|
||||||
hasLightBorder,
|
|
||||||
hasDarkBorder,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TwemojiSpecificEmoji {
|
interface TwemojiSpecificEmoji {
|
||||||
|
|
@ -150,6 +150,21 @@ export function twemojiToUnicodeInfo(
|
||||||
return hexNumbersToString(mappedCodes);
|
return hexNumbersToString(mappedCodes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
||||||
|
if (!extraEmojis) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isList(extraEmojis)) {
|
||||||
|
return extraEmojis;
|
||||||
|
}
|
||||||
|
return extraEmojis
|
||||||
|
.toJSON()
|
||||||
|
.reduce<ExtraCustomEmojiMap>(
|
||||||
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function hexStringToNumbers(hexString: string): number[] {
|
function hexStringToNumbers(hexString: string): number[] {
|
||||||
return hexString
|
return hexString
|
||||||
.split('-')
|
.split('-')
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,6 @@
|
||||||
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
import { customEmojiFactory, unicodeEmojiFactory } from '@/testing/factories';
|
||||||
|
|
||||||
import {
|
import { EMOJI_MODE_TWEMOJI } from './constants';
|
||||||
EMOJI_MODE_NATIVE,
|
|
||||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
|
||||||
EMOJI_MODE_TWEMOJI,
|
|
||||||
} from './constants';
|
|
||||||
import * as db from './database';
|
import * as db from './database';
|
||||||
import {
|
import {
|
||||||
emojifyElement,
|
emojifyElement,
|
||||||
|
|
@ -12,7 +8,7 @@ import {
|
||||||
testCacheClear,
|
testCacheClear,
|
||||||
tokenizeText,
|
tokenizeText,
|
||||||
} from './render';
|
} from './render';
|
||||||
import type { EmojiAppState, ExtraCustomEmojiMap } from './types';
|
import type { EmojiAppState } from './types';
|
||||||
|
|
||||||
function mockDatabase() {
|
function mockDatabase() {
|
||||||
return {
|
return {
|
||||||
|
|
@ -40,18 +36,6 @@ const expectedSmileImage =
|
||||||
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
'<img draggable="false" class="emojione" alt="😊" title="smiling face with smiling eyes" src="/emoji/1f60a.svg">';
|
||||||
const expectedFlagImage =
|
const expectedFlagImage =
|
||||||
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
'<img draggable="false" class="emojione" alt="🇪🇺" title="flag-eu" src="/emoji/1f1ea-1f1fa.svg">';
|
||||||
const expectedCustomEmojiImage =
|
|
||||||
'<img draggable="false" class="emojione custom-emoji" alt=":custom:" title=":custom:" src="emoji/custom/static" data-original="emoji/custom" data-static="emoji/custom/static">';
|
|
||||||
const expectedRemoteCustomEmojiImage =
|
|
||||||
'<img draggable="false" class="emojione custom-emoji" alt=":remote:" title=":remote:" src="remote.social/static" data-original="remote.social/custom" data-static="remote.social/static">';
|
|
||||||
|
|
||||||
const mockExtraCustom: ExtraCustomEmojiMap = {
|
|
||||||
remote: {
|
|
||||||
shortcode: 'remote',
|
|
||||||
static_url: 'remote.social/static',
|
|
||||||
url: 'remote.social/custom',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function testAppState(state: Partial<EmojiAppState> = {}) {
|
function testAppState(state: Partial<EmojiAppState> = {}) {
|
||||||
return {
|
return {
|
||||||
|
|
@ -86,64 +70,10 @@ describe('emojifyElement', () => {
|
||||||
'en',
|
'en',
|
||||||
);
|
);
|
||||||
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
|
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledExactlyOnceWith([
|
||||||
'custom',
|
':custom:',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('emojifies custom emoji in native mode', async () => {
|
|
||||||
const { searchEmojisByHexcodes } = mockDatabase();
|
|
||||||
const actual = await emojifyElement(
|
|
||||||
testElement(),
|
|
||||||
testAppState({ mode: EMOJI_MODE_NATIVE }),
|
|
||||||
);
|
|
||||||
assert(actual);
|
|
||||||
expect(actual.innerHTML).toBe(
|
|
||||||
`<p>Hello 😊🇪🇺!</p><p>${expectedCustomEmojiImage}</p>`,
|
|
||||||
);
|
|
||||||
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emojifies flag emoji in native-with-flags mode', async () => {
|
|
||||||
const { searchEmojisByHexcodes } = mockDatabase();
|
|
||||||
const actual = await emojifyElement(
|
|
||||||
testElement(),
|
|
||||||
testAppState({ mode: EMOJI_MODE_NATIVE_WITH_FLAGS }),
|
|
||||||
);
|
|
||||||
assert(actual);
|
|
||||||
expect(actual.innerHTML).toBe(
|
|
||||||
`<p>Hello 😊${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
|
||||||
);
|
|
||||||
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emojifies everything in twemoji mode', async () => {
|
|
||||||
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
|
||||||
mockDatabase();
|
|
||||||
const actual = await emojifyElement(testElement(), testAppState());
|
|
||||||
assert(actual);
|
|
||||||
expect(actual.innerHTML).toBe(
|
|
||||||
`<p>Hello ${expectedSmileImage}${expectedFlagImage}!</p><p>${expectedCustomEmojiImage}</p>`,
|
|
||||||
);
|
|
||||||
expect(searchEmojisByHexcodes).toHaveBeenCalledOnce();
|
|
||||||
expect(searchCustomEmojisByShortcodes).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('emojifies with provided custom emoji', async () => {
|
|
||||||
const { searchCustomEmojisByShortcodes, searchEmojisByHexcodes } =
|
|
||||||
mockDatabase();
|
|
||||||
const actual = await emojifyElement(
|
|
||||||
testElement('<p>hi :remote:</p>'),
|
|
||||||
testAppState(),
|
|
||||||
mockExtraCustom,
|
|
||||||
);
|
|
||||||
assert(actual);
|
|
||||||
expect(actual.innerHTML).toBe(
|
|
||||||
`<p>hi ${expectedRemoteCustomEmojiImage}</p>`,
|
|
||||||
);
|
|
||||||
expect(searchEmojisByHexcodes).not.toHaveBeenCalled();
|
|
||||||
expect(searchCustomEmojisByShortcodes).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns null when no emoji are found', async () => {
|
test('returns null when no emoji are found', async () => {
|
||||||
mockDatabase();
|
mockDatabase();
|
||||||
const actual = await emojifyElement(
|
const actual = await emojifyElement(
|
||||||
|
|
@ -165,28 +95,9 @@ describe('emojifyText', () => {
|
||||||
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
|
const actual = await emojifyText('Hello 😊🇪🇺!', testAppState());
|
||||||
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
|
expect(actual).toBe(`Hello ${expectedSmileImage}${expectedFlagImage}!`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders custom emojis', async () => {
|
|
||||||
mockDatabase();
|
|
||||||
const actual = await emojifyText('Hello :custom:!', testAppState());
|
|
||||||
expect(actual).toBe(`Hello ${expectedCustomEmojiImage}!`);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('renders provided extra emojis', async () => {
|
|
||||||
const actual = await emojifyText(
|
|
||||||
'remote emoji :remote:',
|
|
||||||
testAppState(),
|
|
||||||
mockExtraCustom,
|
|
||||||
);
|
|
||||||
expect(actual).toBe(`remote emoji ${expectedRemoteCustomEmojiImage}`);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('tokenizeText', () => {
|
describe('tokenizeText', () => {
|
||||||
test('returns empty array for string with only whitespace', () => {
|
|
||||||
expect(tokenizeText(' \n')).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns an array of text to be a single token', () => {
|
test('returns an array of text to be a single token', () => {
|
||||||
expect(tokenizeText('Hello')).toEqual(['Hello']);
|
expect(tokenizeText('Hello')).toEqual(['Hello']);
|
||||||
});
|
});
|
||||||
|
|
@ -212,7 +123,7 @@ describe('tokenizeText', () => {
|
||||||
'Hello ',
|
'Hello ',
|
||||||
{
|
{
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
code: 'smile',
|
code: ':smile:',
|
||||||
},
|
},
|
||||||
'!!',
|
'!!',
|
||||||
]);
|
]);
|
||||||
|
|
@ -223,7 +134,7 @@ describe('tokenizeText', () => {
|
||||||
'Hello ',
|
'Hello ',
|
||||||
{
|
{
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
code: 'smile_123',
|
code: ':smile_123:',
|
||||||
},
|
},
|
||||||
'!!',
|
'!!',
|
||||||
]);
|
]);
|
||||||
|
|
@ -239,7 +150,7 @@ describe('tokenizeText', () => {
|
||||||
' ',
|
' ',
|
||||||
{
|
{
|
||||||
type: 'custom',
|
type: 'custom',
|
||||||
code: 'smile',
|
code: ':smile:',
|
||||||
},
|
},
|
||||||
'!!',
|
'!!',
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||||
import { createLimitedCache } from '@/mastodon/utils/cache';
|
import { createLimitedCache } from '@/mastodon/utils/cache';
|
||||||
import { assetHost } from '@/mastodon/utils/config';
|
|
||||||
import * as perf from '@/mastodon/utils/performance';
|
import * as perf from '@/mastodon/utils/performance';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -8,38 +7,130 @@ import {
|
||||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
EMOJI_TYPE_UNICODE,
|
EMOJI_TYPE_UNICODE,
|
||||||
EMOJI_TYPE_CUSTOM,
|
EMOJI_TYPE_CUSTOM,
|
||||||
EMOJI_STATE_MISSING,
|
|
||||||
} from './constants';
|
} from './constants';
|
||||||
import {
|
import {
|
||||||
|
loadCustomEmojiByShortcode,
|
||||||
|
loadEmojiByHexcode,
|
||||||
|
LocaleNotLoadedError,
|
||||||
searchCustomEmojisByShortcodes,
|
searchCustomEmojisByShortcodes,
|
||||||
searchEmojisByHexcodes,
|
searchEmojisByHexcodes,
|
||||||
} from './database';
|
} from './database';
|
||||||
import {
|
import { importEmojiData } from './loader';
|
||||||
emojiToUnicodeHex,
|
import { emojiToUnicodeHex, unicodeHexToUrl } from './normalize';
|
||||||
twemojiHasBorder,
|
|
||||||
unicodeToTwemojiHex,
|
|
||||||
} from './normalize';
|
|
||||||
import type {
|
import type {
|
||||||
CustomEmojiToken,
|
|
||||||
EmojiAppState,
|
EmojiAppState,
|
||||||
EmojiLoadedState,
|
EmojiLoadedState,
|
||||||
EmojiMode,
|
EmojiMode,
|
||||||
EmojiState,
|
EmojiState,
|
||||||
|
EmojiStateCustom,
|
||||||
EmojiStateMap,
|
EmojiStateMap,
|
||||||
EmojiToken,
|
EmojiStateUnicode,
|
||||||
ExtraCustomEmojiMap,
|
ExtraCustomEmojiMap,
|
||||||
LocaleOrCustom,
|
LocaleOrCustom,
|
||||||
UnicodeEmojiToken,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
import {
|
import {
|
||||||
anyEmojiRegex,
|
anyEmojiRegex,
|
||||||
emojiLogger,
|
emojiLogger,
|
||||||
|
isCustomEmoji,
|
||||||
|
isUnicodeEmoji,
|
||||||
stringHasAnyEmoji,
|
stringHasAnyEmoji,
|
||||||
stringHasUnicodeFlags,
|
stringHasUnicodeFlags,
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
const log = emojiLogger('render');
|
const log = emojiLogger('render');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses emoji string to extract emoji state.
|
||||||
|
* @param code Hex code or custom shortcode.
|
||||||
|
* @param customEmoji Extra custom emojis.
|
||||||
|
*/
|
||||||
|
export function stringToEmojiState(
|
||||||
|
code: string,
|
||||||
|
customEmoji: ExtraCustomEmojiMap = {},
|
||||||
|
): EmojiState | null {
|
||||||
|
if (isUnicodeEmoji(code)) {
|
||||||
|
return {
|
||||||
|
type: EMOJI_TYPE_UNICODE,
|
||||||
|
code: emojiToUnicodeHex(code),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCustomEmoji(code)) {
|
||||||
|
const shortCode = code.slice(1, -1);
|
||||||
|
return {
|
||||||
|
type: EMOJI_TYPE_CUSTOM,
|
||||||
|
code: shortCode,
|
||||||
|
data: customEmoji[shortCode],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads emoji data into the given state if not already loaded.
|
||||||
|
* @param state Emoji state to load data for.
|
||||||
|
* @param locale Locale to load data for. Only for Unicode emoji.
|
||||||
|
* @param retry Internal. Whether this is a retry after loading the locale.
|
||||||
|
*/
|
||||||
|
export async function loadEmojiDataToState(
|
||||||
|
state: EmojiState,
|
||||||
|
locale: string,
|
||||||
|
retry = false,
|
||||||
|
): Promise<EmojiLoadedState | null> {
|
||||||
|
if (isStateLoaded(state)) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, try to load the data from IndexedDB.
|
||||||
|
try {
|
||||||
|
// This is duplicative, but that's because TS can't distinguish the state type easily.
|
||||||
|
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||||
|
const data = await loadEmojiByHexcode(state.code, locale);
|
||||||
|
if (data) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const data = await loadCustomEmojiByShortcode(state.code);
|
||||||
|
if (data) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If not found, assume it's not an emoji and return null.
|
||||||
|
log(
|
||||||
|
'Could not find emoji %s of type %s for locale %s',
|
||||||
|
state.code,
|
||||||
|
state.type,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// If the locale is not loaded, load it and retry once.
|
||||||
|
if (!retry && err instanceof LocaleNotLoadedError) {
|
||||||
|
log(
|
||||||
|
'Error loading emoji %s for locale %s, loading locale and retrying.',
|
||||||
|
state.code,
|
||||||
|
locale,
|
||||||
|
);
|
||||||
|
await importEmojiData(locale); // Use this from the loader file as it can be awaited.
|
||||||
|
return loadEmojiDataToState(state, locale, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('Error loading emoji data, not retrying:', state, locale, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isStateLoaded(state: EmojiState): state is EmojiLoadedState {
|
||||||
|
return !!state.data;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
* Emojifies an element. This modifies the element in place, replacing text nodes with emojified versions.
|
||||||
*/
|
*/
|
||||||
|
|
@ -177,7 +268,11 @@ async function textToElementArray(
|
||||||
if (token.type === EMOJI_TYPE_CUSTOM) {
|
if (token.type === EMOJI_TYPE_CUSTOM) {
|
||||||
const extraEmojiData = extraEmojis[token.code];
|
const extraEmojiData = extraEmojis[token.code];
|
||||||
if (extraEmojiData) {
|
if (extraEmojiData) {
|
||||||
state = { type: EMOJI_TYPE_CUSTOM, data: extraEmojiData };
|
state = {
|
||||||
|
type: EMOJI_TYPE_CUSTOM,
|
||||||
|
data: extraEmojiData,
|
||||||
|
code: token.code,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
|
state = emojiForLocale(token.code, EMOJI_TYPE_CUSTOM);
|
||||||
}
|
}
|
||||||
|
|
@ -189,7 +284,7 @@ async function textToElementArray(
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the state is valid, create an image element. Otherwise, just append as text.
|
// If the state is valid, create an image element. Otherwise, just append as text.
|
||||||
if (state && typeof state !== 'string') {
|
if (state && typeof state !== 'string' && isStateLoaded(state)) {
|
||||||
const image = stateToImage(state, appState);
|
const image = stateToImage(state, appState);
|
||||||
renderedFragments.push(image);
|
renderedFragments.push(image);
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -202,11 +297,11 @@ async function textToElementArray(
|
||||||
return renderedFragments;
|
return renderedFragments;
|
||||||
}
|
}
|
||||||
|
|
||||||
type TokenizedText = (string | EmojiToken)[];
|
type TokenizedText = (string | EmojiState)[];
|
||||||
|
|
||||||
export function tokenizeText(text: string): TokenizedText {
|
export function tokenizeText(text: string): TokenizedText {
|
||||||
if (!text.trim()) {
|
if (!text.trim()) {
|
||||||
return [];
|
return [text];
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = [];
|
const tokens = [];
|
||||||
|
|
@ -222,14 +317,14 @@ export function tokenizeText(text: string): TokenizedText {
|
||||||
// Custom emoji
|
// Custom emoji
|
||||||
tokens.push({
|
tokens.push({
|
||||||
type: EMOJI_TYPE_CUSTOM,
|
type: EMOJI_TYPE_CUSTOM,
|
||||||
code: code.slice(1, -1), // Remove the colons
|
code,
|
||||||
} satisfies CustomEmojiToken);
|
} satisfies EmojiStateCustom);
|
||||||
} else {
|
} else {
|
||||||
// Unicode emoji
|
// Unicode emoji
|
||||||
tokens.push({
|
tokens.push({
|
||||||
type: EMOJI_TYPE_UNICODE,
|
type: EMOJI_TYPE_UNICODE,
|
||||||
code: code,
|
code: code,
|
||||||
} satisfies UnicodeEmojiToken);
|
} satisfies EmojiStateUnicode);
|
||||||
}
|
}
|
||||||
lastIndex = match.index + code.length;
|
lastIndex = match.index + code.length;
|
||||||
}
|
}
|
||||||
|
|
@ -304,13 +399,11 @@ async function loadMissingEmojiIntoCache(
|
||||||
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
|
const emojis = await searchEmojisByHexcodes(missingEmojis, currentLocale);
|
||||||
const cache = cacheForLocale(currentLocale);
|
const cache = cacheForLocale(currentLocale);
|
||||||
for (const emoji of emojis) {
|
for (const emoji of emojis) {
|
||||||
cache.set(emoji.hexcode, { type: EMOJI_TYPE_UNICODE, data: emoji });
|
cache.set(emoji.hexcode, {
|
||||||
}
|
type: EMOJI_TYPE_UNICODE,
|
||||||
const notFoundEmojis = missingEmojis.filter((code) =>
|
data: emoji,
|
||||||
emojis.every((emoji) => emoji.hexcode !== code),
|
code: emoji.hexcode,
|
||||||
);
|
});
|
||||||
for (const code of notFoundEmojis) {
|
|
||||||
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
|
||||||
}
|
}
|
||||||
localeCacheMap.set(currentLocale, cache);
|
localeCacheMap.set(currentLocale, cache);
|
||||||
}
|
}
|
||||||
|
|
@ -320,19 +413,17 @@ async function loadMissingEmojiIntoCache(
|
||||||
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
|
const emojis = await searchCustomEmojisByShortcodes(missingEmojis);
|
||||||
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
|
const cache = cacheForLocale(EMOJI_TYPE_CUSTOM);
|
||||||
for (const emoji of emojis) {
|
for (const emoji of emojis) {
|
||||||
cache.set(emoji.shortcode, { type: EMOJI_TYPE_CUSTOM, data: emoji });
|
cache.set(emoji.shortcode, {
|
||||||
}
|
type: EMOJI_TYPE_CUSTOM,
|
||||||
const notFoundEmojis = missingEmojis.filter((code) =>
|
data: emoji,
|
||||||
emojis.every((emoji) => emoji.shortcode !== code),
|
code: emoji.shortcode,
|
||||||
);
|
});
|
||||||
for (const code of notFoundEmojis) {
|
|
||||||
cache.set(code, EMOJI_STATE_MISSING); // Mark as missing if not found, as it's probably not a valid emoji.
|
|
||||||
}
|
}
|
||||||
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
|
localeCacheMap.set(EMOJI_TYPE_CUSTOM, cache);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRenderImage(token: EmojiToken, mode: EmojiMode): boolean {
|
export function shouldRenderImage(token: EmojiState, mode: EmojiMode): boolean {
|
||||||
if (token.type === EMOJI_TYPE_UNICODE) {
|
if (token.type === EMOJI_TYPE_UNICODE) {
|
||||||
// If the mode is native or native with flags for non-flag emoji
|
// If the mode is native or native with flags for non-flag emoji
|
||||||
// we can just append the text node directly.
|
// we can just append the text node directly.
|
||||||
|
|
@ -354,18 +445,9 @@ function stateToImage(state: EmojiLoadedState, appState: EmojiAppState) {
|
||||||
image.classList.add('emojione');
|
image.classList.add('emojione');
|
||||||
|
|
||||||
if (state.type === EMOJI_TYPE_UNICODE) {
|
if (state.type === EMOJI_TYPE_UNICODE) {
|
||||||
const emojiInfo = twemojiHasBorder(unicodeToTwemojiHex(state.data.hexcode));
|
|
||||||
let fileName = emojiInfo.hexCode;
|
|
||||||
if (
|
|
||||||
(appState.darkTheme && emojiInfo.hasDarkBorder) ||
|
|
||||||
(!appState.darkTheme && emojiInfo.hasLightBorder)
|
|
||||||
) {
|
|
||||||
fileName = `${emojiInfo.hexCode}_border`;
|
|
||||||
}
|
|
||||||
|
|
||||||
image.alt = state.data.unicode;
|
image.alt = state.data.unicode;
|
||||||
image.title = state.data.label;
|
image.title = state.data.label;
|
||||||
image.src = `${assetHost}/emoji/${fileName}.svg`;
|
image.src = unicodeHexToUrl(state.data.hexcode, appState.darkTheme);
|
||||||
} else {
|
} else {
|
||||||
// Custom emoji
|
// Custom emoji
|
||||||
const shortCode = `:${state.data.shortcode}:`;
|
const shortCode = `:${state.data.shortcode}:`;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import type {
|
||||||
EMOJI_MODE_NATIVE,
|
EMOJI_MODE_NATIVE,
|
||||||
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
EMOJI_MODE_NATIVE_WITH_FLAGS,
|
||||||
EMOJI_MODE_TWEMOJI,
|
EMOJI_MODE_TWEMOJI,
|
||||||
EMOJI_STATE_MISSING,
|
|
||||||
EMOJI_TYPE_CUSTOM,
|
EMOJI_TYPE_CUSTOM,
|
||||||
EMOJI_TYPE_UNICODE,
|
EMOJI_TYPE_UNICODE,
|
||||||
} from './constants';
|
} from './constants';
|
||||||
|
|
@ -29,45 +28,40 @@ export interface EmojiAppState {
|
||||||
darkTheme: boolean;
|
darkTheme: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UnicodeEmojiToken {
|
|
||||||
type: typeof EMOJI_TYPE_UNICODE;
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
export interface CustomEmojiToken {
|
|
||||||
type: typeof EMOJI_TYPE_CUSTOM;
|
|
||||||
code: string;
|
|
||||||
}
|
|
||||||
export type EmojiToken = UnicodeEmojiToken | CustomEmojiToken;
|
|
||||||
|
|
||||||
export type CustomEmojiData = ApiCustomEmojiJSON;
|
export type CustomEmojiData = ApiCustomEmojiJSON;
|
||||||
export type UnicodeEmojiData = FlatCompactEmoji;
|
export type UnicodeEmojiData = FlatCompactEmoji;
|
||||||
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
|
export type AnyEmojiData = CustomEmojiData | UnicodeEmojiData;
|
||||||
|
|
||||||
export type EmojiStateMissing = typeof EMOJI_STATE_MISSING;
|
type CustomEmojiRenderFields = Pick<
|
||||||
|
CustomEmojiData,
|
||||||
|
'shortcode' | 'static_url' | 'url'
|
||||||
|
>;
|
||||||
|
|
||||||
export interface EmojiStateUnicode {
|
export interface EmojiStateUnicode {
|
||||||
type: typeof EMOJI_TYPE_UNICODE;
|
type: typeof EMOJI_TYPE_UNICODE;
|
||||||
data: UnicodeEmojiData;
|
code: string;
|
||||||
|
data?: UnicodeEmojiData;
|
||||||
}
|
}
|
||||||
export interface EmojiStateCustom {
|
export interface EmojiStateCustom {
|
||||||
type: typeof EMOJI_TYPE_CUSTOM;
|
type: typeof EMOJI_TYPE_CUSTOM;
|
||||||
data: CustomEmojiRenderFields;
|
code: string;
|
||||||
|
data?: CustomEmojiRenderFields;
|
||||||
}
|
}
|
||||||
export type EmojiState =
|
export type EmojiState = EmojiStateUnicode | EmojiStateCustom;
|
||||||
| EmojiStateMissing
|
export type EmojiLoadedState =
|
||||||
| EmojiStateUnicode
|
| Required<EmojiStateUnicode>
|
||||||
| EmojiStateCustom;
|
| Required<EmojiStateCustom>;
|
||||||
export type EmojiLoadedState = EmojiStateUnicode | EmojiStateCustom;
|
|
||||||
|
|
||||||
export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
||||||
|
|
||||||
export type CustomEmojiMapArg =
|
export type CustomEmojiMapArg =
|
||||||
| ExtraCustomEmojiMap
|
| ExtraCustomEmojiMap
|
||||||
| ImmutableList<CustomEmoji>;
|
| ImmutableList<CustomEmoji>;
|
||||||
export type CustomEmojiRenderFields = Pick<
|
|
||||||
CustomEmojiData,
|
export type ExtraCustomEmojiMap = Record<
|
||||||
'shortcode' | 'static_url' | 'url'
|
string,
|
||||||
|
Pick<CustomEmojiData, 'shortcode' | 'static_url' | 'url'>
|
||||||
>;
|
>;
|
||||||
export type ExtraCustomEmojiMap = Record<string, CustomEmojiRenderFields>;
|
|
||||||
|
|
||||||
export interface TwemojiBorderInfo {
|
export interface TwemojiBorderInfo {
|
||||||
hexCode: string;
|
hexCode: string;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,13 @@ export function stringHasUnicodeEmoji(input: string): boolean {
|
||||||
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
|
return new RegExp(EMOJI_REGEX, supportedFlags()).test(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isUnicodeEmoji(input: string): boolean {
|
||||||
|
return (
|
||||||
|
input.length > 0 &&
|
||||||
|
new RegExp(`^(${EMOJI_REGEX})+$`, supportedFlags()).test(input)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function stringHasUnicodeFlags(input: string): boolean {
|
export function stringHasUnicodeFlags(input: string): boolean {
|
||||||
if (supportsRegExpSets()) {
|
if (supportsRegExpSets()) {
|
||||||
return new RegExp(
|
return new RegExp(
|
||||||
|
|
@ -27,6 +34,11 @@ export function stringHasUnicodeFlags(input: string): boolean {
|
||||||
|
|
||||||
// Constant as this is supported by all browsers.
|
// Constant as this is supported by all browsers.
|
||||||
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/i;
|
||||||
|
|
||||||
|
export function isCustomEmoji(input: string): boolean {
|
||||||
|
return new RegExp(`^${CUSTOM_EMOJI_REGEX.source}$`, 'i').test(input);
|
||||||
|
}
|
||||||
|
|
||||||
export function stringHasCustomEmoji(input: string) {
|
export function stringHasCustomEmoji(input: string) {
|
||||||
return CUSTOM_EMOJI_REGEX.test(input);
|
return CUSTOM_EMOJI_REGEX.test(input);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@ import { domain } from 'mastodon/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
|
||||||
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
|
|
||||||
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||||
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||||
dismiss: {
|
dismiss: {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
|
||||||
import Column from 'mastodon/components/column';
|
import Column from 'mastodon/components/column';
|
||||||
import ColumnHeader from 'mastodon/components/column_header';
|
import ColumnHeader from 'mastodon/components/column_header';
|
||||||
import { isFeatureEnabled } from 'mastodon/utils/environment';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
heading: { id: 'keyboard_shortcuts.heading', defaultMessage: 'Keyboard Shortcuts' },
|
||||||
|
|
@ -63,12 +62,10 @@ class KeyboardShortcuts extends ImmutablePureComponent {
|
||||||
<td><kbd>b</kbd></td>
|
<td><kbd>b</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.boost' defaultMessage='to boost' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
{isFeatureEnabled('outgoing_quotes') && (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><kbd>q</kbd></td>
|
<td><kbd>q</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.quote' defaultMessage='Quote post' /></td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
|
<td><kbd>enter</kbd>, <kbd>o</kbd></td>
|
||||||
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
|
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open status' /></td>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import type { List as ImmutableList, RecordOf } from 'immutable';
|
import type { List as ImmutableList, RecordOf } from 'immutable';
|
||||||
|
|
||||||
|
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||||
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
import BarChart4BarsIcon from '@/material-icons/400-24px/bar_chart_4_bars.svg?react';
|
||||||
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
import PhotoLibraryIcon from '@/material-icons/400-24px/photo_library.svg?react';
|
||||||
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
import { toggleStatusSpoilers } from 'mastodon/actions/statuses';
|
||||||
|
|
@ -96,8 +97,8 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
).size;
|
).size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<AnimateEmojiProvider
|
||||||
className='notification-group__embedded-status animate-parent'
|
className='notification-group__embedded-status'
|
||||||
role='button'
|
role='button'
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
|
|
@ -148,6 +149,6 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</AnimateEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export const EmbeddedStatusContent: React.FC<{
|
||||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
link.setAttribute('title', `@${mention.get('acct')}`);
|
||||||
link.setAttribute('href', `/@${mention.get('acct')}`);
|
link.setAttribute('href', `/@${mention.get('acct')}`);
|
||||||
} else if (
|
} else if (
|
||||||
link.textContent?.[0] === '#' ||
|
link.textContent.startsWith('#') ||
|
||||||
link.previousSibling?.textContent?.endsWith('#')
|
link.previousSibling?.textContent?.endsWith('#')
|
||||||
) {
|
) {
|
||||||
link.addEventListener(
|
link.addEventListener(
|
||||||
|
|
|
||||||
|
|
@ -54,9 +54,7 @@ export const Profile: React.FC<{
|
||||||
me ? state.accounts.get(me) : undefined,
|
me ? state.accounts.get(me) : undefined,
|
||||||
);
|
);
|
||||||
const [displayName, setDisplayName] = useState(account?.display_name ?? '');
|
const [displayName, setDisplayName] = useState(account?.display_name ?? '');
|
||||||
const [note, setNote] = useState(
|
const [note, setNote] = useState(account ? unescapeHTML(account.note) : '');
|
||||||
account ? (unescapeHTML(account.note) ?? '') : '',
|
|
||||||
);
|
|
||||||
const [avatar, setAvatar] = useState<File>();
|
const [avatar, setAvatar] = useState<File>();
|
||||||
const [header, setHeader] = useState<File>();
|
const [header, setHeader] = useState<File>();
|
||||||
const [discoverable, setDiscoverable] = useState(
|
const [discoverable, setDiscoverable] = useState(
|
||||||
|
|
|
||||||
|
|
@ -2,25 +2,19 @@ import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { defineMessages, useIntl } from 'react-intl';
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||||
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||||
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
|
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
|
||||||
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
|
|
||||||
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
|
||||||
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
|
||||||
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
|
|
||||||
import { replyCompose } from 'mastodon/actions/compose';
|
import { replyCompose } from 'mastodon/actions/compose';
|
||||||
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
import { toggleFavourite } from 'mastodon/actions/interactions';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
import { BoostButton } from 'mastodon/components/status/boost_button';
|
||||||
import { useIdentity } from 'mastodon/identity_context';
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
import { me } from 'mastodon/initial_state';
|
|
||||||
import type { Account } from 'mastodon/models/account';
|
import type { Account } from 'mastodon/models/account';
|
||||||
import type { Status } from 'mastodon/models/status';
|
import type { Status } from 'mastodon/models/status';
|
||||||
import { makeGetStatus } from 'mastodon/selectors';
|
import { makeGetStatus } from 'mastodon/selectors';
|
||||||
|
|
@ -120,29 +114,6 @@ export const Footer: React.FC<{
|
||||||
}
|
}
|
||||||
}, [dispatch, status, signedIn]);
|
}, [dispatch, status, signedIn]);
|
||||||
|
|
||||||
const handleReblogClick = useCallback(
|
|
||||||
(e: React.MouseEvent) => {
|
|
||||||
if (!status) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (signedIn) {
|
|
||||||
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
|
||||||
} else {
|
|
||||||
dispatch(
|
|
||||||
openModal({
|
|
||||||
modalType: 'INTERACTION',
|
|
||||||
modalProps: {
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
|
||||||
url: status.get('uri'),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dispatch, status, signedIn],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleOpenClick = useCallback(
|
const handleOpenClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
if (e.button !== 0 || !status) {
|
if (e.button !== 0 || !status) {
|
||||||
|
|
@ -160,13 +131,6 @@ export const Footer: React.FC<{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(
|
|
||||||
status.get('visibility') as string,
|
|
||||||
);
|
|
||||||
const reblogPrivate =
|
|
||||||
status.getIn(['account', 'id']) === me &&
|
|
||||||
status.get('visibility') === 'private';
|
|
||||||
|
|
||||||
let replyIcon, replyIconComponent, replyTitle;
|
let replyIcon, replyIconComponent, replyTitle;
|
||||||
|
|
||||||
if (status.get('in_reply_to_id', null) === null) {
|
if (status.get('in_reply_to_id', null) === null) {
|
||||||
|
|
@ -179,24 +143,6 @@ export const Footer: React.FC<{
|
||||||
replyTitle = intl.formatMessage(messages.replyAll);
|
replyTitle = intl.formatMessage(messages.replyAll);
|
||||||
}
|
}
|
||||||
|
|
||||||
let reblogTitle, reblogIconComponent;
|
|
||||||
|
|
||||||
if (status.get('reblogged')) {
|
|
||||||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
|
||||||
reblogIconComponent = publicStatus
|
|
||||||
? RepeatActiveIcon
|
|
||||||
: RepeatPrivateActiveIcon;
|
|
||||||
} else if (publicStatus) {
|
|
||||||
reblogTitle = intl.formatMessage(messages.reblog);
|
|
||||||
reblogIconComponent = RepeatIcon;
|
|
||||||
} else if (reblogPrivate) {
|
|
||||||
reblogTitle = intl.formatMessage(messages.reblog_private);
|
|
||||||
reblogIconComponent = RepeatPrivateIcon;
|
|
||||||
} else {
|
|
||||||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
|
||||||
reblogIconComponent = RepeatDisabledIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
const favouriteTitle = intl.formatMessage(
|
const favouriteTitle = intl.formatMessage(
|
||||||
status.get('favourited') ? messages.removeFavourite : messages.favourite,
|
status.get('favourited') ? messages.removeFavourite : messages.favourite,
|
||||||
);
|
);
|
||||||
|
|
@ -222,19 +168,7 @@ export const Footer: React.FC<{
|
||||||
counter={status.get('replies_count') as number}
|
counter={status.get('replies_count') as number}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<IconButton
|
<BoostButton counters status={status} />
|
||||||
className={classNames('status__action-bar-button', { reblogPrivate })}
|
|
||||||
disabled={!publicStatus && !reblogPrivate}
|
|
||||||
active={status.get('reblogged') as boolean}
|
|
||||||
title={reblogTitle}
|
|
||||||
icon='retweet'
|
|
||||||
iconComponent={reblogIconComponent}
|
|
||||||
onClick={handleReblogClick}
|
|
||||||
counter={
|
|
||||||
(status.get('reblogs_count') as number) +
|
|
||||||
(status.get('quotes_count') as number)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<IconButton
|
<IconButton
|
||||||
className='status__action-bar-button star-icon'
|
className='status__action-bar-button star-icon'
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ import { ColumnHeader } from 'mastodon/components/column_header';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import StatusList from 'mastodon/components/status_list';
|
import StatusList from 'mastodon/components/status_list';
|
||||||
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
|
import { domain } from 'mastodon/initial_state';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import Column from '../ui/components/column';
|
import Column from '../ui/components/column';
|
||||||
|
|
@ -31,9 +33,18 @@ export const Quotes: React.FC<{
|
||||||
|
|
||||||
const statusId = params?.statusId;
|
const statusId = params?.statusId;
|
||||||
|
|
||||||
|
const { accountId: me } = useIdentity();
|
||||||
|
|
||||||
const isCorrectStatusId: boolean = useAppSelector(
|
const isCorrectStatusId: boolean = useAppSelector(
|
||||||
(state) => state.status_lists.getIn(['quotes', 'statusId']) === statusId,
|
(state) => state.status_lists.getIn(['quotes', 'statusId']) === statusId,
|
||||||
);
|
);
|
||||||
|
const quotedAccountId = useAppSelector(
|
||||||
|
(state) =>
|
||||||
|
state.statuses.getIn([statusId, 'account']) as string | undefined,
|
||||||
|
);
|
||||||
|
const quotedAccount = useAppSelector((state) =>
|
||||||
|
quotedAccountId ? state.accounts.get(quotedAccountId) : undefined,
|
||||||
|
);
|
||||||
const statusIds = useAppSelector((state) =>
|
const statusIds = useAppSelector((state) =>
|
||||||
state.status_lists.getIn(['quotes', 'items'], emptyList),
|
state.status_lists.getIn(['quotes', 'items'], emptyList),
|
||||||
);
|
);
|
||||||
|
|
@ -74,6 +85,32 @@ export const Quotes: React.FC<{
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let prependMessage;
|
||||||
|
|
||||||
|
if (me === quotedAccountId) {
|
||||||
|
prependMessage = null;
|
||||||
|
} else if (quotedAccount?.username === quotedAccount?.acct) {
|
||||||
|
// Local account, we know this to be exhaustive
|
||||||
|
prependMessage = (
|
||||||
|
<div className='follow_requests-unlocked_explanation'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.quotes.local_other_disclaimer'
|
||||||
|
defaultMessage='Quotes rejected by the author will not be shown.'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
prependMessage = (
|
||||||
|
<div className='follow_requests-unlocked_explanation'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='status.quotes.remote_other_disclaimer'
|
||||||
|
defaultMessage='Only quotes from {domain} are guaranteed to be shown here. Quotes rejected by the author will not be shown.'
|
||||||
|
values={{ domain: <strong>{domain}</strong> }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column bindToDocument={!multiColumn}>
|
<Column bindToDocument={!multiColumn}>
|
||||||
<ColumnHeader
|
<ColumnHeader
|
||||||
|
|
@ -100,6 +137,7 @@ export const Quotes: React.FC<{
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage={emptyMessage}
|
emptyMessage={emptyMessage}
|
||||||
bindToDocument={!multiColumn}
|
bindToDocument={!multiColumn}
|
||||||
|
prepend={prependMessage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Helmet>
|
<Helmet>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { hydrateStore } from 'mastodon/actions/store';
|
||||||
import { Router } from 'mastodon/components/router';
|
import { Router } from 'mastodon/components/router';
|
||||||
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
|
import { DetailedStatus } from 'mastodon/features/status/components/detailed_status';
|
||||||
import { useRenderSignal } from 'mastodon/hooks/useRenderSignal';
|
import { useRenderSignal } from 'mastodon/hooks/useRenderSignal';
|
||||||
import initialState from 'mastodon/initial_state';
|
import { initialState } from 'mastodon/initial_state';
|
||||||
import { IntlProvider } from 'mastodon/locales';
|
import { IntlProvider } from 'mastodon/locales';
|
||||||
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
|
import { makeGetStatus, makeGetPictureInPicture } from 'mastodon/selectors';
|
||||||
import { store, useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { store, useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,6 @@ import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'mastodon/
|
||||||
import { IconButton } from '../../../components/icon_button';
|
import { IconButton } from '../../../components/icon_button';
|
||||||
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
import { Dropdown } from 'mastodon/components/dropdown_menu';
|
||||||
import { me } from '../../../initial_state';
|
import { me } from '../../../initial_state';
|
||||||
import { isFeatureEnabled } from '@/mastodon/utils/environment';
|
|
||||||
import { BoostButton } from '@/mastodon/components/status/boost_button';
|
import { BoostButton } from '@/mastodon/components/status/boost_button';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
|
@ -237,7 +236,7 @@ class ActionBar extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
menu.push({ text: intl.formatMessage(mutingConversation ? messages.unmuteConversation : messages.muteConversation), action: this.handleConversationMuteClick });
|
||||||
if (isFeatureEnabled('outgoing_quotes') && !['private', 'direct'].includes(status.get('visibility'))) {
|
if (!['private', 'direct'].includes(status.get('visibility'))) {
|
||||||
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
menu.push({ text: intl.formatMessage(messages.quotePolicyChange), action: this.handleQuotePolicyChange });
|
||||||
}
|
}
|
||||||
menu.push(null);
|
menu.push(null);
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||||
import { Audio } from 'mastodon/features/audio';
|
import { Audio } from 'mastodon/features/audio';
|
||||||
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
|
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
|
||||||
import { Video } from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
|
|
||||||
import Card from './card';
|
import Card from './card';
|
||||||
|
|
||||||
|
|
@ -75,6 +75,8 @@ export const DetailedStatus: React.FC<{
|
||||||
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
|
const [showDespiteFilter, setShowDespiteFilter] = useState(false);
|
||||||
const nodeRef = useRef<HTMLDivElement>();
|
const nodeRef = useRef<HTMLDivElement>();
|
||||||
|
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
|
||||||
const handleOpenVideo = useCallback(
|
const handleOpenVideo = useCallback(
|
||||||
(options: VideoModalOptions) => {
|
(options: VideoModalOptions) => {
|
||||||
const lang = (status.getIn(['translation', 'language']) ||
|
const lang = (status.getIn(['translation', 'language']) ||
|
||||||
|
|
@ -283,7 +285,7 @@ export const DetailedStatus: React.FC<{
|
||||||
|
|
||||||
if (['private', 'direct'].includes(status.get('visibility') as string)) {
|
if (['private', 'direct'].includes(status.get('visibility') as string)) {
|
||||||
quotesLink = '';
|
quotesLink = '';
|
||||||
} else if (status.getIn(['account', 'id']) === me) {
|
} else if (signedIn) {
|
||||||
quotesLink = (
|
quotesLink = (
|
||||||
<Link
|
<Link
|
||||||
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`}
|
to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/quotes`}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useState, useCallback } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
|
||||||
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
|
import { useIntl, defineMessages } from 'react-intl';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
fetchContext,
|
fetchContext,
|
||||||
|
|
@ -8,31 +8,80 @@ import {
|
||||||
} from 'mastodon/actions/statuses';
|
} from 'mastodon/actions/statuses';
|
||||||
import type { AsyncRefreshHeader } from 'mastodon/api';
|
import type { AsyncRefreshHeader } from 'mastodon/api';
|
||||||
import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
|
import { apiGetAsyncRefresh } from 'mastodon/api/async_refreshes';
|
||||||
|
import { Alert } from 'mastodon/components/alert';
|
||||||
|
import { ExitAnimationWrapper } from 'mastodon/components/exit_animation_wrapper';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
const AnimatedAlert: React.FC<
|
||||||
|
React.ComponentPropsWithoutRef<typeof Alert> & { withEntryDelay?: boolean }
|
||||||
|
> = ({ isActive = false, withEntryDelay, ...props }) => (
|
||||||
|
<ExitAnimationWrapper withEntryDelay isActive={isActive}>
|
||||||
|
{(delayedIsActive) => <Alert isActive={delayedIsActive} {...props} />}
|
||||||
|
</ExitAnimationWrapper>
|
||||||
|
);
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
loading: {
|
moreFound: {
|
||||||
|
id: 'status.context.more_replies_found',
|
||||||
|
defaultMessage: 'More replies found',
|
||||||
|
},
|
||||||
|
show: {
|
||||||
|
id: 'status.context.show',
|
||||||
|
defaultMessage: 'Show',
|
||||||
|
},
|
||||||
|
loadingInitial: {
|
||||||
id: 'status.context.loading',
|
id: 'status.context.loading',
|
||||||
defaultMessage: 'Checking for more replies',
|
defaultMessage: 'Loading',
|
||||||
|
},
|
||||||
|
loadingMore: {
|
||||||
|
id: 'status.context.loading_more',
|
||||||
|
defaultMessage: 'Loading more replies',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
id: 'status.context.loading_success',
|
||||||
|
defaultMessage: 'All replies loaded',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
id: 'status.context.loading_error',
|
||||||
|
defaultMessage: "Couldn't load new replies",
|
||||||
|
},
|
||||||
|
retry: {
|
||||||
|
id: 'status.context.retry',
|
||||||
|
defaultMessage: 'Retry',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type LoadingState =
|
||||||
|
| 'idle'
|
||||||
|
| 'more-available'
|
||||||
|
| 'loading-initial'
|
||||||
|
| 'loading-more'
|
||||||
|
| 'success'
|
||||||
|
| 'error';
|
||||||
|
|
||||||
export const RefreshController: React.FC<{
|
export const RefreshController: React.FC<{
|
||||||
statusId: string;
|
statusId: string;
|
||||||
}> = ({ statusId }) => {
|
}> = ({ statusId }) => {
|
||||||
const refresh = useAppSelector(
|
const refresh = useAppSelector(
|
||||||
(state) => state.contexts.refreshing[statusId],
|
(state) => state.contexts.refreshing[statusId],
|
||||||
);
|
);
|
||||||
const autoRefresh = useAppSelector(
|
const currentReplyCount = useAppSelector(
|
||||||
(state) =>
|
(state) => state.contexts.replies[statusId]?.length ?? 0,
|
||||||
!state.contexts.replies[statusId] ||
|
|
||||||
state.contexts.replies[statusId].length === 0,
|
|
||||||
);
|
);
|
||||||
|
const autoRefresh = !currentReplyCount;
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [ready, setReady] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loadingState, setLoadingState] = useState<LoadingState>(
|
||||||
|
refresh && autoRefresh ? 'loading-initial' : 'idle',
|
||||||
|
);
|
||||||
|
|
||||||
|
const [wasDismissed, setWasDismissed] = useState(false);
|
||||||
|
const dismissPrompt = useCallback(() => {
|
||||||
|
setWasDismissed(true);
|
||||||
|
setLoadingState('idle');
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
|
|
@ -45,67 +94,104 @@ export const RefreshController: React.FC<{
|
||||||
|
|
||||||
if (result.async_refresh.result_count > 0) {
|
if (result.async_refresh.result_count > 0) {
|
||||||
if (autoRefresh) {
|
if (autoRefresh) {
|
||||||
void dispatch(fetchContext({ statusId }));
|
void dispatch(fetchContext({ statusId })).then(() => {
|
||||||
return '';
|
setLoadingState('idle');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setLoadingState('more-available');
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
setReady(true);
|
setLoadingState('idle');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
scheduleRefresh(refresh);
|
scheduleRefresh(refresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
|
||||||
});
|
});
|
||||||
}, refresh.retry * 1000);
|
}, refresh.retry * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (refresh) {
|
if (refresh && !wasDismissed) {
|
||||||
scheduleRefresh(refresh);
|
scheduleRefresh(refresh);
|
||||||
|
setLoadingState('loading-initial');
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [dispatch, setReady, statusId, refresh, autoRefresh]);
|
}, [dispatch, statusId, refresh, autoRefresh, wasDismissed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Hide success message after a short delay
|
||||||
|
if (loadingState === 'success') {
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
setLoadingState('idle');
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return () => '';
|
||||||
|
}, [loadingState]);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
const handleClick = useCallback(() => {
|
||||||
setLoading(true);
|
setLoadingState('loading-more');
|
||||||
setReady(false);
|
|
||||||
|
|
||||||
dispatch(fetchContext({ statusId }))
|
dispatch(fetchContext({ statusId }))
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setLoading(false);
|
setLoadingState('success');
|
||||||
return '';
|
return '';
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setLoading(false);
|
setLoadingState('error');
|
||||||
});
|
});
|
||||||
}, [dispatch, setReady, statusId]);
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
if (ready && !loading) {
|
|
||||||
return (
|
|
||||||
<button className='load-more load-gap' onClick={handleClick}>
|
|
||||||
<FormattedMessage
|
|
||||||
id='status.context.load_new_replies'
|
|
||||||
defaultMessage='New replies available'
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!refresh && !loading) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (loadingState === 'loading-initial') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='load-more load-gap'
|
className='load-more load-gap'
|
||||||
aria-busy
|
aria-busy
|
||||||
aria-live='polite'
|
aria-live='polite'
|
||||||
aria-label={intl.formatMessage(messages.loading)}
|
aria-label={intl.formatMessage(messages.loadingInitial)}
|
||||||
>
|
>
|
||||||
<LoadingIndicator />
|
<LoadingIndicator />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='column__alert' role='status' aria-live='polite'>
|
||||||
|
<AnimatedAlert
|
||||||
|
isActive={loadingState === 'more-available'}
|
||||||
|
message={intl.formatMessage(messages.moreFound)}
|
||||||
|
action={intl.formatMessage(messages.show)}
|
||||||
|
onActionClick={handleClick}
|
||||||
|
onDismiss={dismissPrompt}
|
||||||
|
animateFrom='below'
|
||||||
|
/>
|
||||||
|
<AnimatedAlert
|
||||||
|
isLoading
|
||||||
|
withEntryDelay
|
||||||
|
isActive={loadingState === 'loading-more'}
|
||||||
|
message={intl.formatMessage(messages.loadingMore)}
|
||||||
|
animateFrom='below'
|
||||||
|
/>
|
||||||
|
<AnimatedAlert
|
||||||
|
withEntryDelay
|
||||||
|
isActive={loadingState === 'error'}
|
||||||
|
message={intl.formatMessage(messages.error)}
|
||||||
|
action={intl.formatMessage(messages.retry)}
|
||||||
|
onActionClick={handleClick}
|
||||||
|
onDismiss={dismissPrompt}
|
||||||
|
animateFrom='below'
|
||||||
|
/>
|
||||||
|
<AnimatedAlert
|
||||||
|
withEntryDelay
|
||||||
|
isActive={loadingState === 'success'}
|
||||||
|
message={intl.formatMessage(messages.success)}
|
||||||
|
animateFrom='below'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { defineMessages, injectIntl } from 'react-intl';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Helmet } from 'react-helmet';
|
import { Helmet } from 'react-helmet';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { withRouter } from 'react-router-dom';
|
||||||
|
import { difference } from 'lodash';
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
import ImmutablePropTypes from 'react-immutable-proptypes';
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
|
|
@ -15,7 +16,7 @@ import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?reac
|
||||||
import { Hotkeys } from 'mastodon/components/hotkeys';
|
import { Hotkeys } from 'mastodon/components/hotkeys';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import ScrollContainer from 'mastodon/containers/scroll_container';
|
import { ScrollContainer } from 'mastodon/containers/scroll_container';
|
||||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
||||||
|
|
@ -150,6 +151,11 @@ class Status extends ImmutablePureComponent {
|
||||||
fullscreen: false,
|
fullscreen: false,
|
||||||
showMedia: defaultMediaVisibility(this.props.status),
|
showMedia: defaultMediaVisibility(this.props.status),
|
||||||
loadedStatusId: undefined,
|
loadedStatusId: undefined,
|
||||||
|
/**
|
||||||
|
* Holds the ids of newly added replies, excluding the initial load.
|
||||||
|
* Used to highlight newly added replies in the UI
|
||||||
|
*/
|
||||||
|
newRepliesIds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
UNSAFE_componentWillMount () {
|
UNSAFE_componentWillMount () {
|
||||||
|
|
@ -462,6 +468,7 @@ class Status extends ImmutablePureComponent {
|
||||||
previousId={i > 0 ? list[i - 1] : undefined}
|
previousId={i > 0 ? list[i - 1] : undefined}
|
||||||
nextId={list[i + 1] || (ancestors && statusId)}
|
nextId={list[i + 1] || (ancestors && statusId)}
|
||||||
rootId={statusId}
|
rootId={statusId}
|
||||||
|
shouldHighlightOnMount={this.state.newRepliesIds.includes(id)}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -495,11 +502,20 @@ class Status extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
componentDidUpdate (prevProps) {
|
||||||
const { status, ancestorsIds } = this.props;
|
const { status, ancestorsIds, descendantsIds } = this.props;
|
||||||
|
|
||||||
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) {
|
if (status && (ancestorsIds.length > prevProps.ancestorsIds.length || prevProps.status?.get('id') !== status.get('id'))) {
|
||||||
this._scrollStatusIntoView();
|
this._scrollStatusIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only highlight replies after the initial load
|
||||||
|
if (prevProps.descendantsIds.length) {
|
||||||
|
const newRepliesIds = difference(descendantsIds, prevProps.descendantsIds);
|
||||||
|
|
||||||
|
if (newRepliesIds.length) {
|
||||||
|
this.setState({newRepliesIds});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
|
|
@ -510,9 +526,9 @@ class Status extends ImmutablePureComponent {
|
||||||
this.setState({ fullscreen: isFullscreen() });
|
this.setState({ fullscreen: isFullscreen() });
|
||||||
};
|
};
|
||||||
|
|
||||||
shouldUpdateScroll = (prevRouterProps, { location }) => {
|
shouldUpdateScroll = (prevLocation, location) => {
|
||||||
// Do not change scroll when opening a modal
|
// Do not change scroll when opening a modal
|
||||||
if (location.state?.mastodonModalKey !== prevRouterProps?.location?.state?.mastodonModalKey) {
|
if (location.state?.mastodonModalKey !== prevLocation?.state?.mastodonModalKey) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -586,7 +602,7 @@ class Status extends ImmutablePureComponent {
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll}>
|
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll} childRef={this.setContainerRef}>
|
||||||
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
|
|
@ -632,8 +648,8 @@ class Status extends ImmutablePureComponent {
|
||||||
</div>
|
</div>
|
||||||
</Hotkeys>
|
</Hotkeys>
|
||||||
|
|
||||||
{remoteHint}
|
|
||||||
{descendants}
|
{descendants}
|
||||||
|
{remoteHint}
|
||||||
</div>
|
</div>
|
||||||
</ScrollContainer>
|
</ScrollContainer>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export interface BaseConfirmationModalProps {
|
||||||
export const ConfirmationModal: React.FC<
|
export const ConfirmationModal: React.FC<
|
||||||
{
|
{
|
||||||
title: React.ReactNode;
|
title: React.ReactNode;
|
||||||
message: React.ReactNode;
|
message?: React.ReactNode;
|
||||||
confirm: React.ReactNode;
|
confirm: React.ReactNode;
|
||||||
cancel?: React.ReactNode;
|
cancel?: React.ReactNode;
|
||||||
secondary?: React.ReactNode;
|
secondary?: React.ReactNode;
|
||||||
|
|
@ -48,7 +48,7 @@ export const ConfirmationModal: React.FC<
|
||||||
<div className='safety-action-modal__top'>
|
<div className='safety-action-modal__top'>
|
||||||
<div className='safety-action-modal__confirmation'>
|
<div className='safety-action-modal__confirmation'>
|
||||||
<h1>{title}</h1>
|
<h1>{title}</h1>
|
||||||
<p>{message}</p>
|
{message && <p>{message}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ export {
|
||||||
ConfirmReplyModal,
|
ConfirmReplyModal,
|
||||||
ConfirmEditStatusModal,
|
ConfirmEditStatusModal,
|
||||||
} from './discard_draft_confirmation';
|
} from './discard_draft_confirmation';
|
||||||
|
export { ConfirmWithdrawRequestModal } from './withdraw_follow_request';
|
||||||
export { ConfirmUnfollowModal } from './unfollow';
|
export { ConfirmUnfollowModal } from './unfollow';
|
||||||
|
export { ConfirmUnblockModal } from './unblock';
|
||||||
export { ConfirmClearNotificationsModal } from './clear_notifications';
|
export { ConfirmClearNotificationsModal } from './clear_notifications';
|
||||||
export { ConfirmLogOutModal } from './log_out';
|
export { ConfirmLogOutModal } from './log_out';
|
||||||
export { ConfirmFollowToListModal } from './follow_to_list';
|
export { ConfirmFollowToListModal } from './follow_to_list';
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { unblockAccount } from 'mastodon/actions/accounts';
|
||||||
|
import type { Account } from 'mastodon/models/account';
|
||||||
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||||
|
import { ConfirmationModal } from './confirmation_modal';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
unblockConfirm: {
|
||||||
|
id: 'confirmations.unblock.confirm',
|
||||||
|
defaultMessage: 'Unblock',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConfirmUnblockModal: React.FC<
|
||||||
|
{
|
||||||
|
account: Account;
|
||||||
|
} & BaseConfirmationModalProps
|
||||||
|
> = ({ account, onClose }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const onConfirm = useCallback(() => {
|
||||||
|
dispatch(unblockAccount(account.id));
|
||||||
|
}, [dispatch, account.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationModal
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.unblock.title'
|
||||||
|
defaultMessage='Unblock {name}?'
|
||||||
|
values={{ name: `@${account.acct}` }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
confirm={intl.formatMessage(messages.unblockConfirm)}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -10,10 +10,6 @@ import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||||
import { ConfirmationModal } from './confirmation_modal';
|
import { ConfirmationModal } from './confirmation_modal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
unfollowTitle: {
|
|
||||||
id: 'confirmations.unfollow.title',
|
|
||||||
defaultMessage: 'Unfollow user?',
|
|
||||||
},
|
|
||||||
unfollowConfirm: {
|
unfollowConfirm: {
|
||||||
id: 'confirmations.unfollow.confirm',
|
id: 'confirmations.unfollow.confirm',
|
||||||
defaultMessage: 'Unfollow',
|
defaultMessage: 'Unfollow',
|
||||||
|
|
@ -34,12 +30,11 @@ export const ConfirmUnfollowModal: React.FC<
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmationModal
|
<ConfirmationModal
|
||||||
title={intl.formatMessage(messages.unfollowTitle)}
|
title={
|
||||||
message={
|
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
id='confirmations.unfollow.message'
|
id='confirmations.unfollow.title'
|
||||||
defaultMessage='Are you sure you want to unfollow {name}?'
|
defaultMessage='Unfollow {name}?'
|
||||||
values={{ name: <strong>@{account.acct}</strong> }}
|
values={{ name: `@${account.acct}` }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
confirm={intl.formatMessage(messages.unfollowConfirm)}
|
confirm={intl.formatMessage(messages.unfollowConfirm)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import { unfollowAccount } from 'mastodon/actions/accounts';
|
||||||
|
import type { Account } from 'mastodon/models/account';
|
||||||
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import type { BaseConfirmationModalProps } from './confirmation_modal';
|
||||||
|
import { ConfirmationModal } from './confirmation_modal';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
withdrawConfirm: {
|
||||||
|
id: 'confirmations.withdraw_request.confirm',
|
||||||
|
defaultMessage: 'Withdraw request',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ConfirmWithdrawRequestModal: React.FC<
|
||||||
|
{
|
||||||
|
account: Account;
|
||||||
|
} & BaseConfirmationModalProps
|
||||||
|
> = ({ account, onClose }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const onConfirm = useCallback(() => {
|
||||||
|
dispatch(unfollowAccount(account.id));
|
||||||
|
}, [dispatch, account.id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmationModal
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id='confirmations.withdraw_request.title'
|
||||||
|
defaultMessage='Withdraw request to follow {name}?'
|
||||||
|
values={{ name: `@${account.acct}` }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
confirm={intl.formatMessage(messages.withdrawConfirm)}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -36,6 +36,7 @@ const EmbedModal: React.FC<{
|
||||||
}
|
}
|
||||||
|
|
||||||
iframeDocument.open();
|
iframeDocument.open();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||||
iframeDocument.write(data.html);
|
iframeDocument.write(data.html);
|
||||||
iframeDocument.close();
|
iframeDocument.close();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,9 @@ import {
|
||||||
ConfirmDeleteListModal,
|
ConfirmDeleteListModal,
|
||||||
ConfirmReplyModal,
|
ConfirmReplyModal,
|
||||||
ConfirmEditStatusModal,
|
ConfirmEditStatusModal,
|
||||||
|
ConfirmUnblockModal,
|
||||||
ConfirmUnfollowModal,
|
ConfirmUnfollowModal,
|
||||||
|
ConfirmWithdrawRequestModal,
|
||||||
ConfirmClearNotificationsModal,
|
ConfirmClearNotificationsModal,
|
||||||
ConfirmLogOutModal,
|
ConfirmLogOutModal,
|
||||||
ConfirmFollowToListModal,
|
ConfirmFollowToListModal,
|
||||||
|
|
@ -57,7 +59,9 @@ export const MODAL_COMPONENTS = {
|
||||||
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
|
'CONFIRM_DELETE_LIST': () => Promise.resolve({ default: ConfirmDeleteListModal }),
|
||||||
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
|
'CONFIRM_REPLY': () => Promise.resolve({ default: ConfirmReplyModal }),
|
||||||
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
|
'CONFIRM_EDIT_STATUS': () => Promise.resolve({ default: ConfirmEditStatusModal }),
|
||||||
|
'CONFIRM_UNBLOCK': () => Promise.resolve({ default: ConfirmUnblockModal }),
|
||||||
'CONFIRM_UNFOLLOW': () => Promise.resolve({ default: ConfirmUnfollowModal }),
|
'CONFIRM_UNFOLLOW': () => Promise.resolve({ default: ConfirmUnfollowModal }),
|
||||||
|
'CONFIRM_WITHDRAW_REQUEST': () => Promise.resolve({ default: ConfirmWithdrawRequestModal }),
|
||||||
'CONFIRM_CLEAR_NOTIFICATIONS': () => Promise.resolve({ default: ConfirmClearNotificationsModal }),
|
'CONFIRM_CLEAR_NOTIFICATIONS': () => Promise.resolve({ default: ConfirmClearNotificationsModal }),
|
||||||
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
|
'CONFIRM_LOG_OUT': () => Promise.resolve({ default: ConfirmLogOutModal }),
|
||||||
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
'CONFIRM_FOLLOW_TO_LIST': () => Promise.resolve({ default: ConfirmFollowToListModal }),
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
const breakpoints = {
|
const breakpoints = {
|
||||||
|
narrow: 479, // Device width under which horizontal space is constrained
|
||||||
openable: 759, // Device width at which the sidebar becomes an openable hamburger menu
|
openable: 759, // Device width at which the sidebar becomes an openable hamburger menu
|
||||||
full: 1174, // Device width at which all 3 columns can be displayed
|
full: 1174, // Device width at which all 3 columns can be displayed
|
||||||
};
|
};
|
||||||
|
|
||||||
type Breakpoint = 'openable' | 'full';
|
type Breakpoint = keyof typeof breakpoints;
|
||||||
|
|
||||||
export const useBreakpoint = (breakpoint: Breakpoint) => {
|
export const useBreakpoint = (breakpoint: Breakpoint) => {
|
||||||
const [isMatching, setIsMatching] = useState(false);
|
const [isMatching, setIsMatching] = useState(false);
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ import { uploadCompose, resetCompose, changeComposeSpoilerness } from '../../act
|
||||||
import { clearHeight } from '../../actions/height_cache';
|
import { clearHeight } from '../../actions/height_cache';
|
||||||
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
|
import { fetchServer, fetchServerTranslationLanguages } from '../../actions/server';
|
||||||
import { expandHomeTimeline } from '../../actions/timelines';
|
import { expandHomeTimeline } from '../../actions/timelines';
|
||||||
import initialState, { me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
|
import { initialState, me, owner, singleUserMode, trendsEnabled, trendsAsLanding, disableHoverCards, autoPlayGif } from '../../initial_state';
|
||||||
|
|
||||||
import BundleColumnError from './components/bundle_column_error';
|
import BundleColumnError from './components/bundle_column_error';
|
||||||
import { NavigationBar } from './components/navigation_bar';
|
import { NavigationBar } from './components/navigation_bar';
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import initialState from '@/mastodon/initial_state';
|
import { initialState } from '@/mastodon/initial_state';
|
||||||
|
|
||||||
interface FocusColumnOptions {
|
interface FocusColumnOptions {
|
||||||
index?: number;
|
index?: number;
|
||||||
|
|
@ -60,23 +60,13 @@ export function focusColumn({
|
||||||
* Get the index of the currently focused item in one of our item lists
|
* Get the index of the currently focused item in one of our item lists
|
||||||
*/
|
*/
|
||||||
export function getFocusedItemIndex() {
|
export function getFocusedItemIndex() {
|
||||||
const focusedElement = document.activeElement;
|
const focusedItem = document.activeElement?.closest('.item-list > *');
|
||||||
const itemList = focusedElement?.closest('.item-list');
|
|
||||||
|
|
||||||
if (!focusedElement || !itemList) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let focusedItem: HTMLElement | null = null;
|
|
||||||
if (focusedElement.parentElement === itemList) {
|
|
||||||
focusedItem = focusedElement as HTMLElement;
|
|
||||||
} else {
|
|
||||||
focusedItem = focusedElement.closest('.item-list > *');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!focusedItem) return -1;
|
if (!focusedItem) return -1;
|
||||||
|
|
||||||
const items = Array.from(itemList.children);
|
const { parentElement } = focusedItem;
|
||||||
|
if (!parentElement) return -1;
|
||||||
|
|
||||||
|
const items = Array.from(parentElement.children);
|
||||||
return items.indexOf(focusedItem);
|
return items.indexOf(focusedItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const isMentionClick = (element: HTMLAnchorElement) =>
|
||||||
!element.classList.contains('hashtag');
|
!element.classList.contains('hashtag');
|
||||||
|
|
||||||
const isHashtagClick = (element: HTMLAnchorElement) =>
|
const isHashtagClick = (element: HTMLAnchorElement) =>
|
||||||
element.textContent?.[0] === '#' ||
|
element.textContent.startsWith('#') ||
|
||||||
element.previousSibling?.textContent?.endsWith('#');
|
element.previousSibling?.textContent?.endsWith('#');
|
||||||
|
|
||||||
export const useLinks = (skipHashtags?: boolean) => {
|
export const useLinks = (skipHashtags?: boolean) => {
|
||||||
|
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
// @ts-check
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef {[code: string, name: string, localName: string]} InitialStateLanguage
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef InitialStateMeta
|
|
||||||
* @property {string} access_token
|
|
||||||
* @property {boolean=} advanced_layout
|
|
||||||
* @property {boolean} auto_play_gif
|
|
||||||
* @property {boolean} activity_api_enabled
|
|
||||||
* @property {string} admin
|
|
||||||
* @property {boolean=} boost_modal
|
|
||||||
* @property {boolean=} delete_modal
|
|
||||||
* @property {boolean=} missing_alt_text_modal
|
|
||||||
* @property {boolean=} disable_swiping
|
|
||||||
* @property {boolean=} disable_hover_cards
|
|
||||||
* @property {string=} disabled_account_id
|
|
||||||
* @property {string} display_media
|
|
||||||
* @property {string} domain
|
|
||||||
* @property {boolean=} expand_spoilers
|
|
||||||
* @property {boolean} limited_federation_mode
|
|
||||||
* @property {string} locale
|
|
||||||
* @property {string | null} mascot
|
|
||||||
* @property {string=} me
|
|
||||||
* @property {string=} moved_to_account_id
|
|
||||||
* @property {string=} owner
|
|
||||||
* @property {boolean} profile_directory
|
|
||||||
* @property {boolean} registrations_open
|
|
||||||
* @property {boolean} reduce_motion
|
|
||||||
* @property {string} repository
|
|
||||||
* @property {boolean} search_enabled
|
|
||||||
* @property {boolean} trends_enabled
|
|
||||||
* @property {boolean} single_user_mode
|
|
||||||
* @property {string} source_url
|
|
||||||
* @property {string} streaming_api_base_url
|
|
||||||
* @property {boolean} timeline_preview
|
|
||||||
* @property {string} title
|
|
||||||
* @property {boolean} show_trends
|
|
||||||
* @property {boolean} trends_as_landing_page
|
|
||||||
* @property {boolean} use_blurhash
|
|
||||||
* @property {boolean=} use_pending_items
|
|
||||||
* @property {string} version
|
|
||||||
* @property {string} sso_redirect
|
|
||||||
* @property {string} status_page_url
|
|
||||||
* @property {boolean} terms_of_service_enabled
|
|
||||||
* @property {string?} emoji_style
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef Role
|
|
||||||
* @property {string} id
|
|
||||||
* @property {string} name
|
|
||||||
* @property {string} permissions
|
|
||||||
* @property {string} color
|
|
||||||
* @property {boolean} highlighted
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @typedef InitialState
|
|
||||||
* @property {Record<string, import("./api_types/accounts").ApiAccountJSON>} accounts
|
|
||||||
* @property {InitialStateLanguage[]} languages
|
|
||||||
* @property {boolean=} critical_updates_pending
|
|
||||||
* @property {InitialStateMeta} meta
|
|
||||||
* @property {Role?} role
|
|
||||||
* @property {string[]} features
|
|
||||||
*/
|
|
||||||
|
|
||||||
const element = document.getElementById('initial-state');
|
|
||||||
/** @type {InitialState | undefined} */
|
|
||||||
const initialState = element?.textContent && JSON.parse(element.textContent);
|
|
||||||
|
|
||||||
/** @type {string} */
|
|
||||||
const initialPath = document.querySelector("head meta[name=initialPath]")?.getAttribute("content") ?? '';
|
|
||||||
/** @type {boolean} */
|
|
||||||
export const hasMultiColumnPath = initialPath === '/'
|
|
||||||
|| initialPath === '/getting-started'
|
|
||||||
|| initialPath === '/home'
|
|
||||||
|| initialPath.startsWith('/deck');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @template {keyof InitialStateMeta} K
|
|
||||||
* @param {K} prop
|
|
||||||
* @returns {InitialStateMeta[K] | undefined}
|
|
||||||
*/
|
|
||||||
const getMeta = (prop) => initialState?.meta && initialState.meta[prop];
|
|
||||||
|
|
||||||
export const activityApiEnabled = getMeta('activity_api_enabled');
|
|
||||||
export const autoPlayGif = getMeta('auto_play_gif');
|
|
||||||
export const boostModal = getMeta('boost_modal');
|
|
||||||
export const deleteModal = getMeta('delete_modal');
|
|
||||||
export const missingAltTextModal = getMeta('missing_alt_text_modal');
|
|
||||||
export const disableSwiping = getMeta('disable_swiping');
|
|
||||||
export const disableHoverCards = getMeta('disable_hover_cards');
|
|
||||||
export const disabledAccountId = getMeta('disabled_account_id');
|
|
||||||
export const displayMedia = getMeta('display_media');
|
|
||||||
export const domain = getMeta('domain');
|
|
||||||
export const emojiStyle = getMeta('emoji_style') || 'auto';
|
|
||||||
export const expandSpoilers = getMeta('expand_spoilers');
|
|
||||||
export const forceSingleColumn = !getMeta('advanced_layout');
|
|
||||||
export const limitedFederationMode = getMeta('limited_federation_mode');
|
|
||||||
export const mascot = getMeta('mascot');
|
|
||||||
export const me = getMeta('me');
|
|
||||||
export const movedToAccountId = getMeta('moved_to_account_id');
|
|
||||||
export const owner = getMeta('owner');
|
|
||||||
export const profile_directory = getMeta('profile_directory');
|
|
||||||
export const reduceMotion = getMeta('reduce_motion');
|
|
||||||
export const registrationsOpen = getMeta('registrations_open');
|
|
||||||
export const repository = getMeta('repository');
|
|
||||||
export const searchEnabled = getMeta('search_enabled');
|
|
||||||
export const trendsEnabled = getMeta('trends_enabled');
|
|
||||||
export const showTrends = getMeta('show_trends');
|
|
||||||
export const singleUserMode = getMeta('single_user_mode');
|
|
||||||
export const source_url = getMeta('source_url');
|
|
||||||
export const timelinePreview = getMeta('timeline_preview');
|
|
||||||
export const title = getMeta('title');
|
|
||||||
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
|
||||||
export const useBlurhash = getMeta('use_blurhash');
|
|
||||||
export const usePendingItems = getMeta('use_pending_items');
|
|
||||||
export const version = getMeta('version');
|
|
||||||
export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
|
||||||
export const statusPageUrl = getMeta('status_page_url');
|
|
||||||
export const sso_redirect = getMeta('sso_redirect');
|
|
||||||
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
|
|
||||||
|
|
||||||
const displayNames = Intl.DisplayNames && new Intl.DisplayNames(getMeta('locale'), {
|
|
||||||
type: 'language',
|
|
||||||
fallback: 'none',
|
|
||||||
languageDisplay: 'standard',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const languages = initialState?.languages?.map(lang => {
|
|
||||||
// zh-YUE is not a valid CLDR unicode_language_id
|
|
||||||
return [lang[0], displayNames?.of(lang[0].replace('zh-YUE', 'yue')) || lang[1], lang[2]];
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @returns {string | undefined}
|
|
||||||
*/
|
|
||||||
export function getAccessToken() {
|
|
||||||
return getMeta('access_token');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default initialState;
|
|
||||||
141
app/javascript/mastodon/initial_state.ts
Normal file
141
app/javascript/mastodon/initial_state.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
import type { ApiAccountJSON } from './api_types/accounts';
|
||||||
|
|
||||||
|
type InitialStateLanguage = [code: string, name: string, localName: string];
|
||||||
|
|
||||||
|
interface InitialStateMeta {
|
||||||
|
access_token: string;
|
||||||
|
advanced_layout?: boolean;
|
||||||
|
auto_play_gif: boolean;
|
||||||
|
activity_api_enabled: boolean;
|
||||||
|
admin: string;
|
||||||
|
boost_modal?: boolean;
|
||||||
|
delete_modal?: boolean;
|
||||||
|
missing_alt_text_modal?: boolean;
|
||||||
|
disable_swiping?: boolean;
|
||||||
|
disable_hover_cards?: boolean;
|
||||||
|
disabled_account_id?: string;
|
||||||
|
display_media: string;
|
||||||
|
domain: string;
|
||||||
|
expand_spoilers?: boolean;
|
||||||
|
limited_federation_mode: boolean;
|
||||||
|
locale: string;
|
||||||
|
mascot: string | null;
|
||||||
|
me?: string;
|
||||||
|
moved_to_account_id?: string;
|
||||||
|
owner?: string;
|
||||||
|
profile_directory: boolean;
|
||||||
|
registrations_open: boolean;
|
||||||
|
reduce_motion: boolean;
|
||||||
|
repository: string;
|
||||||
|
search_enabled: boolean;
|
||||||
|
trends_enabled: boolean;
|
||||||
|
single_user_mode: boolean;
|
||||||
|
source_url: string;
|
||||||
|
streaming_api_base_url: string;
|
||||||
|
timeline_preview: boolean;
|
||||||
|
title: string;
|
||||||
|
show_trends: boolean;
|
||||||
|
trends_as_landing_page: boolean;
|
||||||
|
use_blurhash: boolean;
|
||||||
|
use_pending_items?: boolean;
|
||||||
|
version: string;
|
||||||
|
sso_redirect: string;
|
||||||
|
status_page_url: string;
|
||||||
|
terms_of_service_enabled: boolean;
|
||||||
|
emoji_style?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
permissions: string;
|
||||||
|
color: string;
|
||||||
|
highlighted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InitialState {
|
||||||
|
accounts: Record<string, ApiAccountJSON>;
|
||||||
|
languages: InitialStateLanguage[];
|
||||||
|
critical_updates_pending?: boolean;
|
||||||
|
meta: InitialStateMeta;
|
||||||
|
role?: Role;
|
||||||
|
features: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = document.getElementById('initial-state');
|
||||||
|
export const initialState: InitialState | undefined = element?.textContent
|
||||||
|
? (JSON.parse(element.textContent) as InitialState)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const initialPath: string =
|
||||||
|
document
|
||||||
|
.querySelector('head meta[name=initialPath]')
|
||||||
|
?.getAttribute('content') ?? '';
|
||||||
|
export const hasMultiColumnPath: boolean =
|
||||||
|
initialPath === '/' ||
|
||||||
|
initialPath === '/getting-started' ||
|
||||||
|
initialPath === '/home' ||
|
||||||
|
initialPath.startsWith('/deck');
|
||||||
|
|
||||||
|
function getMeta<K extends keyof InitialStateMeta>(
|
||||||
|
prop: K,
|
||||||
|
): InitialStateMeta[K] | undefined {
|
||||||
|
return initialState?.meta[prop];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activityApiEnabled = getMeta('activity_api_enabled');
|
||||||
|
export const autoPlayGif = getMeta('auto_play_gif');
|
||||||
|
export const boostModal = getMeta('boost_modal');
|
||||||
|
export const deleteModal = getMeta('delete_modal');
|
||||||
|
export const missingAltTextModal = getMeta('missing_alt_text_modal');
|
||||||
|
export const disableSwiping = getMeta('disable_swiping');
|
||||||
|
export const disableHoverCards = getMeta('disable_hover_cards');
|
||||||
|
export const disabledAccountId = getMeta('disabled_account_id');
|
||||||
|
export const displayMedia = getMeta('display_media');
|
||||||
|
export const domain = getMeta('domain');
|
||||||
|
export const emojiStyle = getMeta('emoji_style') ?? 'auto';
|
||||||
|
export const expandSpoilers = getMeta('expand_spoilers');
|
||||||
|
export const forceSingleColumn = !getMeta('advanced_layout');
|
||||||
|
export const limitedFederationMode = getMeta('limited_federation_mode');
|
||||||
|
export const mascot = getMeta('mascot');
|
||||||
|
export const me = getMeta('me');
|
||||||
|
export const movedToAccountId = getMeta('moved_to_account_id');
|
||||||
|
export const owner = getMeta('owner');
|
||||||
|
export const profile_directory = getMeta('profile_directory');
|
||||||
|
export const reduceMotion = getMeta('reduce_motion');
|
||||||
|
export const registrationsOpen = getMeta('registrations_open');
|
||||||
|
export const repository = getMeta('repository');
|
||||||
|
export const searchEnabled = getMeta('search_enabled');
|
||||||
|
export const trendsEnabled = getMeta('trends_enabled');
|
||||||
|
export const showTrends = getMeta('show_trends');
|
||||||
|
export const singleUserMode = getMeta('single_user_mode');
|
||||||
|
export const source_url = getMeta('source_url');
|
||||||
|
export const timelinePreview = getMeta('timeline_preview');
|
||||||
|
export const title = getMeta('title');
|
||||||
|
export const trendsAsLanding = getMeta('trends_as_landing_page');
|
||||||
|
export const useBlurhash = getMeta('use_blurhash');
|
||||||
|
export const usePendingItems = getMeta('use_pending_items');
|
||||||
|
export const version = getMeta('version');
|
||||||
|
export const criticalUpdatesPending = initialState?.critical_updates_pending;
|
||||||
|
export const statusPageUrl = getMeta('status_page_url');
|
||||||
|
export const sso_redirect = getMeta('sso_redirect');
|
||||||
|
export const termsOfServiceEnabled = getMeta('terms_of_service_enabled');
|
||||||
|
|
||||||
|
const displayNames = new Intl.DisplayNames(getMeta('locale'), {
|
||||||
|
type: 'language',
|
||||||
|
fallback: 'none',
|
||||||
|
languageDisplay: 'standard',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const languages = initialState?.languages.map((lang) => {
|
||||||
|
// zh-YUE is not a valid CLDR unicode_language_id
|
||||||
|
return [
|
||||||
|
lang[0],
|
||||||
|
displayNames.of(lang[0].replace('zh-YUE', 'yue')) ?? lang[1],
|
||||||
|
lang[2],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getAccessToken(): string | undefined {
|
||||||
|
return getMeta('access_token');
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,6 @@
|
||||||
"account.posts": "Plasings",
|
"account.posts": "Plasings",
|
||||||
"account.posts_with_replies": "Plasings en antwoorde",
|
"account.posts_with_replies": "Plasings en antwoorde",
|
||||||
"account.report": "Rapporteer @{name}",
|
"account.report": "Rapporteer @{name}",
|
||||||
"account.requested": "Wag op goedkeuring. Klik om volgversoek te kanselleer",
|
|
||||||
"account.requested_follow": "{name} het versoek om jou te volg",
|
"account.requested_follow": "{name} het versoek om jou te volg",
|
||||||
"account.share": "Deel @{name} se profiel",
|
"account.share": "Deel @{name} se profiel",
|
||||||
"account.show_reblogs": "Wys aangestuurde plasings van @{name}",
|
"account.show_reblogs": "Wys aangestuurde plasings van @{name}",
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,6 @@
|
||||||
"account.posts": "Publicacions",
|
"account.posts": "Publicacions",
|
||||||
"account.posts_with_replies": "Publicacions y respuestas",
|
"account.posts_with_replies": "Publicacions y respuestas",
|
||||||
"account.report": "Denunciar a @{name}",
|
"account.report": "Denunciar a @{name}",
|
||||||
"account.requested": "Esperando l'aprebación",
|
|
||||||
"account.requested_follow": "{name} ha demandau seguir-te",
|
"account.requested_follow": "{name} ha demandau seguir-te",
|
||||||
"account.share": "Compartir lo perfil de @{name}",
|
"account.share": "Compartir lo perfil de @{name}",
|
||||||
"account.show_reblogs": "Amostrar retutz de @{name}",
|
"account.show_reblogs": "Amostrar retutz de @{name}",
|
||||||
|
|
@ -134,7 +133,6 @@
|
||||||
"confirmations.mute.confirm": "Silenciar",
|
"confirmations.mute.confirm": "Silenciar",
|
||||||
"confirmations.redraft.confirm": "Borrar y tornar ta borrador",
|
"confirmations.redraft.confirm": "Borrar y tornar ta borrador",
|
||||||
"confirmations.unfollow.confirm": "Deixar de seguir",
|
"confirmations.unfollow.confirm": "Deixar de seguir",
|
||||||
"confirmations.unfollow.message": "Yes seguro que quiers deixar de seguir a {name}?",
|
|
||||||
"conversation.delete": "Borrar conversación",
|
"conversation.delete": "Borrar conversación",
|
||||||
"conversation.mark_as_read": "Marcar como leyiu",
|
"conversation.mark_as_read": "Marcar como leyiu",
|
||||||
"conversation.open": "Veyer conversación",
|
"conversation.open": "Veyer conversación",
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@
|
||||||
"account.posts_with_replies": "المنشورات والرُدود",
|
"account.posts_with_replies": "المنشورات والرُدود",
|
||||||
"account.remove_from_followers": "إزالة {name} من المتابعين",
|
"account.remove_from_followers": "إزالة {name} من المتابعين",
|
||||||
"account.report": "الإبلاغ عن @{name}",
|
"account.report": "الإبلاغ عن @{name}",
|
||||||
"account.requested": "في انتظار القبول. اضغط لإلغاء طلب المُتابعة",
|
|
||||||
"account.requested_follow": "لقد طلب {name} متابعتك",
|
"account.requested_follow": "لقد طلب {name} متابعتك",
|
||||||
"account.requests_to_follow_you": "طلبات المتابعة",
|
"account.requests_to_follow_you": "طلبات المتابعة",
|
||||||
"account.share": "شارِك الملف التعريفي لـ @{name}",
|
"account.share": "شارِك الملف التعريفي لـ @{name}",
|
||||||
|
|
@ -253,8 +252,6 @@
|
||||||
"confirmations.revoke_quote.message": "لا يمكن التراجع عن هذا الإجراء.",
|
"confirmations.revoke_quote.message": "لا يمكن التراجع عن هذا الإجراء.",
|
||||||
"confirmations.revoke_quote.title": "أتريد إزالة المنشور؟",
|
"confirmations.revoke_quote.title": "أتريد إزالة المنشور؟",
|
||||||
"confirmations.unfollow.confirm": "إلغاء المتابعة",
|
"confirmations.unfollow.confirm": "إلغاء المتابعة",
|
||||||
"confirmations.unfollow.message": "متأكد من أنك تريد إلغاء متابعة {name} ؟",
|
|
||||||
"confirmations.unfollow.title": "إلغاء متابعة المستخدم؟",
|
|
||||||
"content_warning.hide": "إخفاء المنشور",
|
"content_warning.hide": "إخفاء المنشور",
|
||||||
"content_warning.show": "إظهار على أي حال",
|
"content_warning.show": "إظهار على أي حال",
|
||||||
"content_warning.show_more": "إظهار المزيد",
|
"content_warning.show_more": "إظهار المزيد",
|
||||||
|
|
@ -861,8 +858,6 @@
|
||||||
"status.cancel_reblog_private": "إلغاء إعادة النشر",
|
"status.cancel_reblog_private": "إلغاء إعادة النشر",
|
||||||
"status.cannot_quote": "غير مصرح لك باقتباس هذا المنشور",
|
"status.cannot_quote": "غير مصرح لك باقتباس هذا المنشور",
|
||||||
"status.cannot_reblog": "لا يمكن إعادة نشر هذا المنشور",
|
"status.cannot_reblog": "لا يمكن إعادة نشر هذا المنشور",
|
||||||
"status.context.load_new_replies": "الردود الجديدة المتاحة",
|
|
||||||
"status.context.loading": "التحقق من المزيد من الردود",
|
|
||||||
"status.continued_thread": "تكملة للخيط",
|
"status.continued_thread": "تكملة للخيط",
|
||||||
"status.copy": "انسخ رابط الرسالة",
|
"status.copy": "انسخ رابط الرسالة",
|
||||||
"status.delete": "احذف",
|
"status.delete": "احذف",
|
||||||
|
|
|
||||||
|
|
@ -155,8 +155,6 @@
|
||||||
"confirmations.redraft.confirm": "Desaniciar y reeditar",
|
"confirmations.redraft.confirm": "Desaniciar y reeditar",
|
||||||
"confirmations.redraft.title": "¿Desaniciar y reeditar la publicación?",
|
"confirmations.redraft.title": "¿Desaniciar y reeditar la publicación?",
|
||||||
"confirmations.unfollow.confirm": "Dexar de siguir",
|
"confirmations.unfollow.confirm": "Dexar de siguir",
|
||||||
"confirmations.unfollow.message": "¿De xuru que quies dexar de siguir a {name}?",
|
|
||||||
"confirmations.unfollow.title": "¿Dexar de siguir al usuariu?",
|
|
||||||
"content_warning.hide": "Esconder la publicación",
|
"content_warning.hide": "Esconder la publicación",
|
||||||
"content_warning.show": "Amosar de toes toes",
|
"content_warning.show": "Amosar de toes toes",
|
||||||
"content_warning.show_more": "Amosar más",
|
"content_warning.show_more": "Amosar más",
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@
|
||||||
"account.posts_with_replies": "Paylaşım və cavablar",
|
"account.posts_with_replies": "Paylaşım və cavablar",
|
||||||
"account.remove_from_followers": "{name} - izləyicilərdən çıxart",
|
"account.remove_from_followers": "{name} - izləyicilərdən çıxart",
|
||||||
"account.report": "@{name} istifadəçisini şikayət et",
|
"account.report": "@{name} istifadəçisini şikayət et",
|
||||||
"account.requested": "Təsdiq edilməsi gözlənilir. İzləmə sorğusunu ləğv etmək üçün kliklə",
|
|
||||||
"account.requested_follow": "{name} sizi izləmək sorğusu göndərib",
|
"account.requested_follow": "{name} sizi izləmək sorğusu göndərib",
|
||||||
"account.requests_to_follow_you": "Sizi izləmək istəyir",
|
"account.requests_to_follow_you": "Sizi izləmək istəyir",
|
||||||
"account.share": "@{name} profilini paylaş",
|
"account.share": "@{name} profilini paylaş",
|
||||||
|
|
@ -249,8 +248,6 @@
|
||||||
"confirmations.revoke_quote.message": "Bu əməliyyatın geri dönüşü yoxdur.",
|
"confirmations.revoke_quote.message": "Bu əməliyyatın geri dönüşü yoxdur.",
|
||||||
"confirmations.revoke_quote.title": "Göndəriş silinsin?",
|
"confirmations.revoke_quote.title": "Göndəriş silinsin?",
|
||||||
"confirmations.unfollow.confirm": "İzləmədən çıxar",
|
"confirmations.unfollow.confirm": "İzləmədən çıxar",
|
||||||
"confirmations.unfollow.message": "{name} izləmədən çıxmaq istədiyinizə əminsiniz?",
|
|
||||||
"confirmations.unfollow.title": "İstifadəçi izləmədən çıxarılsın?",
|
|
||||||
"content_warning.hide": "Paylaşımı gizlət",
|
"content_warning.hide": "Paylaşımı gizlət",
|
||||||
"content_warning.show": "Yenə də göstər",
|
"content_warning.show": "Yenə də göstər",
|
||||||
"content_warning.show_more": "Daha çox göstər",
|
"content_warning.show_more": "Daha çox göstər",
|
||||||
|
|
@ -829,8 +826,6 @@
|
||||||
"status.bookmark": "Əlfəcin",
|
"status.bookmark": "Əlfəcin",
|
||||||
"status.cancel_reblog_private": "Təkrar paylaşımı geri al",
|
"status.cancel_reblog_private": "Təkrar paylaşımı geri al",
|
||||||
"status.cannot_reblog": "Bu göndəriş təkrar paylaşıla bilməz",
|
"status.cannot_reblog": "Bu göndəriş təkrar paylaşıla bilməz",
|
||||||
"status.context.load_new_replies": "Yeni cavablar mövcuddur",
|
|
||||||
"status.context.loading": "Daha çox cavab yoxlanılır",
|
|
||||||
"status.continued_thread": "Davam edən mövzu",
|
"status.continued_thread": "Davam edən mövzu",
|
||||||
"status.copy": "Göndəriş keçidini kopyala",
|
"status.copy": "Göndəriş keçidini kopyala",
|
||||||
"status.delete": "Sil",
|
"status.delete": "Sil",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}",
|
"account.disable_notifications": "Не паведамляць мне пра публікацыі @{name}",
|
||||||
"account.domain_blocking": "Блакіраванне дамена",
|
"account.domain_blocking": "Блакіраванне дамена",
|
||||||
"account.edit_profile": "Рэдагаваць профіль",
|
"account.edit_profile": "Рэдагаваць профіль",
|
||||||
|
"account.edit_profile_short": "Рэдагаваць",
|
||||||
"account.enable_notifications": "Апавяшчаць мяне пра допісы @{name}",
|
"account.enable_notifications": "Апавяшчаць мяне пра допісы @{name}",
|
||||||
"account.endorse": "Паказваць у профілі",
|
"account.endorse": "Паказваць у профілі",
|
||||||
"account.familiar_followers_many": "Мае сярод падпісчыкаў {name1}, {name2}, і {othersCount, plural, one {яшчэ # чалавека, знаёмага вам} few {яшчэ # чалавекі, знаёмыя вам} many {яшчэ # чалавек, знаёмых вам} other {яшчэ # чалавекі, знаёмыя вам}}",
|
"account.familiar_followers_many": "Мае сярод падпісчыкаў {name1}, {name2}, і {othersCount, plural, one {яшчэ # чалавека, знаёмага вам} few {яшчэ # чалавекі, знаёмыя вам} many {яшчэ # чалавек, знаёмых вам} other {яшчэ # чалавекі, знаёмыя вам}}",
|
||||||
|
|
@ -40,6 +41,11 @@
|
||||||
"account.featured_tags.last_status_never": "Няма допісаў",
|
"account.featured_tags.last_status_never": "Няма допісаў",
|
||||||
"account.follow": "Падпісацца",
|
"account.follow": "Падпісацца",
|
||||||
"account.follow_back": "Падпісацца ў адказ",
|
"account.follow_back": "Падпісацца ў адказ",
|
||||||
|
"account.follow_back_short": "Падпісацца ў адказ",
|
||||||
|
"account.follow_request": "Даслаць запыт на падпіску",
|
||||||
|
"account.follow_request_cancel": "Скасаваць запыт",
|
||||||
|
"account.follow_request_cancel_short": "Скасаваць",
|
||||||
|
"account.follow_request_short": "Запыт",
|
||||||
"account.followers": "Падпісчыкі",
|
"account.followers": "Падпісчыкі",
|
||||||
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
|
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
|
"account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
|
||||||
|
|
@ -70,7 +76,6 @@
|
||||||
"account.posts_with_replies": "Допісы і адказы",
|
"account.posts_with_replies": "Допісы і адказы",
|
||||||
"account.remove_from_followers": "Выдаліць {name} з падпісчыкаў",
|
"account.remove_from_followers": "Выдаліць {name} з падпісчыкаў",
|
||||||
"account.report": "Паскардзіцца на @{name}",
|
"account.report": "Паскардзіцца на @{name}",
|
||||||
"account.requested": "Чакаецца ўхваленне. Націсніце, каб скасаваць запыт на падпіску",
|
|
||||||
"account.requested_follow": "{name} адправіў(-ла) запыт на падпіску",
|
"account.requested_follow": "{name} адправіў(-ла) запыт на падпіску",
|
||||||
"account.requests_to_follow_you": "Хоча падпісацца на вас",
|
"account.requests_to_follow_you": "Хоча падпісацца на вас",
|
||||||
"account.share": "Абагуліць профіль @{name}",
|
"account.share": "Абагуліць профіль @{name}",
|
||||||
|
|
@ -253,8 +258,6 @@
|
||||||
"confirmations.revoke_quote.message": "Гэтае дзеянне немагчыма адмяніць.",
|
"confirmations.revoke_quote.message": "Гэтае дзеянне немагчыма адмяніць.",
|
||||||
"confirmations.revoke_quote.title": "Выдаліць допіс?",
|
"confirmations.revoke_quote.title": "Выдаліць допіс?",
|
||||||
"confirmations.unfollow.confirm": "Адпісацца",
|
"confirmations.unfollow.confirm": "Адпісацца",
|
||||||
"confirmations.unfollow.message": "Вы ўпэўненыя, што хочаце адпісацца ад {name}?",
|
|
||||||
"confirmations.unfollow.title": "Адпісацца ад карыстальніка?",
|
|
||||||
"content_warning.hide": "Схаваць допіс",
|
"content_warning.hide": "Схаваць допіс",
|
||||||
"content_warning.show": "Усё адно паказаць",
|
"content_warning.show": "Усё адно паказаць",
|
||||||
"content_warning.show_more": "Паказаць усё роўна",
|
"content_warning.show_more": "Паказаць усё роўна",
|
||||||
|
|
@ -864,8 +867,14 @@
|
||||||
"status.cancel_reblog_private": "Прыбраць",
|
"status.cancel_reblog_private": "Прыбраць",
|
||||||
"status.cannot_quote": "Вы не маеце дазвол цытаваць гэты допіс",
|
"status.cannot_quote": "Вы не маеце дазвол цытаваць гэты допіс",
|
||||||
"status.cannot_reblog": "Гэты допіс нельга пашырыць",
|
"status.cannot_reblog": "Гэты допіс нельга пашырыць",
|
||||||
"status.context.load_new_replies": "Даступныя новыя адказы",
|
"status.contains_quote": "Утрымлівае цытату",
|
||||||
"status.context.loading": "Правяраюцца новыя адказы",
|
"status.context.loading": "Загружаюцца іншыя адказы",
|
||||||
|
"status.context.loading_error": "Немагчыма загрузіць новыя адказы",
|
||||||
|
"status.context.loading_more": "Загружаюцца іншыя адказы",
|
||||||
|
"status.context.loading_success": "Усе адказы загружаныя",
|
||||||
|
"status.context.more_replies_found": "Знойдзеныя іншыя адказы",
|
||||||
|
"status.context.retry": "Паспрабаваць зноў",
|
||||||
|
"status.context.show": "Паказаць",
|
||||||
"status.continued_thread": "Працяг ланцужка",
|
"status.continued_thread": "Працяг ланцужка",
|
||||||
"status.copy": "Скапіраваць спасылку на допіс",
|
"status.copy": "Скапіраваць спасылку на допіс",
|
||||||
"status.delete": "Выдаліць",
|
"status.delete": "Выдаліць",
|
||||||
|
|
@ -903,6 +912,7 @@
|
||||||
"status.quote_error.revoked": "Аўтар выдаліў допіс",
|
"status.quote_error.revoked": "Аўтар выдаліў допіс",
|
||||||
"status.quote_followers_only": "Толькі падпісчыкі могуць цытаваць гэты допіс",
|
"status.quote_followers_only": "Толькі падпісчыкі могуць цытаваць гэты допіс",
|
||||||
"status.quote_manual_review": "Аўтар зробіць агляд уручную",
|
"status.quote_manual_review": "Аўтар зробіць агляд уручную",
|
||||||
|
"status.quote_noun": "Цытаваць",
|
||||||
"status.quote_policy_change": "Змяніць, хто можа цытаваць",
|
"status.quote_policy_change": "Змяніць, хто можа цытаваць",
|
||||||
"status.quote_post_author": "Цытаваў допіс @{name}",
|
"status.quote_post_author": "Цытаваў допіс @{name}",
|
||||||
"status.quote_private": "Прыватныя допісы нельга цытаваць",
|
"status.quote_private": "Прыватныя допісы нельга цытаваць",
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@
|
||||||
"account.posts_with_replies": "Публ. и отговори",
|
"account.posts_with_replies": "Публ. и отговори",
|
||||||
"account.remove_from_followers": "Премахване на {name} от последователи",
|
"account.remove_from_followers": "Премахване на {name} от последователи",
|
||||||
"account.report": "Докладване на @{name}",
|
"account.report": "Докладване на @{name}",
|
||||||
"account.requested": "Чака се одобрение. Щракнете за отмяна на заявката за последване",
|
|
||||||
"account.requested_follow": "{name} поиска да ви последва",
|
"account.requested_follow": "{name} поиска да ви последва",
|
||||||
"account.requests_to_follow_you": "Заявки да ви последват",
|
"account.requests_to_follow_you": "Заявки да ви последват",
|
||||||
"account.share": "Споделяне на профила на @{name}",
|
"account.share": "Споделяне на профила на @{name}",
|
||||||
|
|
@ -249,8 +248,6 @@
|
||||||
"confirmations.revoke_quote.message": "Действието е неотменимо.",
|
"confirmations.revoke_quote.message": "Действието е неотменимо.",
|
||||||
"confirmations.revoke_quote.title": "Премахвате ли публикацията?",
|
"confirmations.revoke_quote.title": "Премахвате ли публикацията?",
|
||||||
"confirmations.unfollow.confirm": "Без следване",
|
"confirmations.unfollow.confirm": "Без следване",
|
||||||
"confirmations.unfollow.message": "Наистина ли искате вече да не следвате {name}?",
|
|
||||||
"confirmations.unfollow.title": "Спирате ли да следвате потребителя?",
|
|
||||||
"content_warning.hide": "Скриване на публ.",
|
"content_warning.hide": "Скриване на публ.",
|
||||||
"content_warning.show": "Нека се покаже",
|
"content_warning.show": "Нека се покаже",
|
||||||
"content_warning.show_more": "Показване на още",
|
"content_warning.show_more": "Показване на още",
|
||||||
|
|
@ -832,8 +829,6 @@
|
||||||
"status.bookmark": "Отмятане",
|
"status.bookmark": "Отмятане",
|
||||||
"status.cancel_reblog_private": "Край на подсилването",
|
"status.cancel_reblog_private": "Край на подсилването",
|
||||||
"status.cannot_reblog": "Публикацията не може да се подсилва",
|
"status.cannot_reblog": "Публикацията не може да се подсилва",
|
||||||
"status.context.load_new_replies": "Има нови отговори",
|
|
||||||
"status.context.loading": "Проверка за още отговори",
|
|
||||||
"status.continued_thread": "Продължена нишка",
|
"status.continued_thread": "Продължена нишка",
|
||||||
"status.copy": "Копиране на връзката към публикация",
|
"status.copy": "Копиране на връзката към публикация",
|
||||||
"status.delete": "Изтриване",
|
"status.delete": "Изтриване",
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@
|
||||||
"account.posts": "পোষ্টসমূহ",
|
"account.posts": "পোষ্টসমূহ",
|
||||||
"account.posts_with_replies": "টুট এবং মতামত",
|
"account.posts_with_replies": "টুট এবং মতামত",
|
||||||
"account.report": "@{name} কে রিপোর্ট করুন",
|
"account.report": "@{name} কে রিপোর্ট করুন",
|
||||||
"account.requested": "অনুমতির অপেক্ষা। অনুসরণ করার অনুরোধ বাতিল করতে এখানে ক্লিক করুন",
|
|
||||||
"account.requested_follow": "{name} আপনাকে অনুসরণ করার জন্য অনুরোধ করেছে",
|
"account.requested_follow": "{name} আপনাকে অনুসরণ করার জন্য অনুরোধ করেছে",
|
||||||
"account.share": "@{name} র প্রোফাইল অন্যদের দেখান",
|
"account.share": "@{name} র প্রোফাইল অন্যদের দেখান",
|
||||||
"account.show_reblogs": "@{name} র সমর্থনগুলো দেখান",
|
"account.show_reblogs": "@{name} র সমর্থনগুলো দেখান",
|
||||||
|
|
@ -153,7 +152,6 @@
|
||||||
"confirmations.mute.confirm": "সরিয়ে ফেলুন",
|
"confirmations.mute.confirm": "সরিয়ে ফেলুন",
|
||||||
"confirmations.redraft.confirm": "মুছে ফেলুন এবং আবার সম্পাদন করুন",
|
"confirmations.redraft.confirm": "মুছে ফেলুন এবং আবার সম্পাদন করুন",
|
||||||
"confirmations.unfollow.confirm": "অনুসরণ বন্ধ করো",
|
"confirmations.unfollow.confirm": "অনুসরণ বন্ধ করো",
|
||||||
"confirmations.unfollow.message": "তুমি কি নিশ্চিত {name} কে আর অনুসরণ করতে চাও না?",
|
|
||||||
"conversation.delete": "কথোপকথন মুছে ফেলুন",
|
"conversation.delete": "কথোপকথন মুছে ফেলুন",
|
||||||
"conversation.mark_as_read": "পঠিত হিসেবে চিহ্নিত করুন",
|
"conversation.mark_as_read": "পঠিত হিসেবে চিহ্নিত করুন",
|
||||||
"conversation.open": "কথপোকথন দেখান",
|
"conversation.open": "কথপোকথন দেখান",
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,6 @@
|
||||||
"account.posts_with_replies": "Embannadurioù ha respontoù",
|
"account.posts_with_replies": "Embannadurioù ha respontoù",
|
||||||
"account.remove_from_followers": "Dilemel {name} eus an heulierien·ezed",
|
"account.remove_from_followers": "Dilemel {name} eus an heulierien·ezed",
|
||||||
"account.report": "Disklêriañ @{name}",
|
"account.report": "Disklêriañ @{name}",
|
||||||
"account.requested": "O c'hortoz an asant. Klikit evit nullañ ar goulenn heuliañ",
|
|
||||||
"account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ",
|
"account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ",
|
||||||
"account.requests_to_follow_you": "Rekedoù d'ho heuliañ",
|
"account.requests_to_follow_you": "Rekedoù d'ho heuliañ",
|
||||||
"account.share": "Skignañ profil @{name}",
|
"account.share": "Skignañ profil @{name}",
|
||||||
|
|
@ -218,8 +217,6 @@
|
||||||
"confirmations.revoke_quote.confirm": "Dilemel an embannadur",
|
"confirmations.revoke_quote.confirm": "Dilemel an embannadur",
|
||||||
"confirmations.revoke_quote.title": "Dilemel an embannadur?",
|
"confirmations.revoke_quote.title": "Dilemel an embannadur?",
|
||||||
"confirmations.unfollow.confirm": "Diheuliañ",
|
"confirmations.unfollow.confirm": "Diheuliañ",
|
||||||
"confirmations.unfollow.message": "Ha sur oc'h e fell deoc'h paouez da heuliañ {name} ?",
|
|
||||||
"confirmations.unfollow.title": "Paouez da heuliañ an implijer·ez?",
|
|
||||||
"content_warning.hide": "Kuzhat an embannadur",
|
"content_warning.hide": "Kuzhat an embannadur",
|
||||||
"content_warning.show": "Diskwel memes tra",
|
"content_warning.show": "Diskwel memes tra",
|
||||||
"content_warning.show_more": "Diskouez muioc'h",
|
"content_warning.show_more": "Diskouez muioc'h",
|
||||||
|
|
@ -653,8 +650,6 @@
|
||||||
"status.bookmark": "Ouzhpennañ d'ar sinedoù",
|
"status.bookmark": "Ouzhpennañ d'ar sinedoù",
|
||||||
"status.cancel_reblog_private": "Nac'hañ ar skignadenn",
|
"status.cancel_reblog_private": "Nac'hañ ar skignadenn",
|
||||||
"status.cannot_reblog": "Ar c'hannad-se na c'hall ket bezañ skignet",
|
"status.cannot_reblog": "Ar c'hannad-se na c'hall ket bezañ skignet",
|
||||||
"status.context.load_new_replies": "Respontoù nevez zo",
|
|
||||||
"status.context.loading": "O kerc'hat muioc'h a respontoù",
|
|
||||||
"status.copy": "Eilañ liamm ar c'hannad",
|
"status.copy": "Eilañ liamm ar c'hannad",
|
||||||
"status.delete": "Dilemel",
|
"status.delete": "Dilemel",
|
||||||
"status.delete.success": "Embannadur dilamet",
|
"status.delete.success": "Embannadur dilamet",
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@
|
||||||
"account.posts_with_replies": "Tuts i respostes",
|
"account.posts_with_replies": "Tuts i respostes",
|
||||||
"account.remove_from_followers": "Elimina {name} dels seguidors",
|
"account.remove_from_followers": "Elimina {name} dels seguidors",
|
||||||
"account.report": "Informa sobre @{name}",
|
"account.report": "Informa sobre @{name}",
|
||||||
"account.requested": "S'espera l'aprovació. Clica per a cancel·lar la petició de seguiment",
|
|
||||||
"account.requested_follow": "{name} ha demanat de seguir-te",
|
"account.requested_follow": "{name} ha demanat de seguir-te",
|
||||||
"account.requests_to_follow_you": "Peticions de seguir-vos",
|
"account.requests_to_follow_you": "Peticions de seguir-vos",
|
||||||
"account.share": "Comparteix el perfil de @{name}",
|
"account.share": "Comparteix el perfil de @{name}",
|
||||||
|
|
@ -249,8 +248,6 @@
|
||||||
"confirmations.revoke_quote.message": "Aquesta acció no es pot desfer.",
|
"confirmations.revoke_quote.message": "Aquesta acció no es pot desfer.",
|
||||||
"confirmations.revoke_quote.title": "Eliminar la publicació?",
|
"confirmations.revoke_quote.title": "Eliminar la publicació?",
|
||||||
"confirmations.unfollow.confirm": "Deixa de seguir",
|
"confirmations.unfollow.confirm": "Deixa de seguir",
|
||||||
"confirmations.unfollow.message": "Segur que vols deixar de seguir {name}?",
|
|
||||||
"confirmations.unfollow.title": "Deixar de seguir l'usuari?",
|
|
||||||
"content_warning.hide": "Amaga la publicació",
|
"content_warning.hide": "Amaga la publicació",
|
||||||
"content_warning.show": "Mostra-la igualment",
|
"content_warning.show": "Mostra-la igualment",
|
||||||
"content_warning.show_more": "Mostra'n més",
|
"content_warning.show_more": "Mostra'n més",
|
||||||
|
|
@ -844,8 +841,6 @@
|
||||||
"status.bookmark": "Marca",
|
"status.bookmark": "Marca",
|
||||||
"status.cancel_reblog_private": "Desfés l'impuls",
|
"status.cancel_reblog_private": "Desfés l'impuls",
|
||||||
"status.cannot_reblog": "No es pot impulsar aquest tut",
|
"status.cannot_reblog": "No es pot impulsar aquest tut",
|
||||||
"status.context.load_new_replies": "Hi ha respostes noves",
|
|
||||||
"status.context.loading": "Comprovació de més respostes",
|
|
||||||
"status.continued_thread": "Continuació del fil",
|
"status.continued_thread": "Continuació del fil",
|
||||||
"status.copy": "Copia l'enllaç al tut",
|
"status.copy": "Copia l'enllaç al tut",
|
||||||
"status.delete": "Elimina",
|
"status.delete": "Elimina",
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,6 @@
|
||||||
"account.posts": "نووسراوەکان",
|
"account.posts": "نووسراوەکان",
|
||||||
"account.posts_with_replies": "توتس و وەڵامەکان",
|
"account.posts_with_replies": "توتس و وەڵامەکان",
|
||||||
"account.report": "گوزارشت @{name}",
|
"account.report": "گوزارشت @{name}",
|
||||||
"account.requested": "چاوەڕێی ڕەزامەندین. کرتە بکە بۆ هەڵوەشاندنەوەی داواکاری شوێنکەوتن",
|
|
||||||
"account.requested_follow": "{name} داوای کردووە شوێنت بکەوێت",
|
"account.requested_follow": "{name} داوای کردووە شوێنت بکەوێت",
|
||||||
"account.share": "پرۆفایلی @{name} هاوبەش بکە",
|
"account.share": "پرۆفایلی @{name} هاوبەش بکە",
|
||||||
"account.show_reblogs": "پیشاندانی بەرزکردنەوەکان لە @{name}",
|
"account.show_reblogs": "پیشاندانی بەرزکردنەوەکان لە @{name}",
|
||||||
|
|
@ -161,7 +160,6 @@
|
||||||
"confirmations.redraft.confirm": "سڕینەوە & دووبارە ڕەشکردنەوە",
|
"confirmations.redraft.confirm": "سڕینەوە & دووبارە ڕەشکردنەوە",
|
||||||
"confirmations.redraft.message": "دڵنیای دەتەوێت ئەم پۆستە بسڕیتەوە و دووبارە دایبڕێژیتەوە؟ فەڤۆریت و بووستەکان لەدەست دەچن، وەڵامەکانی پۆستە ئەسڵیەکەش هەتیو دەبن.",
|
"confirmations.redraft.message": "دڵنیای دەتەوێت ئەم پۆستە بسڕیتەوە و دووبارە دایبڕێژیتەوە؟ فەڤۆریت و بووستەکان لەدەست دەچن، وەڵامەکانی پۆستە ئەسڵیەکەش هەتیو دەبن.",
|
||||||
"confirmations.unfollow.confirm": "بەدوادانەچو",
|
"confirmations.unfollow.confirm": "بەدوادانەچو",
|
||||||
"confirmations.unfollow.message": "ئایا دڵنیایت لەوەی دەتەوێت پەیڕەوی {name}?",
|
|
||||||
"conversation.delete": "سڕینەوەی گفتوگۆ",
|
"conversation.delete": "سڕینەوەی گفتوگۆ",
|
||||||
"conversation.mark_as_read": "نیشانەکردن وەک خوێندراوە",
|
"conversation.mark_as_read": "نیشانەکردن وەک خوێندراوە",
|
||||||
"conversation.open": "نیشاندان گفتوگۆ",
|
"conversation.open": "نیشاندان گفتوگۆ",
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@
|
||||||
"account.posts": "Statuti",
|
"account.posts": "Statuti",
|
||||||
"account.posts_with_replies": "Statuti è risposte",
|
"account.posts_with_replies": "Statuti è risposte",
|
||||||
"account.report": "Palisà @{name}",
|
"account.report": "Palisà @{name}",
|
||||||
"account.requested": "In attesa d'apprubazione. Cliccate per annullà a dumanda",
|
|
||||||
"account.share": "Sparte u prufile di @{name}",
|
"account.share": "Sparte u prufile di @{name}",
|
||||||
"account.show_reblogs": "Vede spartere da @{name}",
|
"account.show_reblogs": "Vede spartere da @{name}",
|
||||||
"account.unblock": "Sbluccà @{name}",
|
"account.unblock": "Sbluccà @{name}",
|
||||||
|
|
@ -88,7 +87,6 @@
|
||||||
"confirmations.mute.confirm": "Piattà",
|
"confirmations.mute.confirm": "Piattà",
|
||||||
"confirmations.redraft.confirm": "Sguassà è riscrive",
|
"confirmations.redraft.confirm": "Sguassà è riscrive",
|
||||||
"confirmations.unfollow.confirm": "Disabbunassi",
|
"confirmations.unfollow.confirm": "Disabbunassi",
|
||||||
"confirmations.unfollow.message": "Site sicuru·a ch'ùn vulete più siguità @{name}?",
|
|
||||||
"conversation.delete": "Sguassà a cunversazione",
|
"conversation.delete": "Sguassà a cunversazione",
|
||||||
"conversation.mark_as_read": "Marcà cum'è lettu",
|
"conversation.mark_as_read": "Marcà cum'è lettu",
|
||||||
"conversation.open": "Vede a cunversazione",
|
"conversation.open": "Vede a cunversazione",
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@
|
||||||
"account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek",
|
"account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek",
|
||||||
"account.domain_blocking": "Blokované domény",
|
"account.domain_blocking": "Blokované domény",
|
||||||
"account.edit_profile": "Upravit profil",
|
"account.edit_profile": "Upravit profil",
|
||||||
|
"account.edit_profile_short": "Upravit",
|
||||||
"account.enable_notifications": "Oznamovat mi příspěvky @{name}",
|
"account.enable_notifications": "Oznamovat mi příspěvky @{name}",
|
||||||
"account.endorse": "Zvýraznit na profilu",
|
"account.endorse": "Zvýraznit na profilu",
|
||||||
"account.familiar_followers_many": "Sleduje je {name1}, {name2} a {othersCount, plural, one {jeden další, které znáte} few {# další, které znáte} many {# dalších, které znáte} other {# dalších, které znáte}}",
|
"account.familiar_followers_many": "Sleduje je {name1}, {name2} a {othersCount, plural, one {jeden další, které znáte} few {# další, které znáte} many {# dalších, které znáte} other {# dalších, které znáte}}",
|
||||||
|
|
@ -40,6 +41,11 @@
|
||||||
"account.featured_tags.last_status_never": "Žádné příspěvky",
|
"account.featured_tags.last_status_never": "Žádné příspěvky",
|
||||||
"account.follow": "Sledovat",
|
"account.follow": "Sledovat",
|
||||||
"account.follow_back": "Také sledovat",
|
"account.follow_back": "Také sledovat",
|
||||||
|
"account.follow_back_short": "Také sledovat",
|
||||||
|
"account.follow_request": "Požádat o sledování",
|
||||||
|
"account.follow_request_cancel": "Zrušit požadavek",
|
||||||
|
"account.follow_request_cancel_short": "Zrušit",
|
||||||
|
"account.follow_request_short": "Požádat",
|
||||||
"account.followers": "Sledující",
|
"account.followers": "Sledující",
|
||||||
"account.followers.empty": "Tohoto uživatele zatím nikdo nesleduje.",
|
"account.followers.empty": "Tohoto uživatele zatím nikdo nesleduje.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} sledující} few {{counter} sledující} many {{counter} sledujících} other {{counter} sledujících}}",
|
"account.followers_counter": "{count, plural, one {{counter} sledující} few {{counter} sledující} many {{counter} sledujících} other {{counter} sledujících}}",
|
||||||
|
|
@ -70,7 +76,6 @@
|
||||||
"account.posts_with_replies": "Příspěvky a odpovědi",
|
"account.posts_with_replies": "Příspěvky a odpovědi",
|
||||||
"account.remove_from_followers": "Odebrat {name} ze sledujících",
|
"account.remove_from_followers": "Odebrat {name} ze sledujících",
|
||||||
"account.report": "Nahlásit @{name}",
|
"account.report": "Nahlásit @{name}",
|
||||||
"account.requested": "Čeká na schválení. Kliknutím žádost o sledování zrušíte",
|
|
||||||
"account.requested_follow": "{name} tě požádal o sledování",
|
"account.requested_follow": "{name} tě požádal o sledování",
|
||||||
"account.requests_to_follow_you": "Žádosti o sledování",
|
"account.requests_to_follow_you": "Žádosti o sledování",
|
||||||
"account.share": "Sdílet profil @{name}",
|
"account.share": "Sdílet profil @{name}",
|
||||||
|
|
@ -253,8 +258,6 @@
|
||||||
"confirmations.revoke_quote.message": "Tuto akci nelze vrátit zpět.",
|
"confirmations.revoke_quote.message": "Tuto akci nelze vrátit zpět.",
|
||||||
"confirmations.revoke_quote.title": "Odstranit příspěvek?",
|
"confirmations.revoke_quote.title": "Odstranit příspěvek?",
|
||||||
"confirmations.unfollow.confirm": "Přestat sledovat",
|
"confirmations.unfollow.confirm": "Přestat sledovat",
|
||||||
"confirmations.unfollow.message": "Opravdu chcete {name} přestat sledovat?",
|
|
||||||
"confirmations.unfollow.title": "Přestat sledovat uživatele?",
|
|
||||||
"content_warning.hide": "Skrýt příspěvek",
|
"content_warning.hide": "Skrýt příspěvek",
|
||||||
"content_warning.show": "Přesto zobrazit",
|
"content_warning.show": "Přesto zobrazit",
|
||||||
"content_warning.show_more": "Zobrazit více",
|
"content_warning.show_more": "Zobrazit více",
|
||||||
|
|
@ -865,8 +868,13 @@
|
||||||
"status.cannot_quote": "Nemáte oprávnění citovat tento příspěvek",
|
"status.cannot_quote": "Nemáte oprávnění citovat tento příspěvek",
|
||||||
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
|
"status.cannot_reblog": "Tento příspěvek nemůže být boostnutý",
|
||||||
"status.contains_quote": "Obsahuje citaci",
|
"status.contains_quote": "Obsahuje citaci",
|
||||||
"status.context.load_new_replies": "K dispozici jsou nové odpovědi",
|
"status.context.loading": "Načítání dalších odpovědí",
|
||||||
"status.context.loading": "Hledání dalších odpovědí",
|
"status.context.loading_error": "Nelze načíst nové odpovědi",
|
||||||
|
"status.context.loading_more": "Načítání dalších odpovědí",
|
||||||
|
"status.context.loading_success": "Všechny odpovědi načteny",
|
||||||
|
"status.context.more_replies_found": "Nalezeny další odpovědi",
|
||||||
|
"status.context.retry": "Zkusit znovu",
|
||||||
|
"status.context.show": "Zobrazit",
|
||||||
"status.continued_thread": "Pokračuje ve vlákně",
|
"status.continued_thread": "Pokračuje ve vlákně",
|
||||||
"status.copy": "Zkopírovat odkaz na příspěvek",
|
"status.copy": "Zkopírovat odkaz na příspěvek",
|
||||||
"status.delete": "Smazat",
|
"status.delete": "Smazat",
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user