Merge pull request #3220 from ClearlyClaire/glitch-soc/merge-upstream
Merge upstream changes up to b8444d9bb7885d75112a3cae74b6a5c711c7d547
This commit is contained in:
commit
44b45ab0b7
@ -321,9 +321,6 @@ SESSION_RETENTION_PERIOD=31556952
|
|||||||
|
|
||||||
# Fetch All Replies Behavior
|
# Fetch All Replies Behavior
|
||||||
# --------------------------
|
# --------------------------
|
||||||
# When a user expands a post (DetailedStatus view), fetch all of its replies
|
|
||||||
# (default: false)
|
|
||||||
FETCH_REPLIES_ENABLED=false
|
|
||||||
|
|
||||||
# Period to wait between fetching replies (in minutes)
|
# Period to wait between fetching replies (in minutes)
|
||||||
FETCH_REPLIES_COOLDOWN_MINUTES=15
|
FETCH_REPLIES_COOLDOWN_MINUTES=15
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
3.4.6
|
3.4.7
|
||||||
|
|||||||
26
Gemfile
26
Gemfile
@ -106,19 +106,19 @@ gem 'opentelemetry-api', '~> 1.7.0'
|
|||||||
|
|
||||||
group :opentelemetry do
|
group :opentelemetry do
|
||||||
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
|
gem 'opentelemetry-exporter-otlp', '~> 0.30.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_job', '~> 0.8.0', require: false
|
gem 'opentelemetry-instrumentation-active_job', '~> 0.9.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.22.0', require: false
|
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.23.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.22.0', require: false
|
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.23.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-excon', '~> 0.24.0', require: false
|
gem 'opentelemetry-instrumentation-excon', '~> 0.25.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-faraday', '~> 0.28.0', require: false
|
gem 'opentelemetry-instrumentation-faraday', '~> 0.29.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http', '~> 0.25.0', require: false
|
gem 'opentelemetry-instrumentation-http', '~> 0.26.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-http_client', '~> 0.24.0', require: false
|
gem 'opentelemetry-instrumentation-http_client', '~> 0.25.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-net_http', '~> 0.24.0', require: false
|
gem 'opentelemetry-instrumentation-net_http', '~> 0.25.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-pg', '~> 0.30.0', require: false
|
gem 'opentelemetry-instrumentation-pg', '~> 0.31.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rack', '~> 0.27.0', require: false
|
gem 'opentelemetry-instrumentation-rack', '~> 0.28.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-rails', '~> 0.37.0', require: false
|
gem 'opentelemetry-instrumentation-rails', '~> 0.38.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-redis', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-redis', '~> 0.27.0', require: false
|
||||||
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.26.0', require: false
|
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.27.0', require: false
|
||||||
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
gem 'opentelemetry-sdk', '~> 1.4', require: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
197
Gemfile.lock
197
Gemfile.lock
@ -96,7 +96,7 @@ GEM
|
|||||||
ast (2.4.3)
|
ast (2.4.3)
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
aws-eventstream (1.4.0)
|
aws-eventstream (1.4.0)
|
||||||
aws-partitions (1.1135.0)
|
aws-partitions (1.1168.0)
|
||||||
aws-sdk-core (3.215.1)
|
aws-sdk-core (3.215.1)
|
||||||
aws-eventstream (~> 1, >= 1.3.0)
|
aws-eventstream (~> 1, >= 1.3.0)
|
||||||
aws-partitions (~> 1, >= 1.992.0)
|
aws-partitions (~> 1, >= 1.992.0)
|
||||||
@ -207,7 +207,7 @@ GEM
|
|||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
dotenv (3.1.8)
|
dotenv (3.1.8)
|
||||||
drb (2.2.3)
|
drb (2.2.3)
|
||||||
dry-cli (1.2.0)
|
dry-cli (1.3.0)
|
||||||
elasticsearch (7.17.11)
|
elasticsearch (7.17.11)
|
||||||
elasticsearch-api (= 7.17.11)
|
elasticsearch-api (= 7.17.11)
|
||||||
elasticsearch-transport (= 7.17.11)
|
elasticsearch-transport (= 7.17.11)
|
||||||
@ -226,18 +226,18 @@ GEM
|
|||||||
activemodel
|
activemodel
|
||||||
erb (5.0.2)
|
erb (5.0.2)
|
||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.2.11)
|
et-orbi (1.4.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
excon (1.2.8)
|
excon (1.3.0)
|
||||||
logger
|
logger
|
||||||
fabrication (3.0.0)
|
fabrication (3.0.0)
|
||||||
faker (3.5.2)
|
faker (3.5.2)
|
||||||
i18n (>= 1.8.11, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
faraday (2.13.4)
|
faraday (2.14.0)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.5)
|
||||||
json
|
json
|
||||||
logger
|
logger
|
||||||
faraday-follow_redirects (0.3.0)
|
faraday-follow_redirects (0.4.0)
|
||||||
faraday (>= 1, < 3)
|
faraday (>= 1, < 3)
|
||||||
faraday-httpclient (2.0.2)
|
faraday-httpclient (2.0.2)
|
||||||
httpclient (>= 2.2)
|
httpclient (>= 2.2)
|
||||||
@ -266,18 +266,19 @@ GEM
|
|||||||
fog-openstack (1.1.5)
|
fog-openstack (1.1.5)
|
||||||
fog-core (~> 2.1)
|
fog-core (~> 2.1)
|
||||||
fog-json (>= 1.0)
|
fog-json (>= 1.0)
|
||||||
formatador (1.1.1)
|
formatador (1.2.1)
|
||||||
|
reline
|
||||||
forwardable (1.3.3)
|
forwardable (1.3.3)
|
||||||
fugit (1.11.1)
|
fugit (1.12.0)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1.4)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
globalid (1.2.1)
|
globalid (1.3.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
google-protobuf (4.31.1)
|
google-protobuf (4.32.1)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
rake (>= 13)
|
rake (>= 13)
|
||||||
googleapis-common-protos-types (1.20.0)
|
googleapis-common-protos-types (1.21.0)
|
||||||
google-protobuf (>= 3.18, < 5.a)
|
google-protobuf (~> 4.26)
|
||||||
haml (6.3.0)
|
haml (6.3.0)
|
||||||
temple (>= 0.8.2)
|
temple (>= 0.8.2)
|
||||||
thor
|
thor
|
||||||
@ -293,7 +294,7 @@ GEM
|
|||||||
rainbow
|
rainbow
|
||||||
rubocop (>= 1.0)
|
rubocop (>= 1.0)
|
||||||
sysexits (~> 1.1)
|
sysexits (~> 1.1)
|
||||||
hashdiff (1.2.0)
|
hashdiff (1.2.1)
|
||||||
hashie (5.0.0)
|
hashie (5.0.0)
|
||||||
hcaptcha (7.1.0)
|
hcaptcha (7.1.0)
|
||||||
json
|
json
|
||||||
@ -309,7 +310,7 @@ GEM
|
|||||||
http-cookie (~> 1.0)
|
http-cookie (~> 1.0)
|
||||||
http-form_data (~> 2.2)
|
http-form_data (~> 2.2)
|
||||||
llhttp-ffi (~> 0.5.0)
|
llhttp-ffi (~> 0.5.0)
|
||||||
http-cookie (1.0.8)
|
http-cookie (1.1.0)
|
||||||
domain_name (~> 0.5)
|
domain_name (~> 0.5)
|
||||||
http-form_data (2.3.0)
|
http-form_data (2.3.0)
|
||||||
http_accept_language (2.1.1)
|
http_accept_language (2.1.1)
|
||||||
@ -345,9 +346,9 @@ 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.15.0)
|
json (2.15.1)
|
||||||
json-canonicalization (1.0.0)
|
json-canonicalization (1.0.0)
|
||||||
json-jwt (1.16.7)
|
json-jwt (1.17.0)
|
||||||
activesupport (>= 4.2)
|
activesupport (>= 4.2)
|
||||||
aes_key_wrap
|
aes_key_wrap
|
||||||
base64
|
base64
|
||||||
@ -438,7 +439,7 @@ GEM
|
|||||||
mime-types (3.7.0)
|
mime-types (3.7.0)
|
||||||
logger
|
logger
|
||||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||||
mime-types-data (3.2025.0916)
|
mime-types-data (3.2025.0924)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.9)
|
mini_portile2 (2.8.9)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
@ -447,7 +448,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.10)
|
net-imap (0.5.12)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ldap (0.20.0)
|
net-ldap (0.20.0)
|
||||||
@ -497,7 +498,7 @@ GEM
|
|||||||
tzinfo
|
tzinfo
|
||||||
validate_url
|
validate_url
|
||||||
webfinger (~> 2.0)
|
webfinger (~> 2.0)
|
||||||
openssl (3.3.0)
|
openssl (3.3.1)
|
||||||
openssl-signature_algorithm (1.3.0)
|
openssl-signature_algorithm (1.3.0)
|
||||||
openssl (> 2.0)
|
openssl (> 2.0)
|
||||||
opentelemetry-api (1.7.0)
|
opentelemetry-api (1.7.0)
|
||||||
@ -510,86 +511,61 @@ GEM
|
|||||||
opentelemetry-common (~> 0.20)
|
opentelemetry-common (~> 0.20)
|
||||||
opentelemetry-sdk (~> 1.2)
|
opentelemetry-sdk (~> 1.2)
|
||||||
opentelemetry-semantic_conventions
|
opentelemetry-semantic_conventions
|
||||||
opentelemetry-helpers-sql (0.1.1)
|
opentelemetry-helpers-sql (0.2.0)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-api (~> 1.7)
|
||||||
opentelemetry-helpers-sql-obfuscation (0.3.0)
|
opentelemetry-helpers-sql-obfuscation (0.3.0)
|
||||||
opentelemetry-common (~> 0.21)
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-instrumentation-action_mailer (0.4.0)
|
opentelemetry-instrumentation-action_mailer (0.5.0)
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-action_pack (0.14.1)
|
||||||
opentelemetry-instrumentation-action_pack (0.13.0)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
|
||||||
opentelemetry-instrumentation-rack (~> 0.21)
|
opentelemetry-instrumentation-rack (~> 0.21)
|
||||||
opentelemetry-instrumentation-action_view (0.9.0)
|
opentelemetry-instrumentation-action_view (0.10.0)
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-active_job (0.9.2)
|
||||||
opentelemetry-instrumentation-active_job (0.8.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-active_model_serializers (0.23.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
|
||||||
opentelemetry-instrumentation-active_model_serializers (0.22.0)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-active_support (>= 0.7.0)
|
opentelemetry-instrumentation-active_support (>= 0.7.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-active_record (0.10.1)
|
||||||
opentelemetry-instrumentation-active_record (0.9.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-active_storage (0.2.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
|
||||||
opentelemetry-instrumentation-active_storage (0.1.1)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-active_support (~> 0.7)
|
opentelemetry-instrumentation-active_support (~> 0.7)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-active_support (0.9.1)
|
||||||
opentelemetry-instrumentation-active_support (0.8.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-base (0.24.0)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-api (~> 1.7)
|
||||||
opentelemetry-instrumentation-base (0.23.0)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-common (~> 0.21)
|
opentelemetry-common (~> 0.21)
|
||||||
opentelemetry-registry (~> 0.1)
|
opentelemetry-registry (~> 0.1)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (0.22.0)
|
opentelemetry-instrumentation-concurrent_ruby (0.23.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-excon (0.25.1)
|
||||||
opentelemetry-instrumentation-excon (0.24.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-faraday (0.29.1)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-instrumentation-faraday (0.28.0)
|
opentelemetry-instrumentation-http (0.26.1)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-http_client (0.25.1)
|
||||||
opentelemetry-instrumentation-http (0.25.1)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-net_http (0.25.1)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-instrumentation-http_client (0.24.0)
|
opentelemetry-instrumentation-pg (0.31.1)
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
|
||||||
opentelemetry-instrumentation-net_http (0.24.0)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
|
||||||
opentelemetry-instrumentation-pg (0.30.1)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-helpers-sql
|
opentelemetry-helpers-sql
|
||||||
opentelemetry-helpers-sql-obfuscation
|
opentelemetry-helpers-sql-obfuscation
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-instrumentation-rack (0.27.1)
|
opentelemetry-instrumentation-rack (0.28.2)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-rails (0.38.0)
|
||||||
opentelemetry-instrumentation-rails (0.37.0)
|
opentelemetry-instrumentation-action_mailer (~> 0.4)
|
||||||
opentelemetry-api (~> 1.0)
|
opentelemetry-instrumentation-action_pack (~> 0.13)
|
||||||
opentelemetry-instrumentation-action_mailer (~> 0.4.0)
|
opentelemetry-instrumentation-action_view (~> 0.9)
|
||||||
opentelemetry-instrumentation-action_pack (~> 0.13.0)
|
opentelemetry-instrumentation-active_job (~> 0.8)
|
||||||
opentelemetry-instrumentation-action_view (~> 0.9.0)
|
opentelemetry-instrumentation-active_record (~> 0.9)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
opentelemetry-instrumentation-active_storage (~> 0.1)
|
||||||
opentelemetry-instrumentation-active_record (~> 0.9.0)
|
opentelemetry-instrumentation-active_support (~> 0.8)
|
||||||
opentelemetry-instrumentation-active_storage (~> 0.1.0)
|
opentelemetry-instrumentation-concurrent_ruby (~> 0.22)
|
||||||
opentelemetry-instrumentation-active_support (~> 0.8.0)
|
opentelemetry-instrumentation-redis (0.27.1)
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
opentelemetry-instrumentation-sidekiq (0.27.1)
|
||||||
opentelemetry-instrumentation-redis (0.26.1)
|
opentelemetry-instrumentation-base (~> 0.24)
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
|
||||||
opentelemetry-instrumentation-sidekiq (0.26.1)
|
|
||||||
opentelemetry-api (~> 1.0)
|
|
||||||
opentelemetry-instrumentation-base (~> 0.23.0)
|
|
||||||
opentelemetry-registry (0.4.0)
|
opentelemetry-registry (0.4.0)
|
||||||
opentelemetry-api (~> 1.1)
|
opentelemetry-api (~> 1.1)
|
||||||
opentelemetry-sdk (1.9.0)
|
opentelemetry-sdk (1.9.0)
|
||||||
@ -616,7 +592,7 @@ GEM
|
|||||||
playwright-ruby-client (1.55.0)
|
playwright-ruby-client (1.55.0)
|
||||||
concurrent-ruby (>= 1.1.6)
|
concurrent-ruby (>= 1.1.6)
|
||||||
mime-types (>= 3.0)
|
mime-types (>= 3.0)
|
||||||
pp (0.6.2)
|
pp (0.6.3)
|
||||||
prettyprint
|
prettyprint
|
||||||
premailer (1.27.0)
|
premailer (1.27.0)
|
||||||
addressable
|
addressable
|
||||||
@ -644,7 +620,7 @@ GEM
|
|||||||
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.2.1)
|
rack (3.2.2)
|
||||||
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)
|
||||||
@ -714,9 +690,10 @@ GEM
|
|||||||
readline (~> 0.0)
|
readline (~> 0.0)
|
||||||
rdf-normalize (0.7.0)
|
rdf-normalize (0.7.0)
|
||||||
rdf (~> 3.3)
|
rdf (~> 3.3)
|
||||||
rdoc (6.14.2)
|
rdoc (6.15.0)
|
||||||
erb
|
erb
|
||||||
psych (>= 4.0.0)
|
psych (>= 4.0.0)
|
||||||
|
tsort
|
||||||
readline (0.0.4)
|
readline (0.0.4)
|
||||||
reline
|
reline
|
||||||
redcarpet (3.6.1)
|
redcarpet (3.6.1)
|
||||||
@ -733,7 +710,7 @@ GEM
|
|||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
rotp (6.3.0)
|
rotp (6.3.0)
|
||||||
rouge (4.6.0)
|
rouge (4.6.1)
|
||||||
rpam2 (4.0.2)
|
rpam2 (4.0.2)
|
||||||
rqrcode (3.1.0)
|
rqrcode (3.1.0)
|
||||||
chunky_png (~> 1.0)
|
chunky_png (~> 1.0)
|
||||||
@ -766,7 +743,7 @@ GEM
|
|||||||
rspec-expectations (~> 3.0)
|
rspec-expectations (~> 3.0)
|
||||||
rspec-mocks (~> 3.0)
|
rspec-mocks (~> 3.0)
|
||||||
sidekiq (>= 5, < 9)
|
sidekiq (>= 5, < 9)
|
||||||
rspec-support (3.13.4)
|
rspec-support (3.13.6)
|
||||||
rubocop (1.81.1)
|
rubocop (1.81.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
@ -827,7 +804,7 @@ GEM
|
|||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
shoulda-matchers (6.5.0)
|
shoulda-matchers (6.5.0)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
sidekiq (8.0.7)
|
sidekiq (8.0.8)
|
||||||
connection_pool (>= 2.5.0)
|
connection_pool (>= 2.5.0)
|
||||||
json (>= 2.9.0)
|
json (>= 2.9.0)
|
||||||
logger (>= 1.6.2)
|
logger (>= 1.6.2)
|
||||||
@ -905,7 +882,7 @@ GEM
|
|||||||
unicode-display_width (3.2.0)
|
unicode-display_width (3.2.0)
|
||||||
unicode-emoji (~> 4.1)
|
unicode-emoji (~> 4.1)
|
||||||
unicode-emoji (4.1.0)
|
unicode-emoji (4.1.0)
|
||||||
uri (1.0.3)
|
uri (1.0.4)
|
||||||
useragent (0.16.11)
|
useragent (0.16.11)
|
||||||
validate_url (1.0.15)
|
validate_url (1.0.15)
|
||||||
activemodel (>= 3.0.0)
|
activemodel (>= 3.0.0)
|
||||||
@ -1032,19 +1009,19 @@ DEPENDENCIES
|
|||||||
omniauth_openid_connect (~> 0.8.0)
|
omniauth_openid_connect (~> 0.8.0)
|
||||||
opentelemetry-api (~> 1.7.0)
|
opentelemetry-api (~> 1.7.0)
|
||||||
opentelemetry-exporter-otlp (~> 0.30.0)
|
opentelemetry-exporter-otlp (~> 0.30.0)
|
||||||
opentelemetry-instrumentation-active_job (~> 0.8.0)
|
opentelemetry-instrumentation-active_job (~> 0.9.0)
|
||||||
opentelemetry-instrumentation-active_model_serializers (~> 0.22.0)
|
opentelemetry-instrumentation-active_model_serializers (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-concurrent_ruby (~> 0.22.0)
|
opentelemetry-instrumentation-concurrent_ruby (~> 0.23.0)
|
||||||
opentelemetry-instrumentation-excon (~> 0.24.0)
|
opentelemetry-instrumentation-excon (~> 0.25.0)
|
||||||
opentelemetry-instrumentation-faraday (~> 0.28.0)
|
opentelemetry-instrumentation-faraday (~> 0.29.0)
|
||||||
opentelemetry-instrumentation-http (~> 0.25.0)
|
opentelemetry-instrumentation-http (~> 0.26.0)
|
||||||
opentelemetry-instrumentation-http_client (~> 0.24.0)
|
opentelemetry-instrumentation-http_client (~> 0.25.0)
|
||||||
opentelemetry-instrumentation-net_http (~> 0.24.0)
|
opentelemetry-instrumentation-net_http (~> 0.25.0)
|
||||||
opentelemetry-instrumentation-pg (~> 0.30.0)
|
opentelemetry-instrumentation-pg (~> 0.31.0)
|
||||||
opentelemetry-instrumentation-rack (~> 0.27.0)
|
opentelemetry-instrumentation-rack (~> 0.28.0)
|
||||||
opentelemetry-instrumentation-rails (~> 0.37.0)
|
opentelemetry-instrumentation-rails (~> 0.38.0)
|
||||||
opentelemetry-instrumentation-redis (~> 0.26.0)
|
opentelemetry-instrumentation-redis (~> 0.27.0)
|
||||||
opentelemetry-instrumentation-sidekiq (~> 0.26.0)
|
opentelemetry-instrumentation-sidekiq (~> 0.27.0)
|
||||||
opentelemetry-sdk (~> 1.4)
|
opentelemetry-sdk (~> 1.4)
|
||||||
ox (~> 2.14)
|
ox (~> 2.14)
|
||||||
parslet
|
parslet
|
||||||
@ -1110,4 +1087,4 @@ RUBY VERSION
|
|||||||
ruby 3.4.1p0
|
ruby 3.4.1p0
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.1
|
2.7.2
|
||||||
|
|||||||
@ -21,7 +21,13 @@ module HomeHelper
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
link_to(path || ActivityPub::TagManager.instance.url_for(account), class: 'account__display-name') do
|
account_url = if account.suspended?
|
||||||
|
ActivityPub::TagManager.instance.url_for(account)
|
||||||
|
else
|
||||||
|
web_url("@#{account.pretty_acct}")
|
||||||
|
end
|
||||||
|
|
||||||
|
link_to(path || account_url, class: 'account__display-name') do
|
||||||
content_tag(:div, class: 'account__avatar-wrapper') do
|
content_tag(:div, class: 'account__avatar-wrapper') do
|
||||||
image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46)
|
image_tag(full_asset_url(current_account&.user&.setting_auto_play_gif ? account.avatar_original_url : account.avatar_static_url), class: 'account__avatar', width: 46, height: 46)
|
||||||
end +
|
end +
|
||||||
|
|||||||
@ -70,7 +70,7 @@ function loaded() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
document.querySelectorAll('.emojify').forEach((content) => {
|
document.querySelectorAll('.emojify').forEach((content) => {
|
||||||
content.innerHTML = emojify(content.innerHTML);
|
content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system.
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { createAction } from '@reduxjs/toolkit';
|
|||||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { apiUpdateMedia } from 'flavours/glitch/api/compose';
|
import { apiUpdateMedia } from 'flavours/glitch/api/compose';
|
||||||
|
import { apiGetSearch } from 'flavours/glitch/api/search';
|
||||||
import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments';
|
import type { ApiMediaAttachmentJSON } from 'flavours/glitch/api_types/media_attachments';
|
||||||
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
import type { MediaAttachment } from 'flavours/glitch/models/media_attachment';
|
||||||
import {
|
import {
|
||||||
@ -16,6 +17,7 @@ import type { Status } from '../models/status';
|
|||||||
|
|
||||||
import { showAlert } from './alerts';
|
import { showAlert } from './alerts';
|
||||||
import { focusCompose } from './compose';
|
import { focusCompose } from './compose';
|
||||||
|
import { importFetchedStatuses } from './importer';
|
||||||
import { openModal } from './modal';
|
import { openModal } from './modal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -165,6 +167,41 @@ export const quoteComposeById = createAppThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const pasteLinkCompose = createDataLoadingThunk(
|
||||||
|
'compose/pasteLink',
|
||||||
|
async ({ url }: { url: string }) => {
|
||||||
|
return await apiGetSearch({
|
||||||
|
q: url,
|
||||||
|
type: 'statuses',
|
||||||
|
resolve: true,
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(data, { dispatch, getState }) => {
|
||||||
|
const composeState = getState().compose;
|
||||||
|
|
||||||
|
if (
|
||||||
|
composeState.get('quoted_status_id') ||
|
||||||
|
composeState.get('is_submitting') ||
|
||||||
|
composeState.get('poll') ||
|
||||||
|
composeState.get('is_uploading')
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.statuses.length === 1 &&
|
||||||
|
data.statuses[0] &&
|
||||||
|
['automatic', 'manual'].includes(
|
||||||
|
data.statuses[0].quote_approval?.current_user ?? 'denied',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
dispatch(quoteComposeById(data.statuses[0].id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||||
|
|
||||||
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
||||||
|
|||||||
@ -9,8 +9,9 @@ import { importFetchedStatuses } from './importer';
|
|||||||
|
|
||||||
export const fetchContext = createDataLoadingThunk(
|
export const fetchContext = createDataLoadingThunk(
|
||||||
'status/context',
|
'status/context',
|
||||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
({ statusId }: { statusId: string; prefetchOnly?: boolean }) =>
|
||||||
({ context, refresh }, { dispatch }) => {
|
apiGetContext(statusId),
|
||||||
|
({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => {
|
||||||
const statuses = context.ancestors.concat(context.descendants);
|
const statuses = context.ancestors.concat(context.descendants);
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
@ -18,6 +19,7 @@ export const fetchContext = createDataLoadingThunk(
|
|||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
refresh,
|
refresh,
|
||||||
|
prefetchOnly,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -26,6 +28,14 @@ export const completeContextRefresh = createAction<{ statusId: string }>(
|
|||||||
'status/context/complete',
|
'status/context/complete',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const showPendingReplies = createAction<{ statusId: string }>(
|
||||||
|
'status/context/showPendingReplies',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const clearPendingReplies = createAction<{ statusId: string }>(
|
||||||
|
'status/context/clearPendingReplies',
|
||||||
|
);
|
||||||
|
|
||||||
export const setStatusQuotePolicy = createDataLoadingThunk(
|
export const setStatusQuotePolicy = createDataLoadingThunk(
|
||||||
'status/setQuotePolicy',
|
'status/setQuotePolicy',
|
||||||
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
|
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
|
||||||
|
|||||||
28
app/javascript/flavours/glitch/api_types/announcements.ts
Normal file
28
app/javascript/flavours/glitch/api_types/announcements.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// See app/serializers/rest/announcement_serializer.rb
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||||
|
import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses';
|
||||||
|
|
||||||
|
export interface ApiAnnouncementJSON {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
starts_at: null | string;
|
||||||
|
ends_at: null | string;
|
||||||
|
all_day: boolean;
|
||||||
|
published_at: string;
|
||||||
|
updated_at: null | string;
|
||||||
|
read: boolean;
|
||||||
|
mentions: ApiMentionJSON[];
|
||||||
|
statuses: ApiStatusJSON[];
|
||||||
|
tags: ApiTagJSON[];
|
||||||
|
emojis: ApiCustomEmojiJSON[];
|
||||||
|
reactions: ApiAnnouncementReactionJSON[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiAnnouncementReactionJSON {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
me: boolean;
|
||||||
|
url?: string;
|
||||||
|
static_url?: string;
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import {
|
import {
|
||||||
blockAccount,
|
blockAccount,
|
||||||
@ -33,7 +34,7 @@ import { me } from 'flavours/glitch/initial_state';
|
|||||||
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
import type { MenuItem } from 'flavours/glitch/models/dropdown_menu';
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
import { Permalink } from './permalink';
|
import { Permalink } from '../permalink';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
follow: { id: 'account.follow', defaultMessage: 'Follow' },
|
||||||
@ -333,9 +334,10 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
{account &&
|
{account &&
|
||||||
withBio &&
|
withBio &&
|
||||||
(account.note.length > 0 ? (
|
(account.note.length > 0 ? (
|
||||||
<div
|
<EmojiHTML
|
||||||
className='account__note translate'
|
className='account__note translate'
|
||||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
htmlString={account.note_emojified}
|
||||||
|
extraEmojis={account.emojis}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='account__note account__note--missing'>
|
<div className='account__note account__note--missing'>
|
||||||
@ -7,8 +7,8 @@ import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
|||||||
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';
|
import { EmojiHTML } from './emoji/html';
|
||||||
|
import { useElementHandledLink } from './status/handled_link';
|
||||||
|
|
||||||
interface AccountBioProps {
|
interface AccountBioProps {
|
||||||
className: string;
|
className: string;
|
||||||
@ -24,19 +24,29 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||||||
const handleClick = useLinks(showDropdown);
|
const handleClick = useLinks(showDropdown);
|
||||||
const handleNodeChange = useCallback(
|
const handleNodeChange = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
if (!showDropdown || !node || node.childNodes.length === 0) {
|
if (
|
||||||
|
!showDropdown ||
|
||||||
|
!node ||
|
||||||
|
node.childNodes.length === 0 ||
|
||||||
|
isModernEmojiEnabled()
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addDropdownToHashtags(node, accountId);
|
addDropdownToHashtags(node, accountId);
|
||||||
},
|
},
|
||||||
[showDropdown, accountId],
|
[showDropdown, accountId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const htmlHandlers = useElementHandledLink({
|
||||||
|
hashtagAccountId: showDropdown ? accountId : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const note = useAppSelector((state) => {
|
const note = useAppSelector((state) => {
|
||||||
const account = state.accounts.get(accountId);
|
const account = state.accounts.get(accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return isModernEmojiEnabled() ? account.note : account.note_emojified;
|
return account.note_emojified;
|
||||||
});
|
});
|
||||||
const extraEmojis = useAppSelector((state) => {
|
const extraEmojis = useAppSelector((state) => {
|
||||||
const account = state.accounts.get(accountId);
|
const account = state.accounts.get(accountId);
|
||||||
@ -48,13 +58,14 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimateEmojiProvider
|
<EmojiHTML
|
||||||
|
htmlString={note}
|
||||||
|
extraEmojis={extraEmojis}
|
||||||
className={classNames(className, 'translate')}
|
className={classNames(className, 'translate')}
|
||||||
onClickCapture={handleClick}
|
onClickCapture={handleClick}
|
||||||
ref={handleNodeChange}
|
ref={handleNodeChange}
|
||||||
>
|
{...htmlHandlers}
|
||||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
/>
|
||||||
</AnimateEmojiProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,42 +1,70 @@
|
|||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import { Icon } from 'flavours/glitch/components/icon';
|
import { Icon } from 'flavours/glitch/components/icon';
|
||||||
import { useLinks } from 'flavours/glitch/hooks/useLinks';
|
|
||||||
import type { Account } from 'flavours/glitch/models/account';
|
import type { Account } from 'flavours/glitch/models/account';
|
||||||
|
|
||||||
export const AccountFields: React.FC<{
|
import { CustomEmojiProvider } from './emoji/context';
|
||||||
fields: Account['fields'];
|
import { EmojiHTML } from './emoji/html';
|
||||||
limit: number;
|
import { useElementHandledLink } from './status/handled_link';
|
||||||
}> = ({ fields, limit = -1 }) => {
|
|
||||||
const handleClick = useLinks();
|
export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||||
|
fields,
|
||||||
|
emojis,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const htmlHandlers = useElementHandledLink();
|
||||||
|
|
||||||
if (fields.size === 0) {
|
if (fields.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-fields' onClickCapture={handleClick}>
|
<CustomEmojiProvider emojis={emojis}>
|
||||||
{fields.take(limit).map((pair, i) => (
|
{fields.map((pair, i) => (
|
||||||
<dl
|
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||||
key={i}
|
<EmojiHTML
|
||||||
className={classNames({ verified: pair.get('verified_at') })}
|
as='dt'
|
||||||
>
|
htmlString={pair.name_emojified}
|
||||||
<dt
|
|
||||||
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
|
||||||
className='translate'
|
className='translate'
|
||||||
|
{...htmlHandlers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
<dd className='translate' title={pair.value_plain ?? ''}>
|
||||||
{pair.get('verified_at') && (
|
{pair.verified_at && (
|
||||||
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
<span
|
||||||
)}
|
title={intl.formatMessage(
|
||||||
<span
|
{
|
||||||
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
id: 'account.link_verified_on',
|
||||||
|
defaultMessage:
|
||||||
|
'Ownership of this link was checked on {date}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: intl.formatDate(pair.verified_at, dateFormatOptions),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||||
|
</span>
|
||||||
|
)}{' '}
|
||||||
|
<EmojiHTML
|
||||||
|
as='span'
|
||||||
|
htmlString={pair.value_emojified}
|
||||||
|
{...htmlHandlers}
|
||||||
/>
|
/>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
))}
|
))}
|
||||||
</div>
|
</CustomEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
};
|
||||||
|
|||||||
@ -150,10 +150,7 @@ const AutosuggestTextarea = forwardRef(({
|
|||||||
}, [suggestions, onSuggestionSelected, textareaRef]);
|
}, [suggestions, onSuggestionSelected, textareaRef]);
|
||||||
|
|
||||||
const handlePaste = useCallback((e) => {
|
const handlePaste = useCallback((e) => {
|
||||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
onPaste(e);
|
||||||
onPaste(e.clipboardData.files);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}, [onPaste]);
|
}, [onPaste]);
|
||||||
|
|
||||||
// Show the suggestions again whenever they change and the textarea is focused
|
// Show the suggestions again whenever they change and the textarea is focused
|
||||||
|
|||||||
@ -1,25 +1,48 @@
|
|||||||
|
import type { List } from 'immutable';
|
||||||
|
|
||||||
|
import type { CustomEmoji } from '../models/custom_emoji';
|
||||||
|
import type { Status } from '../models/status';
|
||||||
|
|
||||||
|
import { EmojiHTML } from './emoji/html';
|
||||||
import type { IconName } from './media_icon';
|
import type { IconName } from './media_icon';
|
||||||
import { MediaIcon } from './media_icon';
|
import { MediaIcon } from './media_icon';
|
||||||
import { StatusBanner, BannerVariant } from './status_banner';
|
import { StatusBanner, BannerVariant } from './status_banner';
|
||||||
|
|
||||||
export const ContentWarning: React.FC<{
|
export const ContentWarning: React.FC<{
|
||||||
text: string;
|
status: Status;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
icons?: IconName[];
|
icons?: IconName[];
|
||||||
}> = ({ text, expanded, onClick, icons }) => (
|
}> = ({ status, expanded, onClick, icons }) => {
|
||||||
<StatusBanner
|
const hasSpoiler = !!status.get('spoiler_text');
|
||||||
expanded={expanded}
|
if (!hasSpoiler) {
|
||||||
onClick={onClick}
|
return null;
|
||||||
variant={BannerVariant.Warning}
|
}
|
||||||
>
|
|
||||||
{icons?.map((icon) => (
|
const text =
|
||||||
<MediaIcon
|
status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
|
||||||
className='status__content__spoiler-icon'
|
if (typeof text !== 'string' || text.length === 0) {
|
||||||
icon={icon}
|
return null;
|
||||||
key={`icon-${icon}`}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusBanner
|
||||||
|
expanded={expanded}
|
||||||
|
onClick={onClick}
|
||||||
|
variant={BannerVariant.Warning}
|
||||||
|
>
|
||||||
|
{icons?.map((icon) => (
|
||||||
|
<MediaIcon
|
||||||
|
className='status__content__spoiler-icon'
|
||||||
|
icon={icon}
|
||||||
|
key={`icon-${icon}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<EmojiHTML
|
||||||
|
as='span'
|
||||||
|
htmlString={text}
|
||||||
|
extraEmojis={status.get('emoji') as List<CustomEmoji>}
|
||||||
/>
|
/>
|
||||||
))}
|
</StatusBanner>
|
||||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
);
|
||||||
</StatusBanner>
|
};
|
||||||
);
|
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
|
||||||
|
|
||||||
import { AnimateEmojiProvider } from '../emoji/context';
|
import { AnimateEmojiProvider } from '../emoji/context';
|
||||||
import { EmojiHTML } from '../emoji/html';
|
import { EmojiHTML } from '../emoji/html';
|
||||||
import { Skeleton } from '../skeleton';
|
import { Skeleton } from '../skeleton';
|
||||||
@ -24,11 +22,7 @@ export const DisplayNameWithoutDomain: FC<
|
|||||||
{account ? (
|
{account ? (
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
className='display-name__html'
|
className='display-name__html'
|
||||||
htmlString={
|
htmlString={account.get('display_name_html')}
|
||||||
isModernEmojiEnabled()
|
|
||||||
? account.get('display_name')
|
|
||||||
: account.get('display_name_html')
|
|
||||||
}
|
|
||||||
as='strong'
|
as='strong'
|
||||||
extraEmojis={account.get('emojis')}
|
extraEmojis={account.get('emojis')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
|
||||||
|
|
||||||
import { EmojiHTML } from '../emoji/html';
|
import { EmojiHTML } from '../emoji/html';
|
||||||
|
|
||||||
import type { DisplayNameProps } from './index';
|
import type { DisplayNameProps } from './index';
|
||||||
@ -19,11 +17,7 @@ export const DisplayNameSimple: FC<
|
|||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
{...props}
|
{...props}
|
||||||
as='span'
|
as='span'
|
||||||
htmlString={
|
htmlString={account.get('display_name_html')}
|
||||||
isModernEmojiEnabled()
|
|
||||||
? account.get('display_name')
|
|
||||||
: account.get('display_name_html')
|
|
||||||
}
|
|
||||||
extraEmojis={account.get('emojis')}
|
extraEmojis={account.get('emojis')}
|
||||||
/>
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
|
|||||||
@ -1,60 +1,89 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { ComponentPropsWithoutRef, ElementType } from 'react';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types';
|
import type { CustomEmojiMapArg } from '@/flavours/glitch/features/emoji/types';
|
||||||
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
|
import type {
|
||||||
|
OnAttributeHandler,
|
||||||
|
OnElementHandler,
|
||||||
|
} from '@/flavours/glitch/utils/html';
|
||||||
import { htmlStringToComponents } from '@/flavours/glitch/utils/html';
|
import { htmlStringToComponents } from '@/flavours/glitch/utils/html';
|
||||||
|
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||||
|
|
||||||
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
||||||
import { textToEmojis } from './index';
|
import { textToEmojis } from './index';
|
||||||
|
|
||||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
interface EmojiHTMLProps {
|
||||||
ComponentPropsWithoutRef<Element>,
|
|
||||||
'dangerouslySetInnerHTML' | 'className'
|
|
||||||
> & {
|
|
||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
as?: Element;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
onElement?: OnElementHandler;
|
||||||
|
onAttribute?: OnAttributeHandler;
|
||||||
|
}
|
||||||
|
|
||||||
export const ModernEmojiHTML = ({
|
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||||
extraEmojis,
|
(
|
||||||
htmlString,
|
{
|
||||||
as: asProp = 'div', // Rename for syntax highlighting
|
extraEmojis,
|
||||||
shallow,
|
htmlString,
|
||||||
className = '',
|
as: asProp = 'div', // Rename for syntax highlighting
|
||||||
...props
|
className = '',
|
||||||
}: EmojiHTMLProps<ElementType>) => {
|
onElement,
|
||||||
const contents = useMemo(
|
onAttribute,
|
||||||
() => htmlStringToComponents(htmlString, { onText: textToEmojis }),
|
...props
|
||||||
[htmlString],
|
},
|
||||||
);
|
ref,
|
||||||
|
) => {
|
||||||
|
const contents = useMemo(
|
||||||
|
() =>
|
||||||
|
htmlStringToComponents(htmlString, {
|
||||||
|
onText: textToEmojis,
|
||||||
|
onElement,
|
||||||
|
onAttribute,
|
||||||
|
}),
|
||||||
|
[htmlString, onAttribute, onElement],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomEmojiProvider emojis={extraEmojis}>
|
<CustomEmojiProvider emojis={extraEmojis}>
|
||||||
<AnimateEmojiProvider {...props} as={asProp} className={className}>
|
<AnimateEmojiProvider
|
||||||
{contents}
|
{...props}
|
||||||
</AnimateEmojiProvider>
|
as={asProp}
|
||||||
</CustomEmojiProvider>
|
className={className}
|
||||||
);
|
ref={ref}
|
||||||
};
|
>
|
||||||
|
{contents}
|
||||||
|
</AnimateEmojiProvider>
|
||||||
|
</CustomEmojiProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
|
||||||
|
|
||||||
export const LegacyEmojiHTML = <Element extends ElementType>(
|
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||||
props: EmojiHTMLProps<Element>,
|
(props, ref) => {
|
||||||
) => {
|
const {
|
||||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
as: asElement,
|
||||||
const Wrapper = asElement ?? 'div';
|
htmlString,
|
||||||
return (
|
extraEmojis,
|
||||||
<Wrapper
|
className,
|
||||||
{...rest}
|
onElement,
|
||||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
onAttribute,
|
||||||
className={classNames(className, 'animate-parent')}
|
...rest
|
||||||
/>
|
} = props;
|
||||||
);
|
const Wrapper = asElement ?? 'div';
|
||||||
};
|
return (
|
||||||
|
<Wrapper
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||||
|
className={classNames(className, 'animate-parent')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
|
||||||
|
|
||||||
export const EmojiHTML = isModernEmojiEnabled()
|
export const EmojiHTML = isModernEmojiEnabled()
|
||||||
? ModernEmojiHTML
|
? ModernEmojiHTML
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import { domain } from 'flavours/glitch/initial_state';
|
|||||||
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
|
import { getAccountHidden } from 'flavours/glitch/selectors/accounts';
|
||||||
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import { useLinks } from '../hooks/useLinks';
|
||||||
|
|
||||||
export const HoverCardAccount = forwardRef<
|
export const HoverCardAccount = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{ accountId?: string }
|
{ accountId?: string }
|
||||||
@ -64,6 +66,8 @@ export const HoverCardAccount = forwardRef<
|
|||||||
!isMutual &&
|
!isMutual &&
|
||||||
!isFollower;
|
!isFollower;
|
||||||
|
|
||||||
|
const handleClick = useLinks();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@ -109,7 +113,14 @@ export const HoverCardAccount = forwardRef<
|
|||||||
accountId={account.id}
|
accountId={account.id}
|
||||||
className='hover-card__bio'
|
className='hover-card__bio'
|
||||||
/>
|
/>
|
||||||
<AccountFields fields={account.fields} limit={2} />
|
|
||||||
|
<div className='account-fields' onClickCapture={handleClick}>
|
||||||
|
<AccountFields
|
||||||
|
fields={account.fields.take(2)}
|
||||||
|
emojis={account.emojis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{note && note.length > 0 && (
|
{note && note.length > 0 && (
|
||||||
<dl className='hover-card__note'>
|
<dl className='hover-card__note'>
|
||||||
<dt className='hover-card__note-label'>
|
<dt className='hover-card__note-label'>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import classNames from 'classnames';
|
|||||||
import { animated, useSpring } from '@react-spring/web';
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
|
||||||
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
|
import { fetchPoll, vote } from 'flavours/glitch/actions/polls';
|
||||||
@ -305,10 +306,11 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span
|
<EmojiHTML
|
||||||
className='poll__option__text translate'
|
className='poll__option__text translate'
|
||||||
lang={lang}
|
lang={lang}
|
||||||
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
htmlString={titleHtml}
|
||||||
|
extraEmojis={poll.emojis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!!voted && (
|
{!!voted && (
|
||||||
|
|||||||
@ -118,7 +118,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
prepend: PropTypes.string,
|
prepend: PropTypes.string,
|
||||||
withDismiss: PropTypes.bool,
|
withDismiss: PropTypes.bool,
|
||||||
isQuotedPost: PropTypes.bool,
|
isQuotedPost: PropTypes.bool,
|
||||||
shouldHighlightOnMount: PropTypes.bool,
|
shouldHighlightOnMount: PropTypes.bool,
|
||||||
getScrollPosition: PropTypes.func,
|
getScrollPosition: PropTypes.func,
|
||||||
updateScrollBottom: PropTypes.func,
|
updateScrollBottom: PropTypes.func,
|
||||||
expanded: PropTypes.bool,
|
expanded: PropTypes.bool,
|
||||||
@ -739,7 +739,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
</header>
|
</header>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.get('spoiler_text').length > 0 && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />}
|
<ContentWarning status={status} expanded={expanded} onClick={this.handleExpandedToggle} icons={mediaIcons} />
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -0,0 +1,65 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { HashtagMenuController } from '@/flavours/glitch/features/ui/components/hashtag_menu_controller';
|
||||||
|
import { accountFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
|
import { HoverCardController } from '../hover_card_controller';
|
||||||
|
|
||||||
|
import type { HandledLinkProps } from './handled_link';
|
||||||
|
import { HandledLink } from './handled_link';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Status/HandledLink',
|
||||||
|
render(args) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HandledLink {...args} mentionAccountId='1' hashtagAccountId='1' />
|
||||||
|
<HashtagMenuController />
|
||||||
|
<HoverCardController />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
href: 'https://example.com/path/subpath?query=1#hash',
|
||||||
|
text: 'https://example.com',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
state: {
|
||||||
|
accounts: {
|
||||||
|
'1': accountFactoryState(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<Pick<HandledLinkProps, 'href' | 'text'>>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const Hashtag: Story = {
|
||||||
|
args: {
|
||||||
|
text: '#example',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Mention: Story = {
|
||||||
|
args: {
|
||||||
|
text: '@user',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InternalLink: Story = {
|
||||||
|
args: {
|
||||||
|
href: '/about',
|
||||||
|
text: 'About',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InvalidURL: Story = {
|
||||||
|
args: {
|
||||||
|
href: 'ht!tp://invalid-url',
|
||||||
|
text: 'ht!tp://invalid-url -- invalid!',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { ComponentProps, FC } from 'react';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { OnElementHandler } from '@/flavours/glitch/utils/html';
|
||||||
|
|
||||||
|
export interface HandledLinkProps {
|
||||||
|
href: string;
|
||||||
|
text: string;
|
||||||
|
hashtagAccountId?: string;
|
||||||
|
mentionAccountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||||
|
href,
|
||||||
|
text,
|
||||||
|
hashtagAccountId,
|
||||||
|
mentionAccountId,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
// Handle hashtags
|
||||||
|
if (text.startsWith('#')) {
|
||||||
|
const hashtag = text.slice(1).trim();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className='mention hashtag'
|
||||||
|
to={`/tags/${hashtag}`}
|
||||||
|
rel='tag'
|
||||||
|
data-menu-hashtag={hashtagAccountId}
|
||||||
|
>
|
||||||
|
#<span>{hashtag}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else if (text.startsWith('@')) {
|
||||||
|
// Handle mentions
|
||||||
|
const mention = text.slice(1).trim();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className='mention'
|
||||||
|
to={`/@${mention}`}
|
||||||
|
title={`@${mention}`}
|
||||||
|
data-hover-card-account={mentionAccountId}
|
||||||
|
>
|
||||||
|
@<span>{mention}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-absolute paths treated as internal links.
|
||||||
|
if (href.startsWith('/')) {
|
||||||
|
return (
|
||||||
|
<Link {...props} className='unhandled-link' to={href}>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash.
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
href={href}
|
||||||
|
title={href}
|
||||||
|
className='unhandled-link'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer noopener'
|
||||||
|
translate='no'
|
||||||
|
>
|
||||||
|
<span className='invisible'>{url.protocol + '//'}</span>
|
||||||
|
<span className='ellipsis'>{`${url.hostname}/${first ?? ''}`}</span>
|
||||||
|
<span className='invisible'>{'/' + rest.join('/')}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useElementHandledLink = ({
|
||||||
|
hashtagAccountId,
|
||||||
|
hrefToMentionAccountId,
|
||||||
|
}: {
|
||||||
|
hashtagAccountId?: string;
|
||||||
|
hrefToMentionAccountId?: (href: string) => string | undefined;
|
||||||
|
} = {}) => {
|
||||||
|
const onElement = useCallback<OnElementHandler>(
|
||||||
|
(element, { key, ...props }) => {
|
||||||
|
if (element instanceof HTMLAnchorElement) {
|
||||||
|
const mentionId = hrefToMentionAccountId?.(element.href);
|
||||||
|
return (
|
||||||
|
<HandledLink
|
||||||
|
{...props}
|
||||||
|
key={key as string} // React requires keys to not be part of spread props.
|
||||||
|
href={element.href}
|
||||||
|
text={element.innerText}
|
||||||
|
hashtagAccountId={hashtagAccountId}
|
||||||
|
mentionAccountId={mentionId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[hashtagAccountId, hrefToMentionAccountId],
|
||||||
|
);
|
||||||
|
return { onElement };
|
||||||
|
};
|
||||||
@ -3,6 +3,8 @@ import { useCallback, useRef, useId } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { AnimateEmojiProvider } from './emoji/context';
|
||||||
|
|
||||||
export enum BannerVariant {
|
export enum BannerVariant {
|
||||||
Warning = 'warning',
|
Warning = 'warning',
|
||||||
Filter = 'filter',
|
Filter = 'filter',
|
||||||
@ -34,8 +36,7 @@ export const StatusBanner: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Element clicks are passed on to button
|
// Element clicks are passed on to button
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
<AnimateEmojiProvider
|
||||||
<div
|
|
||||||
className={
|
className={
|
||||||
variant === BannerVariant.Warning
|
variant === BannerVariant.Warning
|
||||||
? 'content-warning'
|
? 'content-warning'
|
||||||
@ -69,6 +70,6 @@ export const StatusBanner: React.FC<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</AnimateEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import { decode as decodeIDNA } from 'flavours/glitch/utils/idna';
|
|||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
import { EmojiHTML } from './emoji/html';
|
import { EmojiHTML } from './emoji/html';
|
||||||
|
import { HandledLink } from './status/handled_link';
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
@ -83,9 +84,6 @@ const isLinkMisleading = (link) => {
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getStatusContent(status) {
|
export function getStatusContent(status) {
|
||||||
if (isModernEmojiEnabled()) {
|
|
||||||
return status.getIn(['translation', 'content']) || status.get('content');
|
|
||||||
}
|
|
||||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -163,6 +161,23 @@ class StatusContent extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { status, onCollapsedToggle } = this.props;
|
const { status, onCollapsedToggle } = this.props;
|
||||||
|
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
||||||
|
const { collapsible, onClick } = this.props;
|
||||||
|
|
||||||
|
const collapsed =
|
||||||
|
collapsible
|
||||||
|
&& onClick
|
||||||
|
&& node.clientHeight > MAX_HEIGHT
|
||||||
|
&& status.get('spoiler_text').length === 0;
|
||||||
|
|
||||||
|
onCollapsedToggle(collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if modern emoji is enabled, as it handles links using the HandledLink component.
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const links = node.querySelectorAll('a');
|
const links = node.querySelectorAll('a');
|
||||||
|
|
||||||
let link, mention;
|
let link, mention;
|
||||||
@ -225,18 +240,6 @@ class StatusContent extends PureComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
|
||||||
const { collapsible, onClick } = this.props;
|
|
||||||
|
|
||||||
const collapsed =
|
|
||||||
collapsible
|
|
||||||
&& onClick
|
|
||||||
&& node.clientHeight > MAX_HEIGHT
|
|
||||||
&& status.get('spoiler_text').length === 0;
|
|
||||||
|
|
||||||
onCollapsedToggle(collapsed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
@ -298,6 +301,25 @@ class StatusContent extends PureComponent {
|
|||||||
this.node = c;
|
this.node = c;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleElement = (element, { key, ...props }) => {
|
||||||
|
if (element instanceof HTMLAnchorElement) {
|
||||||
|
const mention = this.props.status.get('mentions').find(item => element.href === item.get('url'));
|
||||||
|
return (
|
||||||
|
<HandledLink
|
||||||
|
{...props}
|
||||||
|
href={element.href}
|
||||||
|
text={element.innerText}
|
||||||
|
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
|
||||||
|
mentionAccountId={mention?.get('id')}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, intl, statusContent } = this.props;
|
const { status, intl, statusContent } = this.props;
|
||||||
|
|
||||||
@ -342,6 +364,7 @@ class StatusContent extends PureComponent {
|
|||||||
lang={language}
|
lang={language}
|
||||||
htmlString={content}
|
htmlString={content}
|
||||||
extraEmojis={status.get('emojis')}
|
extraEmojis={status.get('emojis')}
|
||||||
|
onElement={this.handleElement.bind(this)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
@ -359,6 +382,7 @@ class StatusContent extends PureComponent {
|
|||||||
lang={language}
|
lang={language}
|
||||||
htmlString={content}
|
htmlString={content}
|
||||||
extraEmojis={status.get('emojis')}
|
extraEmojis={status.get('emojis')}
|
||||||
|
onElement={this.handleElement.bind(this)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
|
|
||||||
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
import type { OnAttributeHandler } from '../utils/html';
|
||||||
|
|
||||||
import { Icon } from './icon';
|
import { Icon } from './icon';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
const stripRelMe = (html: string) => {
|
const stripRelMe = (html: string) => {
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
||||||
|
|
||||||
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
|
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
|
||||||
@ -15,7 +22,23 @@ const stripRelMe = (html: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const body = document.querySelector('body');
|
const body = document.querySelector('body');
|
||||||
return body ? { __html: body.innerHTML } : undefined;
|
return body?.innerHTML ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAttribute: OnAttributeHandler = (name, value, tagName) => {
|
||||||
|
if (name === 'rel' && tagName === 'a') {
|
||||||
|
if (value === 'me') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
value
|
||||||
|
.split(' ')
|
||||||
|
.filter((x) => x !== 'me')
|
||||||
|
.join(' '),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -24,6 +47,10 @@ interface Props {
|
|||||||
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
||||||
<span className='verified-badge'>
|
<span className='verified-badge'>
|
||||||
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
||||||
<span dangerouslySetInnerHTML={stripRelMe(link)} />
|
<EmojiHTML
|
||||||
|
as='span'
|
||||||
|
htmlString={stripRelMe(link)}
|
||||||
|
onAttribute={onAttribute}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -70,7 +70,7 @@ function loaded() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
document.querySelectorAll('.emojify').forEach((content) => {
|
document.querySelectorAll('.emojify').forEach((content) => {
|
||||||
content.innerHTML = emojify(content.innerHTML);
|
content.innerHTML = emojify(content.innerHTML, {}, true); // Force emojify as public doesn't load the new emoji system.
|
||||||
});
|
});
|
||||||
|
|
||||||
document
|
document
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import { Helmet } from 'react-helmet';
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
import { AccountBio } from '@/flavours/glitch/components/account_bio';
|
||||||
|
import { AccountFields } from '@/flavours/glitch/components/account_fields';
|
||||||
import { DisplayName } from '@/flavours/glitch/components/display_name';
|
import { DisplayName } from '@/flavours/glitch/components/display_name';
|
||||||
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||||
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';
|
||||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||||
@ -190,14 +190,6 @@ const titleFromAccount = (account: Account) => {
|
|||||||
return `${prefix} (@${acct})`;
|
return `${prefix} (@${acct})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AccountHeader: React.FC<{
|
export const AccountHeader: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
hideTabs?: boolean;
|
hideTabs?: boolean;
|
||||||
@ -895,46 +887,7 @@ export const AccountHeader: React.FC<{
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{fields.map((pair, i) => (
|
<AccountFields fields={fields} emojis={account.emojis} />
|
||||||
<dl
|
|
||||||
key={i}
|
|
||||||
className={classNames({
|
|
||||||
verified: pair.verified_at,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<dt
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: pair.name_emojified,
|
|
||||||
}}
|
|
||||||
title={pair.name}
|
|
||||||
className='translate'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<dd className='translate' title={pair.value_plain ?? ''}>
|
|
||||||
{pair.verified_at && (
|
|
||||||
<span
|
|
||||||
title={intl.formatMessage(messages.linkVerifiedOn, {
|
|
||||||
date: intl.formatDate(
|
|
||||||
pair.verified_at,
|
|
||||||
dateFormatOptions,
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
id='check'
|
|
||||||
icon={CheckIcon}
|
|
||||||
className='verified__mark'
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}{' '}
|
|
||||||
<span
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: pair.value_emojified,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -49,9 +49,7 @@ export const EditIndicator = () => {
|
|||||||
|
|
||||||
<EmbeddedStatusContent
|
<EmbeddedStatusContent
|
||||||
className='edit-indicator__content translate'
|
className='edit-indicator__content translate'
|
||||||
content={status.get('contentHtml')}
|
status={status}
|
||||||
language={status.get('language')}
|
|
||||||
mentions={status.get('mentions')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||||
|
|||||||
@ -34,9 +34,7 @@ export const ReplyIndicator = () => {
|
|||||||
|
|
||||||
<EmbeddedStatusContent
|
<EmbeddedStatusContent
|
||||||
className='reply-indicator__content translate'
|
className='reply-indicator__content translate'
|
||||||
content={status.get('contentHtml')}
|
status={status}
|
||||||
language={status.get('language')}
|
|
||||||
mentions={status.get('mentions')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||||
|
|||||||
@ -10,11 +10,14 @@ import {
|
|||||||
insertEmojiCompose,
|
insertEmojiCompose,
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
} from 'flavours/glitch/actions/compose';
|
} from 'flavours/glitch/actions/compose';
|
||||||
|
import { pasteLinkCompose } from 'flavours/glitch/actions/compose_typed';
|
||||||
import { openModal } from 'flavours/glitch/actions/modal';
|
import { openModal } from 'flavours/glitch/actions/modal';
|
||||||
import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
|
import { privacyPreference } from 'flavours/glitch/utils/privacy_preference';
|
||||||
|
|
||||||
import ComposeForm from '../components/compose_form';
|
import ComposeForm from '../components/compose_form';
|
||||||
|
|
||||||
|
const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i;
|
||||||
|
|
||||||
const sideArmPrivacy = state => {
|
const sideArmPrivacy = state => {
|
||||||
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
|
const inReplyTo = state.getIn(['compose', 'in_reply_to']);
|
||||||
const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
|
const replyPrivacy = inReplyTo ? state.getIn(['statuses', inReplyTo, 'visibility']) : null;
|
||||||
@ -93,8 +96,21 @@ const mapDispatchToProps = (dispatch, props) => ({
|
|||||||
dispatch(changeComposeSpoilerText(checked));
|
dispatch(changeComposeSpoilerText(checked));
|
||||||
},
|
},
|
||||||
|
|
||||||
onPaste (files) {
|
onPaste (e) {
|
||||||
dispatch(uploadCompose(files));
|
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||||
|
dispatch(uploadCompose(e.clipboardData.files));
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.clipboardData && e.clipboardData.files.length === 0) {
|
||||||
|
const data = e.clipboardData.getData('text/plain');
|
||||||
|
if (!data.match(urlLikeRegex)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(data);
|
||||||
|
dispatch(pasteLinkCompose({ url }));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onPickEmoji (position, data, needsSpace) {
|
onPickEmoji (position, data, needsSpace) {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
import { Avatar } from 'flavours/glitch/components/avatar';
|
import { Avatar } from 'flavours/glitch/components/avatar';
|
||||||
import { DisplayName } from 'flavours/glitch/components/display_name';
|
import { DisplayName } from 'flavours/glitch/components/display_name';
|
||||||
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
import { FollowButton } from 'flavours/glitch/components/follow_button';
|
||||||
@ -42,9 +43,10 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
{account.get('note').length > 0 && (
|
{account.get('note').length > 0 && (
|
||||||
<div
|
<EmojiHTML
|
||||||
className='account-card__bio translate animate-parent'
|
className='account-card__bio translate'
|
||||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
htmlString={account.get('note_emojified')}
|
||||||
|
extraEmojis={account.get('emojis')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Trie from 'substring-trie';
|
import Trie from 'substring-trie';
|
||||||
|
|
||||||
|
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
import { assetHost } from 'flavours/glitch/utils/config';
|
import { assetHost } from 'flavours/glitch/utils/config';
|
||||||
|
|
||||||
import { autoPlayGif, useSystemEmojiFont } from '../../initial_state';
|
import { autoPlayGif, useSystemEmojiFont } from '../../initial_state';
|
||||||
@ -148,7 +149,17 @@ const emojifyNode = (node, customEmojis) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const emojify = (str, customEmojis = {}) => {
|
/**
|
||||||
|
* Legacy emoji processing function.
|
||||||
|
* @param {string} str
|
||||||
|
* @param {object} customEmojis
|
||||||
|
* @param {boolean} force If true, always emojify even if modern emoji is enabled
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const emojify = (str, customEmojis = {}, force = false) => {
|
||||||
|
if (isModernEmojiEnabled() && !force) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = str;
|
wrapper.innerHTML = str;
|
||||||
|
|
||||||
|
|||||||
@ -154,15 +154,21 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
|||||||
if (!extraEmojis) {
|
if (!extraEmojis) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!isList(extraEmojis)) {
|
if (Array.isArray(extraEmojis)) {
|
||||||
return extraEmojis;
|
return extraEmojis.reduce<ExtraCustomEmojiMap>(
|
||||||
}
|
|
||||||
return extraEmojis
|
|
||||||
.toJSON()
|
|
||||||
.reduce<ExtraCustomEmojiMap>(
|
|
||||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (isList(extraEmojis)) {
|
||||||
|
return extraEmojis
|
||||||
|
.toJS()
|
||||||
|
.reduce<ExtraCustomEmojiMap>(
|
||||||
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return extraEmojis;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexStringToNumbers(hexString: string): number[] {
|
function hexStringToNumbers(hexString: string): number[] {
|
||||||
|
|||||||
@ -56,7 +56,9 @@ export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
|||||||
|
|
||||||
export type CustomEmojiMapArg =
|
export type CustomEmojiMapArg =
|
||||||
| ExtraCustomEmojiMap
|
| ExtraCustomEmojiMap
|
||||||
| ImmutableList<CustomEmoji>;
|
| ImmutableList<CustomEmoji>
|
||||||
|
| CustomEmoji[]
|
||||||
|
| ApiCustomEmojiJSON[];
|
||||||
|
|
||||||
export type ExtraCustomEmojiMap = Record<
|
export type ExtraCustomEmojiMap = Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@ -8,10 +8,11 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
import { Avatar } from '../../../components/avatar';
|
import { Avatar } from '@/flavours/glitch/components/avatar';
|
||||||
import { DisplayName } from '../../../components/display_name';
|
import { DisplayName } from '@/flavours/glitch/components/display_name';
|
||||||
import { IconButton } from '../../../components/icon_button';
|
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||||
import { Permalink } from '../../../components/permalink';
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
|
import { Permalink } from '@/flavours/glitch/components/permalink';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
||||||
@ -29,7 +30,6 @@ class AccountAuthorize extends ImmutablePureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, account, onAuthorize, onReject } = this.props;
|
const { intl, account, onAuthorize, onReject } = this.props;
|
||||||
const content = { __html: account.get('note_emojified') };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-authorize__wrapper'>
|
<div className='account-authorize__wrapper'>
|
||||||
@ -39,7 +39,11 @@ class AccountAuthorize extends ImmutablePureComponent {
|
|||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
</Permalink>
|
</Permalink>
|
||||||
|
|
||||||
<div className='account__header__content translate' dangerouslySetInnerHTML={content} />
|
<EmojiHTML
|
||||||
|
className='account__header__content translate'
|
||||||
|
htmlString={account.get('note_emojified')}
|
||||||
|
extraEmojis={account.get('emojis')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='account--panel'>
|
<div className='account--panel'>
|
||||||
|
|||||||
@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import type { ApiAnnouncementJSON } from '@/flavours/glitch/api_types/announcements';
|
||||||
|
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||||
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
|
|
||||||
|
import { ReactionsBar } from './reactions';
|
||||||
|
|
||||||
|
export interface IAnnouncement extends ApiAnnouncementJSON {
|
||||||
|
contentHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementProps {
|
||||||
|
announcement: IAnnouncement;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Announcement: FC<AnnouncementProps> = ({
|
||||||
|
announcement,
|
||||||
|
selected,
|
||||||
|
}) => {
|
||||||
|
const [unread, setUnread] = useState(!announcement.read);
|
||||||
|
useEffect(() => {
|
||||||
|
// Only update `unread` marker once the announcement is out of view
|
||||||
|
if (!selected && unread !== !announcement.read) {
|
||||||
|
setUnread(!announcement.read);
|
||||||
|
}
|
||||||
|
}, [announcement.read, selected, unread]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimateEmojiProvider className='announcements__item'>
|
||||||
|
<strong className='announcements__item__range'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='announcement.announcement'
|
||||||
|
defaultMessage='Announcement'
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{' · '}
|
||||||
|
<Timestamp announcement={announcement} />
|
||||||
|
</span>
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<EmojiHTML
|
||||||
|
className='announcements__item__content translate'
|
||||||
|
htmlString={announcement.contentHtml}
|
||||||
|
extraEmojis={announcement.emojis}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReactionsBar reactions={announcement.reactions} id={announcement.id} />
|
||||||
|
|
||||||
|
{unread && <span className='announcements__item__unread' />}
|
||||||
|
</AnimateEmojiProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Timestamp: FC<Pick<AnnouncementProps, 'announcement'>> = ({
|
||||||
|
announcement,
|
||||||
|
}) => {
|
||||||
|
const startsAt = announcement.starts_at && new Date(announcement.starts_at);
|
||||||
|
const endsAt = announcement.ends_at && new Date(announcement.ends_at);
|
||||||
|
const now = new Date();
|
||||||
|
const hasTimeRange = startsAt && endsAt;
|
||||||
|
const skipTime = announcement.all_day;
|
||||||
|
|
||||||
|
if (hasTimeRange) {
|
||||||
|
const skipYear =
|
||||||
|
startsAt.getFullYear() === endsAt.getFullYear() &&
|
||||||
|
endsAt.getFullYear() === now.getFullYear();
|
||||||
|
const skipEndDate =
|
||||||
|
startsAt.getDate() === endsAt.getDate() &&
|
||||||
|
startsAt.getMonth() === endsAt.getMonth() &&
|
||||||
|
startsAt.getFullYear() === endsAt.getFullYear();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormattedDate
|
||||||
|
value={startsAt}
|
||||||
|
year={
|
||||||
|
skipYear || startsAt.getFullYear() === now.getFullYear()
|
||||||
|
? undefined
|
||||||
|
: 'numeric'
|
||||||
|
}
|
||||||
|
month='short'
|
||||||
|
day='2-digit'
|
||||||
|
hour={skipTime ? undefined : '2-digit'}
|
||||||
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
|
/>{' '}
|
||||||
|
-{' '}
|
||||||
|
<FormattedDate
|
||||||
|
value={endsAt}
|
||||||
|
year={
|
||||||
|
skipYear || endsAt.getFullYear() === now.getFullYear()
|
||||||
|
? undefined
|
||||||
|
: 'numeric'
|
||||||
|
}
|
||||||
|
month={skipEndDate ? undefined : 'short'}
|
||||||
|
day={skipEndDate ? undefined : '2-digit'}
|
||||||
|
hour={skipTime ? undefined : '2-digit'}
|
||||||
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const publishedAt = new Date(announcement.published_at);
|
||||||
|
return (
|
||||||
|
<FormattedDate
|
||||||
|
value={publishedAt}
|
||||||
|
year={
|
||||||
|
publishedAt.getFullYear() === now.getFullYear() ? undefined : 'numeric'
|
||||||
|
}
|
||||||
|
month='short'
|
||||||
|
day='2-digit'
|
||||||
|
hour={skipTime ? undefined : '2-digit'}
|
||||||
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import type { Map, List } from 'immutable';
|
||||||
|
|
||||||
|
import ReactSwipeableViews from 'react-swipeable-views';
|
||||||
|
|
||||||
|
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||||
|
import { IconButton } from '@/flavours/glitch/components/icon_button';
|
||||||
|
import LegacyAnnouncements from '@/flavours/glitch/features/getting_started/containers/announcements_container';
|
||||||
|
import { mascot, reduceMotion } from '@/flavours/glitch/initial_state';
|
||||||
|
import { createAppSelector, useAppSelector } from '@/flavours/glitch/store';
|
||||||
|
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
|
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||||
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
|
|
||||||
|
import type { IAnnouncement } from './announcement';
|
||||||
|
import { Announcement } from './announcement';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||||
|
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||||
|
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const announcementSelector = createAppSelector(
|
||||||
|
[(state) => state.announcements as Map<string, List<Map<string, unknown>>>],
|
||||||
|
(announcements) =>
|
||||||
|
(announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ModernAnnouncements: FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const announcements = useAppSelector(announcementSelector);
|
||||||
|
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||||
|
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const handleChangeIndex = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
setIndex(idx % announcements.length);
|
||||||
|
},
|
||||||
|
[announcements.length],
|
||||||
|
);
|
||||||
|
const handleNextIndex = useCallback(() => {
|
||||||
|
setIndex((prevIndex) => (prevIndex + 1) % announcements.length);
|
||||||
|
}, [announcements.length]);
|
||||||
|
const handlePrevIndex = useCallback(() => {
|
||||||
|
setIndex((prevIndex) =>
|
||||||
|
prevIndex === 0 ? announcements.length - 1 : prevIndex - 1,
|
||||||
|
);
|
||||||
|
}, [announcements.length]);
|
||||||
|
|
||||||
|
if (announcements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='announcements'>
|
||||||
|
<img
|
||||||
|
className='announcements__mastodon'
|
||||||
|
alt=''
|
||||||
|
draggable='false'
|
||||||
|
src={mascot ?? elephantUIPlane}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='announcements__container'>
|
||||||
|
<CustomEmojiProvider emojis={emojis}>
|
||||||
|
<ReactSwipeableViews
|
||||||
|
animateHeight
|
||||||
|
animateTransitions={!reduceMotion}
|
||||||
|
index={index}
|
||||||
|
onChangeIndex={handleChangeIndex}
|
||||||
|
>
|
||||||
|
{announcements
|
||||||
|
.map((announcement, idx) => (
|
||||||
|
<Announcement
|
||||||
|
key={announcement.id}
|
||||||
|
announcement={announcement}
|
||||||
|
selected={index === idx}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.reverse()}
|
||||||
|
</ReactSwipeableViews>
|
||||||
|
</CustomEmojiProvider>
|
||||||
|
|
||||||
|
{announcements.length > 1 && (
|
||||||
|
<div className='announcements__pagination'>
|
||||||
|
<IconButton
|
||||||
|
disabled={announcements.length === 1}
|
||||||
|
title={intl.formatMessage(messages.previous)}
|
||||||
|
icon='chevron-left'
|
||||||
|
iconComponent={ChevronLeftIcon}
|
||||||
|
onClick={handlePrevIndex}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{index + 1} / {announcements.length}
|
||||||
|
</span>
|
||||||
|
<IconButton
|
||||||
|
disabled={announcements.length === 1}
|
||||||
|
title={intl.formatMessage(messages.next)}
|
||||||
|
icon='chevron-right'
|
||||||
|
iconComponent={ChevronRightIcon}
|
||||||
|
onClick={handleNextIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Announcements = isModernEmojiEnabled()
|
||||||
|
? ModernAnnouncements
|
||||||
|
: LegacyAnnouncements;
|
||||||
@ -0,0 +1,111 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import type { FC, HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { AnimatedProps } from '@react-spring/web';
|
||||||
|
import { animated, useTransition } from '@react-spring/web';
|
||||||
|
|
||||||
|
import {
|
||||||
|
addReaction,
|
||||||
|
removeReaction,
|
||||||
|
} from '@/flavours/glitch/actions/announcements';
|
||||||
|
import type { ApiAnnouncementReactionJSON } from '@/flavours/glitch/api_types/announcements';
|
||||||
|
import { AnimatedNumber } from '@/flavours/glitch/components/animated_number';
|
||||||
|
import { Emoji } from '@/flavours/glitch/components/emoji';
|
||||||
|
import { Icon } from '@/flavours/glitch/components/icon';
|
||||||
|
import EmojiPickerDropdown from '@/flavours/glitch/features/compose/containers/emoji_picker_dropdown_container';
|
||||||
|
import { isUnicodeEmoji } from '@/flavours/glitch/features/emoji/utils';
|
||||||
|
import { useAppDispatch } from '@/flavours/glitch/store';
|
||||||
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
|
|
||||||
|
export const ReactionsBar: FC<{
|
||||||
|
reactions: ApiAnnouncementReactionJSON[];
|
||||||
|
id: string;
|
||||||
|
}> = ({ reactions, id }) => {
|
||||||
|
const visibleReactions = useMemo(
|
||||||
|
() => reactions.filter((x) => x.count > 0),
|
||||||
|
[reactions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleEmojiPick = useCallback(
|
||||||
|
(emoji: { native: string }) => {
|
||||||
|
dispatch(addReaction(id, emoji.native.replaceAll(/:/g, '')));
|
||||||
|
},
|
||||||
|
[dispatch, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const transitions = useTransition(visibleReactions, {
|
||||||
|
from: {
|
||||||
|
scale: 0,
|
||||||
|
},
|
||||||
|
enter: {
|
||||||
|
scale: 1,
|
||||||
|
},
|
||||||
|
leave: {
|
||||||
|
scale: 0,
|
||||||
|
},
|
||||||
|
keys: visibleReactions.map((x) => x.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('reactions-bar', {
|
||||||
|
'reactions-bar--empty': visibleReactions.length === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{transitions(({ scale }, reaction) => (
|
||||||
|
<Reaction
|
||||||
|
key={reaction.name}
|
||||||
|
reaction={reaction}
|
||||||
|
style={{ transform: scale.to((s) => `scale(${s})`) }}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{visibleReactions.length < 8 && (
|
||||||
|
<EmojiPickerDropdown
|
||||||
|
onPickEmoji={handleEmojiPick}
|
||||||
|
button={<Icon id='plus' icon={AddIcon} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Reaction: FC<{
|
||||||
|
reaction: ApiAnnouncementReactionJSON;
|
||||||
|
id: string;
|
||||||
|
style: AnimatedProps<HTMLAttributes<HTMLButtonElement>>['style'];
|
||||||
|
}> = ({ id, reaction, style }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (reaction.me) {
|
||||||
|
dispatch(removeReaction(id, reaction.name));
|
||||||
|
} else {
|
||||||
|
dispatch(addReaction(id, reaction.name));
|
||||||
|
}
|
||||||
|
}, [dispatch, id, reaction.me, reaction.name]);
|
||||||
|
|
||||||
|
const code = isUnicodeEmoji(reaction.name)
|
||||||
|
? reaction.name
|
||||||
|
: `:${reaction.name}:`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<animated.button
|
||||||
|
className={classNames('reactions-bar__item', {
|
||||||
|
active: reaction.me,
|
||||||
|
})}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<span className='reactions-bar__item__emoji'>
|
||||||
|
<Emoji code={code} />
|
||||||
|
</span>
|
||||||
|
<span className='reactions-bar__item__count'>
|
||||||
|
<AnimatedNumber value={reaction.count} />
|
||||||
|
</span>
|
||||||
|
</animated.button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,7 +14,6 @@ import { SymbolLogo } from 'flavours/glitch/components/logo';
|
|||||||
import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements';
|
import { fetchAnnouncements, toggleShowAnnouncements } from 'flavours/glitch/actions/announcements';
|
||||||
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
|
import { IconWithBadge } from 'flavours/glitch/components/icon_with_badge';
|
||||||
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
import { NotSignedInIndicator } from 'flavours/glitch/components/not_signed_in_indicator';
|
||||||
import AnnouncementsContainer from 'flavours/glitch/features/getting_started/containers/announcements_container';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
import { identityContextPropShape, withIdentity } from 'flavours/glitch/identity_context';
|
||||||
import { criticalUpdatesPending } from 'flavours/glitch/initial_state';
|
import { criticalUpdatesPending } from 'flavours/glitch/initial_state';
|
||||||
import { withBreakpoint } from 'flavours/glitch/features/ui/hooks/useBreakpoint';
|
import { withBreakpoint } from 'flavours/glitch/features/ui/hooks/useBreakpoint';
|
||||||
@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
|
|||||||
|
|
||||||
import { ColumnSettings } from './components/column_settings';
|
import { ColumnSettings } from './components/column_settings';
|
||||||
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||||
|
import { Announcements } from './components/announcements';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
@ -164,7 +164,7 @@ class HomeTimeline extends PureComponent {
|
|||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
extraButton={announcementsButton}
|
extraButton={announcementsButton}
|
||||||
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
appendContent={hasAnnouncements && showAnnouncements && <Announcements />}
|
||||||
>
|
>
|
||||||
<ColumnSettings />
|
<ColumnSettings />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|||||||
@ -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 type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
|
||||||
import { AnimateEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
import { AnimateEmojiProvider } from '@/flavours/glitch/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';
|
||||||
@ -18,7 +19,7 @@ import { useAppSelector, useAppDispatch } from 'flavours/glitch/store';
|
|||||||
|
|
||||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||||
|
|
||||||
export type Mention = RecordOf<{ url: string; acct: string }>;
|
export type Mention = RecordOf<ApiMentionJSON>;
|
||||||
|
|
||||||
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
statusId,
|
statusId,
|
||||||
@ -86,12 +87,9 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
||||||
const contentHtml = status.get('contentHtml') as string;
|
const hasContentWarning = !!status.get('spoiler_text');
|
||||||
const contentWarning = status.get('spoilerHtml') as string;
|
|
||||||
const poll = status.get('poll');
|
const poll = status.get('poll');
|
||||||
const language = status.get('language') as string;
|
const expanded = !status.get('hidden') || !hasContentWarning;
|
||||||
const mentions = status.get('mentions') as ImmutableList<Mention>;
|
|
||||||
const expanded = !status.get('hidden') || !contentWarning;
|
|
||||||
const mediaAttachmentsSize = (
|
const mediaAttachmentsSize = (
|
||||||
status.get('media_attachments') as ImmutableList<unknown>
|
status.get('media_attachments') as ImmutableList<unknown>
|
||||||
).size;
|
).size;
|
||||||
@ -109,20 +107,16 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contentWarning && (
|
<ContentWarning
|
||||||
<ContentWarning
|
status={status}
|
||||||
text={contentWarning}
|
onClick={handleContentWarningClick}
|
||||||
onClick={handleContentWarningClick}
|
expanded={expanded}
|
||||||
expanded={expanded}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(!contentWarning || expanded) && (
|
{(!hasContentWarning || expanded) && (
|
||||||
<EmbeddedStatusContent
|
<EmbeddedStatusContent
|
||||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
className='notification-group__embedded-status__content reply-indicator__content translate'
|
||||||
content={contentHtml}
|
status={status}
|
||||||
language={language}
|
|
||||||
mentions={mentions}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
@ -6,16 +6,22 @@ import type { List } from 'immutable';
|
|||||||
|
|
||||||
import type { History } from 'history';
|
import type { History } from 'history';
|
||||||
|
|
||||||
|
import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses';
|
||||||
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
|
import { useElementHandledLink } from '@/flavours/glitch/components/status/handled_link';
|
||||||
|
import type { Status } from '@/flavours/glitch/models/status';
|
||||||
|
import { isModernEmojiEnabled } from '@/flavours/glitch/utils/environment';
|
||||||
|
|
||||||
import type { Mention } from './embedded_status';
|
import type { Mention } from './embedded_status';
|
||||||
|
|
||||||
const handleMentionClick = (
|
const handleMentionClick = (
|
||||||
history: History,
|
history: History,
|
||||||
mention: Mention,
|
mention: ApiMentionJSON,
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
) => {
|
) => {
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
history.push(`/@${mention.get('acct')}`);
|
history.push(`/@${mention.acct}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,16 +37,26 @@ const handleHashtagClick = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbeddedStatusContent: React.FC<{
|
export const EmbeddedStatusContent: React.FC<{
|
||||||
content: string;
|
status: Status;
|
||||||
mentions: List<Mention>;
|
|
||||||
language: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ content, mentions, language, className }) => {
|
}> = ({ status, className }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
|
const mentions = useMemo(
|
||||||
|
() => (status.get('mentions') as List<Mention>).toJS(),
|
||||||
|
[status],
|
||||||
|
);
|
||||||
|
const htmlHandlers = useElementHandledLink({
|
||||||
|
hashtagAccountId: status.get('account') as string | undefined,
|
||||||
|
hrefToMentionAccountId(href) {
|
||||||
|
const mention = mentions.find((item) => item.url === href);
|
||||||
|
return mention?.id;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleContentRef = useCallback(
|
const handleContentRef = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
if (!node) {
|
if (!node || isModernEmojiEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +69,7 @@ export const EmbeddedStatusContent: React.FC<{
|
|||||||
|
|
||||||
link.classList.add('status-link');
|
link.classList.add('status-link');
|
||||||
|
|
||||||
const mention = mentions.find((item) => link.href === item.get('url'));
|
const mention = mentions.find((item) => link.href === item.url);
|
||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
link.addEventListener(
|
link.addEventListener(
|
||||||
@ -61,8 +77,8 @@ export const EmbeddedStatusContent: React.FC<{
|
|||||||
handleMentionClick.bind(null, history, mention),
|
handleMentionClick.bind(null, history, mention),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
link.setAttribute('title', `@${mention.acct}`);
|
||||||
link.setAttribute('href', `/@${mention.get('acct')}`);
|
link.setAttribute('href', `/@${mention.acct}`);
|
||||||
} else if (
|
} else if (
|
||||||
link.textContent.startsWith('#') ||
|
link.textContent.startsWith('#') ||
|
||||||
link.previousSibling?.textContent?.endsWith('#')
|
link.previousSibling?.textContent?.endsWith('#')
|
||||||
@ -83,11 +99,12 @@ export const EmbeddedStatusContent: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<EmojiHTML
|
||||||
|
{...htmlHandlers}
|
||||||
className={className}
|
className={className}
|
||||||
ref={handleContentRef}
|
ref={handleContentRef}
|
||||||
lang={language}
|
lang={status.get('language') as string}
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
htmlString={status.get('contentHtml') as string}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -429,17 +429,13 @@ export const DetailedStatus: React.FC<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.get('spoiler_text').length > 0 &&
|
{(!matchedFilters || showDespiteFilter) && (
|
||||||
(!matchedFilters || showDespiteFilter) && (
|
<ContentWarning
|
||||||
<ContentWarning
|
status={status}
|
||||||
text={
|
expanded={expanded}
|
||||||
status.getIn(['translation', 'spoilerHtml']) ||
|
onClick={handleExpandedToggle}
|
||||||
status.get('spoilerHtml')
|
/>
|
||||||
}
|
)}
|
||||||
expanded={expanded}
|
|
||||||
onClick={handleExpandedToggle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useIntl, defineMessages } from 'react-intl';
|
|||||||
import {
|
import {
|
||||||
fetchContext,
|
fetchContext,
|
||||||
completeContextRefresh,
|
completeContextRefresh,
|
||||||
|
showPendingReplies,
|
||||||
|
clearPendingReplies,
|
||||||
} from 'flavours/glitch/actions/statuses';
|
} from 'flavours/glitch/actions/statuses';
|
||||||
import type { AsyncRefreshHeader } from 'flavours/glitch/api';
|
import type { AsyncRefreshHeader } from 'flavours/glitch/api';
|
||||||
import { apiGetAsyncRefresh } from 'flavours/glitch/api/async_refreshes';
|
import { apiGetAsyncRefresh } from 'flavours/glitch/api/async_refreshes';
|
||||||
@ -34,10 +36,6 @@ const messages = defineMessages({
|
|||||||
id: 'status.context.loading',
|
id: 'status.context.loading',
|
||||||
defaultMessage: 'Loading',
|
defaultMessage: 'Loading',
|
||||||
},
|
},
|
||||||
loadingMore: {
|
|
||||||
id: 'status.context.loading_more',
|
|
||||||
defaultMessage: 'Loading more replies',
|
|
||||||
},
|
|
||||||
success: {
|
success: {
|
||||||
id: 'status.context.loading_success',
|
id: 'status.context.loading_success',
|
||||||
defaultMessage: 'All replies loaded',
|
defaultMessage: 'All replies loaded',
|
||||||
@ -52,36 +50,33 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoadingState =
|
type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error';
|
||||||
| '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(
|
|
||||||
(state) => state.contexts.refreshing[statusId],
|
|
||||||
);
|
|
||||||
const currentReplyCount = useAppSelector(
|
|
||||||
(state) => state.contexts.replies[statusId]?.length ?? 0,
|
|
||||||
);
|
|
||||||
const autoRefresh = !currentReplyCount;
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>(
|
const refreshHeader = useAppSelector(
|
||||||
refresh && autoRefresh ? 'loading-initial' : 'idle',
|
(state) => state.contexts.refreshing[statusId],
|
||||||
);
|
);
|
||||||
|
const hasPendingReplies = useAppSelector(
|
||||||
|
(state) => !!state.contexts.pendingReplies[statusId]?.length,
|
||||||
|
);
|
||||||
|
const [partialLoadingState, setLoadingState] = useState<LoadingState>(
|
||||||
|
refreshHeader ? 'loading' : 'idle',
|
||||||
|
);
|
||||||
|
const loadingState = hasPendingReplies
|
||||||
|
? 'more-available'
|
||||||
|
: partialLoadingState;
|
||||||
|
|
||||||
const [wasDismissed, setWasDismissed] = useState(false);
|
const [wasDismissed, setWasDismissed] = useState(false);
|
||||||
const dismissPrompt = useCallback(() => {
|
const dismissPrompt = useCallback(() => {
|
||||||
setWasDismissed(true);
|
setWasDismissed(true);
|
||||||
setLoadingState('idle');
|
setLoadingState('idle');
|
||||||
}, []);
|
dispatch(clearPendingReplies({ statusId }));
|
||||||
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
@ -89,36 +84,51 @@ export const RefreshController: React.FC<{
|
|||||||
const scheduleRefresh = (refresh: AsyncRefreshHeader) => {
|
const scheduleRefresh = (refresh: AsyncRefreshHeader) => {
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
||||||
if (result.async_refresh.status === 'finished') {
|
// If the refresh status is not finished,
|
||||||
dispatch(completeContextRefresh({ statusId }));
|
// schedule another refresh and exit
|
||||||
|
if (result.async_refresh.status !== 'finished') {
|
||||||
if (result.async_refresh.result_count > 0) {
|
|
||||||
if (autoRefresh) {
|
|
||||||
void dispatch(fetchContext({ statusId })).then(() => {
|
|
||||||
setLoadingState('idle');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setLoadingState('more-available');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setLoadingState('idle');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scheduleRefresh(refresh);
|
scheduleRefresh(refresh);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh status is finished. The action below will clear `refreshHeader`
|
||||||
|
dispatch(completeContextRefresh({ statusId }));
|
||||||
|
|
||||||
|
// Exit if there's nothing to fetch
|
||||||
|
if (result.async_refresh.result_count === 0) {
|
||||||
|
setLoadingState('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A positive result count means there _might_ be new replies,
|
||||||
|
// so we fetch the context in the background to check if there
|
||||||
|
// are any new replies.
|
||||||
|
// If so, they will populate `contexts.pendingReplies[statusId]`
|
||||||
|
void dispatch(fetchContext({ statusId, prefetchOnly: true }))
|
||||||
|
.then(() => {
|
||||||
|
// Reset loading state to `idle` – but if the fetch
|
||||||
|
// has resulted in new pending replies, the `hasPendingReplies`
|
||||||
|
// flag will switch the loading state to 'more-available'
|
||||||
|
setLoadingState('idle');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Show an error if the fetch failed
|
||||||
|
setLoadingState('error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, refresh.retry * 1000);
|
}, refresh.retry * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (refresh && !wasDismissed) {
|
// Initialise a refresh
|
||||||
scheduleRefresh(refresh);
|
if (refreshHeader && !wasDismissed) {
|
||||||
setLoadingState('loading-initial');
|
scheduleRefresh(refreshHeader);
|
||||||
|
setLoadingState('loading');
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [dispatch, statusId, refresh, autoRefresh, wasDismissed]);
|
}, [dispatch, statusId, refreshHeader, wasDismissed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hide success message after a short delay
|
// Hide success message after a short delay
|
||||||
@ -134,20 +144,19 @@ export const RefreshController: React.FC<{
|
|||||||
return () => '';
|
return () => '';
|
||||||
}, [loadingState]);
|
}, [loadingState]);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
useEffect(() => {
|
||||||
setLoadingState('loading-more');
|
// Clear pending replies on unmount
|
||||||
|
return () => {
|
||||||
dispatch(fetchContext({ statusId }))
|
dispatch(clearPendingReplies({ statusId }));
|
||||||
.then(() => {
|
};
|
||||||
setLoadingState('success');
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLoadingState('error');
|
|
||||||
});
|
|
||||||
}, [dispatch, statusId]);
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
if (loadingState === 'loading-initial') {
|
const handleClick = useCallback(() => {
|
||||||
|
dispatch(showPendingReplies({ statusId }));
|
||||||
|
setLoadingState('success');
|
||||||
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
|
if (loadingState === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='load-more load-gap'
|
className='load-more load-gap'
|
||||||
@ -170,13 +179,6 @@ export const RefreshController: React.FC<{
|
|||||||
onDismiss={dismissPrompt}
|
onDismiss={dismissPrompt}
|
||||||
animateFrom='below'
|
animateFrom='below'
|
||||||
/>
|
/>
|
||||||
<AnimatedAlert
|
|
||||||
isLoading
|
|
||||||
withEntryDelay
|
|
||||||
isActive={loadingState === 'loading-more'}
|
|
||||||
message={intl.formatMessage(messages.loadingMore)}
|
|
||||||
animateFrom='below'
|
|
||||||
/>
|
|
||||||
<AnimatedAlert
|
<AnimatedAlert
|
||||||
withEntryDelay
|
withEntryDelay
|
||||||
isActive={loadingState === 'error'}
|
isActive={loadingState === 'error'}
|
||||||
|
|||||||
@ -635,7 +635,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll} childRef={this.setContainerRef}>
|
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll} childRef={this.setContainerRef}>
|
||||||
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
<div className={classNames('item-list scrollable scrollable--flex', { fullscreen })} ref={this.setContainerRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<Hotkeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import InlineAccount from 'flavours/glitch/components/inline_account';
|
|||||||
import MediaAttachments from 'flavours/glitch/components/media_attachments';
|
import MediaAttachments from 'flavours/glitch/components/media_attachments';
|
||||||
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
import { RelativeTimestamp } from 'flavours/glitch/components/relative_timestamp';
|
||||||
import emojify from 'flavours/glitch/features/emoji/emoji';
|
import emojify from 'flavours/glitch/features/emoji/emoji';
|
||||||
|
import { EmojiHTML } from '@/flavours/glitch/components/emoji/html';
|
||||||
|
import { CustomEmojiProvider } from '@/flavours/glitch/components/emoji/context';
|
||||||
|
|
||||||
const mapStateToProps = (state, { statusId }) => ({
|
const mapStateToProps = (state, { statusId }) => ({
|
||||||
language: state.getIn(['statuses', statusId, 'language']),
|
language: state.getIn(['statuses', statusId, 'language']),
|
||||||
@ -51,8 +53,8 @@ class CompareHistoryModal extends PureComponent {
|
|||||||
return obj;
|
return obj;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const content = { __html: emojify(currentVersion.get('content'), emojiMap) };
|
const content = emojify(currentVersion.get('content'), emojiMap);
|
||||||
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) };
|
const spoilerContent = emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap);
|
||||||
|
|
||||||
const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
|
const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
|
||||||
const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
|
const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
|
||||||
@ -65,43 +67,50 @@ class CompareHistoryModal extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal compare-history-modal'>
|
<div className='modal-root__modal compare-history-modal'>
|
||||||
<div className='report-modal__target'>
|
<CustomEmojiProvider emojis={currentVersion.get('emojis')}>
|
||||||
<IconButton className='report-modal__close' icon='times' iconComponent={CloseIcon} onClick={onClose} size={20} />
|
<div className='report-modal__target'>
|
||||||
{label}
|
<IconButton className='report-modal__close' icon='times' iconComponent={CloseIcon} onClick={onClose} size={20} />
|
||||||
</div>
|
{label}
|
||||||
|
|
||||||
<div className='compare-history-modal__container'>
|
|
||||||
<div className='status__content'>
|
|
||||||
{currentVersion.get('spoiler_text').length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className='translate' dangerouslySetInnerHTML={spoilerContent} lang={language} />
|
|
||||||
<hr />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} lang={language} />
|
|
||||||
|
|
||||||
{!!currentVersion.get('poll') && (
|
|
||||||
<div className='poll'>
|
|
||||||
<ul>
|
|
||||||
{currentVersion.getIn(['poll', 'options']).map(option => (
|
|
||||||
<li key={option.get('title')}>
|
|
||||||
<span className='poll__input disabled' />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className='poll__option__text translate'
|
|
||||||
dangerouslySetInnerHTML={{ __html: emojify(escapeTextContentForBrowser(option.get('title')), emojiMap) }}
|
|
||||||
lang={language}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MediaAttachments status={currentVersion} lang={language} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className='compare-history-modal__container'>
|
||||||
|
<div className='status__content'>
|
||||||
|
{currentVersion.get('spoiler_text').length > 0 && (
|
||||||
|
<>
|
||||||
|
<EmojiHTML className='translate' htmlString={spoilerContent} lang={language} />
|
||||||
|
<hr />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmojiHTML
|
||||||
|
className='status__content__text status__content__text--visible translate'
|
||||||
|
htmlString={content}
|
||||||
|
lang={language}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!currentVersion.get('poll') && (
|
||||||
|
<div className='poll'>
|
||||||
|
<ul>
|
||||||
|
{currentVersion.getIn(['poll', 'options']).map(option => (
|
||||||
|
<li key={option.get('title')}>
|
||||||
|
<span className='poll__input disabled' />
|
||||||
|
|
||||||
|
<EmojiHTML
|
||||||
|
as="span"
|
||||||
|
className='poll__option__text translate'
|
||||||
|
htmlString={emojify(escapeTextContentForBrowser(option.get('title')), emojiMap)}
|
||||||
|
lang={language}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MediaAttachments status={currentVersion} lang={language} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomEmojiProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
|||||||
import { openURL } from 'flavours/glitch/actions/search';
|
import { openURL } from 'flavours/glitch/actions/search';
|
||||||
import { useAppDispatch } from 'flavours/glitch/store';
|
import { useAppDispatch } from 'flavours/glitch/store';
|
||||||
|
|
||||||
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
const isMentionClick = (element: HTMLAnchorElement) =>
|
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||||
element.classList.contains('mention') &&
|
element.classList.contains('mention') &&
|
||||||
!element.classList.contains('hashtag');
|
!element.classList.contains('hashtag');
|
||||||
@ -53,6 +55,11 @@ export const useLinks = (skipHashtags?: boolean) => {
|
|||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
// Exit early if modern emoji is enabled, as this is handled by HandledLink.
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const target = (e.target as HTMLElement).closest('a');
|
const target = (e.target as HTMLElement).closest('a');
|
||||||
|
|
||||||
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export function loadPolyfills() {
|
|||||||
return Promise.all([
|
return Promise.all([
|
||||||
loadIntlPolyfills(),
|
loadIntlPolyfills(),
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- those properties might not exist in old browsers, even if they are always here in types
|
||||||
needsExtraPolyfills && importExtraPolyfills(),
|
needsExtraPolyfills ? importExtraPolyfills() : Promise.resolve(),
|
||||||
loadEmojiPolyfills(),
|
loadEmojiPolyfills(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,12 @@ import type {
|
|||||||
import type { Status } from 'flavours/glitch/models/status';
|
import type { Status } from 'flavours/glitch/models/status';
|
||||||
|
|
||||||
import { blockAccountSuccess, muteAccountSuccess } from '../actions/accounts';
|
import { blockAccountSuccess, muteAccountSuccess } from '../actions/accounts';
|
||||||
import { fetchContext, completeContextRefresh } from '../actions/statuses';
|
import {
|
||||||
|
fetchContext,
|
||||||
|
completeContextRefresh,
|
||||||
|
showPendingReplies,
|
||||||
|
clearPendingReplies,
|
||||||
|
} from '../actions/statuses';
|
||||||
import { TIMELINE_UPDATE } from '../actions/timelines';
|
import { TIMELINE_UPDATE } from '../actions/timelines';
|
||||||
import { compareId } from '../compare_id';
|
import { compareId } from '../compare_id';
|
||||||
|
|
||||||
@ -26,52 +31,84 @@ interface TimelineUpdateAction extends UnknownAction {
|
|||||||
interface State {
|
interface State {
|
||||||
inReplyTos: Record<string, string>;
|
inReplyTos: Record<string, string>;
|
||||||
replies: Record<string, string[]>;
|
replies: Record<string, string[]>;
|
||||||
|
pendingReplies: Record<
|
||||||
|
string,
|
||||||
|
Pick<ApiStatusJSON, 'id' | 'in_reply_to_id'>[]
|
||||||
|
>;
|
||||||
refreshing: Record<string, AsyncRefreshHeader>;
|
refreshing: Record<string, AsyncRefreshHeader>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: State = {
|
const initialState: State = {
|
||||||
inReplyTos: {},
|
inReplyTos: {},
|
||||||
replies: {},
|
replies: {},
|
||||||
|
pendingReplies: {},
|
||||||
refreshing: {},
|
refreshing: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addReply = (
|
||||||
|
state: Draft<State>,
|
||||||
|
{ id, in_reply_to_id }: Pick<ApiStatusJSON, 'id' | 'in_reply_to_id'>,
|
||||||
|
) => {
|
||||||
|
if (!in_reply_to_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.inReplyTos[id]) {
|
||||||
|
const siblings = (state.replies[in_reply_to_id] ??= []);
|
||||||
|
const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0);
|
||||||
|
siblings.splice(index + 1, 0, id);
|
||||||
|
state.inReplyTos[id] = in_reply_to_id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeContext = (
|
const normalizeContext = (
|
||||||
state: Draft<State>,
|
state: Draft<State>,
|
||||||
id: string,
|
id: string,
|
||||||
{ ancestors, descendants }: ApiContextJSON,
|
{ ancestors, descendants }: ApiContextJSON,
|
||||||
): void => {
|
): void => {
|
||||||
const addReply = ({
|
ancestors.forEach((item) => {
|
||||||
id,
|
addReply(state, item);
|
||||||
in_reply_to_id,
|
});
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
in_reply_to_id?: string;
|
|
||||||
}) => {
|
|
||||||
if (!in_reply_to_id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!state.inReplyTos[id]) {
|
|
||||||
const siblings = (state.replies[in_reply_to_id] ??= []);
|
|
||||||
const index = siblings.findIndex((sibling) => compareId(sibling, id) < 0);
|
|
||||||
siblings.splice(index + 1, 0, id);
|
|
||||||
state.inReplyTos[id] = in_reply_to_id;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// We know in_reply_to_id of statuses but `id` itself.
|
// We know in_reply_to_id of statuses but `id` itself.
|
||||||
// So we assume that the status of the id replies to last ancestors.
|
// So we assume that the status of the id replies to last ancestors.
|
||||||
|
|
||||||
ancestors.forEach(addReply);
|
|
||||||
|
|
||||||
if (ancestors[0]) {
|
if (ancestors[0]) {
|
||||||
addReply({
|
addReply(state, {
|
||||||
id,
|
id,
|
||||||
in_reply_to_id: ancestors[ancestors.length - 1]?.id,
|
in_reply_to_id: ancestors[ancestors.length - 1]?.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
descendants.forEach(addReply);
|
descendants.forEach((item) => {
|
||||||
|
addReply(state, item);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPrefetchedReplies = (state: Draft<State>, statusId: string) => {
|
||||||
|
const pendingReplies = state.pendingReplies[statusId];
|
||||||
|
if (pendingReplies?.length) {
|
||||||
|
pendingReplies.forEach((item) => {
|
||||||
|
addReply(state, item);
|
||||||
|
});
|
||||||
|
delete state.pendingReplies[statusId];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const storePrefetchedReplies = (
|
||||||
|
state: Draft<State>,
|
||||||
|
statusId: string,
|
||||||
|
{ descendants }: ApiContextJSON,
|
||||||
|
): void => {
|
||||||
|
descendants.forEach(({ id, in_reply_to_id }) => {
|
||||||
|
if (!in_reply_to_id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isNewReply = !state.replies[in_reply_to_id]?.includes(id);
|
||||||
|
if (isNewReply) {
|
||||||
|
const pendingReplies = (state.pendingReplies[statusId] ??= []);
|
||||||
|
pendingReplies.push({ id, in_reply_to_id });
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteFromContexts = (state: Draft<State>, ids: string[]): void => {
|
const deleteFromContexts = (state: Draft<State>, ids: string[]): void => {
|
||||||
@ -129,12 +166,30 @@ const updateContext = (state: Draft<State>, status: ApiStatusJSON): void => {
|
|||||||
export const contextsReducer = createReducer(initialState, (builder) => {
|
export const contextsReducer = createReducer(initialState, (builder) => {
|
||||||
builder
|
builder
|
||||||
.addCase(fetchContext.fulfilled, (state, action) => {
|
.addCase(fetchContext.fulfilled, (state, action) => {
|
||||||
normalizeContext(state, action.meta.arg.statusId, action.payload.context);
|
if (action.payload.prefetchOnly) {
|
||||||
|
storePrefetchedReplies(
|
||||||
|
state,
|
||||||
|
action.meta.arg.statusId,
|
||||||
|
action.payload.context,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
normalizeContext(
|
||||||
|
state,
|
||||||
|
action.meta.arg.statusId,
|
||||||
|
action.payload.context,
|
||||||
|
);
|
||||||
|
|
||||||
if (action.payload.refresh) {
|
if (action.payload.refresh) {
|
||||||
state.refreshing[action.meta.arg.statusId] = action.payload.refresh;
|
state.refreshing[action.meta.arg.statusId] = action.payload.refresh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
.addCase(showPendingReplies, (state, action) => {
|
||||||
|
applyPrefetchedReplies(state, action.payload.statusId);
|
||||||
|
})
|
||||||
|
.addCase(clearPendingReplies, (state, action) => {
|
||||||
|
delete state.pendingReplies[action.payload.statusId];
|
||||||
|
})
|
||||||
.addCase(completeContextRefresh, (state, action) => {
|
.addCase(completeContextRefresh, (state, action) => {
|
||||||
delete state.refreshing[action.payload.statusId];
|
delete state.refreshing[action.payload.statusId];
|
||||||
})
|
})
|
||||||
|
|||||||
@ -41,7 +41,7 @@ const popModal = (
|
|||||||
modalType === state.get('stack').get(0)?.get('modalType')
|
modalType === state.get('stack').get(0)?.get('modalType')
|
||||||
) {
|
) {
|
||||||
return state
|
return state
|
||||||
.set('ignoreFocus', !!ignoreFocus)
|
.set('ignoreFocus', ignoreFocus)
|
||||||
.update('stack', (stack) => stack.shift());
|
.update('stack', (stack) => stack.shift());
|
||||||
} else {
|
} else {
|
||||||
return state;
|
return state;
|
||||||
|
|||||||
@ -3218,18 +3218,23 @@ a.account__display-name {
|
|||||||
|
|
||||||
.column__alert {
|
.column__alert {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 1rem;
|
bottom: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 360px;
|
max-width: 360px;
|
||||||
padding-inline: 10px;
|
padding: 1rem;
|
||||||
margin-top: 1rem;
|
margin: auto auto 0;
|
||||||
margin-inline: auto;
|
overflow: clip;
|
||||||
|
|
||||||
|
&:empty {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: #{$mobile-menu-breakpoint - 1}) {
|
@media (max-width: #{$mobile-menu-breakpoint - 1}) {
|
||||||
bottom: 4rem;
|
// Compensate for mobile menubar
|
||||||
|
bottom: var(--mobile-bottom-nav-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
|
|||||||
@ -20,10 +20,7 @@ export function isFeatureEnabled(feature: Features) {
|
|||||||
|
|
||||||
export function isModernEmojiEnabled() {
|
export function isModernEmojiEnabled() {
|
||||||
try {
|
try {
|
||||||
return (
|
return isFeatureEnabled('modern_emojis');
|
||||||
isFeatureEnabled('modern_emojis') &&
|
|
||||||
localStorage.getItem('experiments')?.split(',').includes('modern_emojis')
|
|
||||||
);
|
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,20 +32,31 @@ interface QueueItem {
|
|||||||
depth: number;
|
depth: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HTMLToStringOptions<Arg extends Record<string, unknown>> {
|
export type OnElementHandler<
|
||||||
|
Arg extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
> = (
|
||||||
|
element: HTMLElement,
|
||||||
|
props: Record<string, unknown>,
|
||||||
|
children: React.ReactNode[],
|
||||||
|
extra: Arg,
|
||||||
|
) => React.ReactNode;
|
||||||
|
|
||||||
|
export type OnAttributeHandler<
|
||||||
|
Arg extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
> = (
|
||||||
|
name: string,
|
||||||
|
value: string,
|
||||||
|
tagName: string,
|
||||||
|
extra: Arg,
|
||||||
|
) => [string, unknown] | undefined | null;
|
||||||
|
|
||||||
|
export interface HTMLToStringOptions<
|
||||||
|
Arg extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
> {
|
||||||
maxDepth?: number;
|
maxDepth?: number;
|
||||||
onText?: (text: string, extra: Arg) => React.ReactNode;
|
onText?: (text: string, extra: Arg) => React.ReactNode;
|
||||||
onElement?: (
|
onElement?: OnElementHandler<Arg>;
|
||||||
element: HTMLElement,
|
onAttribute?: OnAttributeHandler<Arg>;
|
||||||
children: React.ReactNode[],
|
|
||||||
extra: Arg,
|
|
||||||
) => React.ReactNode;
|
|
||||||
onAttribute?: (
|
|
||||||
name: string,
|
|
||||||
value: string,
|
|
||||||
tagName: string,
|
|
||||||
extra: Arg,
|
|
||||||
) => [string, unknown] | null;
|
|
||||||
allowedTags?: AllowedTagsType;
|
allowedTags?: AllowedTagsType;
|
||||||
extraArgs?: Arg;
|
extraArgs?: Arg;
|
||||||
}
|
}
|
||||||
@ -125,9 +136,57 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
|
|||||||
const children: React.ReactNode[] = [];
|
const children: React.ReactNode[] = [];
|
||||||
let element: React.ReactNode = undefined;
|
let element: React.ReactNode = undefined;
|
||||||
|
|
||||||
|
// Generate props from attributes.
|
||||||
|
const key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
|
||||||
|
const props: Record<string, unknown> = { key };
|
||||||
|
for (const attr of node.attributes) {
|
||||||
|
let name = attr.name.toLowerCase();
|
||||||
|
|
||||||
|
// Custom attribute handler.
|
||||||
|
if (onAttribute) {
|
||||||
|
const result = onAttribute(name, attr.value, tagName, extraArgs);
|
||||||
|
// Rewrite this attribute.
|
||||||
|
if (result) {
|
||||||
|
const [cbName, value] = result;
|
||||||
|
props[cbName] = value;
|
||||||
|
continue;
|
||||||
|
} else if (result === null) {
|
||||||
|
// Explicitly remove this attribute.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check global attributes first, then tag-specific ones.
|
||||||
|
const globalAttr = globalAttributes[name];
|
||||||
|
const tagAttr = tagInfo.attributes?.[name];
|
||||||
|
|
||||||
|
// Exit if neither global nor tag-specific attribute is allowed.
|
||||||
|
if (!globalAttr && !tagAttr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename if needed.
|
||||||
|
if (typeof tagAttr === 'string') {
|
||||||
|
name = tagAttr;
|
||||||
|
} else if (typeof globalAttr === 'string') {
|
||||||
|
name = globalAttr;
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: string | boolean | number = attr.value;
|
||||||
|
|
||||||
|
// Handle boolean attributes.
|
||||||
|
if (value === 'true') {
|
||||||
|
value = true;
|
||||||
|
} else if (value === 'false') {
|
||||||
|
value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
props[name] = value;
|
||||||
|
}
|
||||||
|
|
||||||
// If onElement is provided, use it to create the element.
|
// If onElement is provided, use it to create the element.
|
||||||
if (onElement) {
|
if (onElement) {
|
||||||
const component = onElement(node, children, extraArgs);
|
const component = onElement(node, props, children, extraArgs);
|
||||||
|
|
||||||
// Check for undefined to allow returning null.
|
// Check for undefined to allow returning null.
|
||||||
if (component !== undefined) {
|
if (component !== undefined) {
|
||||||
@ -137,53 +196,6 @@ export function htmlStringToComponents<Arg extends Record<string, unknown>>(
|
|||||||
|
|
||||||
// If the element wasn't created, use the default conversion.
|
// If the element wasn't created, use the default conversion.
|
||||||
if (element === undefined) {
|
if (element === undefined) {
|
||||||
const props: Record<string, unknown> = {};
|
|
||||||
props.key = `html-${uniqueIdCounter++}`; // Get the current key and then increment it.
|
|
||||||
for (const attr of node.attributes) {
|
|
||||||
let name = attr.name.toLowerCase();
|
|
||||||
|
|
||||||
// Custom attribute handler.
|
|
||||||
if (onAttribute) {
|
|
||||||
const result = onAttribute(
|
|
||||||
name,
|
|
||||||
attr.value,
|
|
||||||
node.tagName.toLowerCase(),
|
|
||||||
extraArgs,
|
|
||||||
);
|
|
||||||
if (result) {
|
|
||||||
const [cbName, value] = result;
|
|
||||||
props[cbName] = value;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Check global attributes first, then tag-specific ones.
|
|
||||||
const globalAttr = globalAttributes[name];
|
|
||||||
const tagAttr = tagInfo.attributes?.[name];
|
|
||||||
|
|
||||||
// Exit if neither global nor tag-specific attribute is allowed.
|
|
||||||
if (!globalAttr && !tagAttr) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename if needed.
|
|
||||||
if (typeof tagAttr === 'string') {
|
|
||||||
name = tagAttr;
|
|
||||||
} else if (typeof globalAttr === 'string') {
|
|
||||||
name = globalAttr;
|
|
||||||
}
|
|
||||||
|
|
||||||
let value: string | boolean | number = attr.value;
|
|
||||||
|
|
||||||
// Handle boolean attributes.
|
|
||||||
if (value === 'true') {
|
|
||||||
value = true;
|
|
||||||
} else if (value === 'false') {
|
|
||||||
value = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
props[name] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
element = React.createElement(
|
element = React.createElement(
|
||||||
tagName,
|
tagName,
|
||||||
props,
|
props,
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { createAction } from '@reduxjs/toolkit';
|
|||||||
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
|
||||||
|
|
||||||
import { apiUpdateMedia } from 'mastodon/api/compose';
|
import { apiUpdateMedia } from 'mastodon/api/compose';
|
||||||
|
import { apiGetSearch } from 'mastodon/api/search';
|
||||||
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
import type { ApiMediaAttachmentJSON } from 'mastodon/api_types/media_attachments';
|
||||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||||
import {
|
import {
|
||||||
@ -16,6 +17,7 @@ import type { Status } from '../models/status';
|
|||||||
|
|
||||||
import { showAlert } from './alerts';
|
import { showAlert } from './alerts';
|
||||||
import { focusCompose } from './compose';
|
import { focusCompose } from './compose';
|
||||||
|
import { importFetchedStatuses } from './importer';
|
||||||
import { openModal } from './modal';
|
import { openModal } from './modal';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
@ -165,6 +167,41 @@ export const quoteComposeById = createAppThunk(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const pasteLinkCompose = createDataLoadingThunk(
|
||||||
|
'compose/pasteLink',
|
||||||
|
async ({ url }: { url: string }) => {
|
||||||
|
return await apiGetSearch({
|
||||||
|
q: url,
|
||||||
|
type: 'statuses',
|
||||||
|
resolve: true,
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(data, { dispatch, getState }) => {
|
||||||
|
const composeState = getState().compose;
|
||||||
|
|
||||||
|
if (
|
||||||
|
composeState.get('quoted_status_id') ||
|
||||||
|
composeState.get('is_submitting') ||
|
||||||
|
composeState.get('poll') ||
|
||||||
|
composeState.get('is_uploading')
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
dispatch(importFetchedStatuses(data.statuses));
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.statuses.length === 1 &&
|
||||||
|
data.statuses[0] &&
|
||||||
|
['automatic', 'manual'].includes(
|
||||||
|
data.statuses[0].quote_approval?.current_user ?? 'denied',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
dispatch(quoteComposeById(data.statuses[0].id));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
export const quoteComposeCancel = createAction('compose/quoteComposeCancel');
|
||||||
|
|
||||||
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
export const setComposeQuotePolicy = createAction<ApiQuotePolicy>(
|
||||||
|
|||||||
@ -9,8 +9,9 @@ import { importFetchedStatuses } from './importer';
|
|||||||
|
|
||||||
export const fetchContext = createDataLoadingThunk(
|
export const fetchContext = createDataLoadingThunk(
|
||||||
'status/context',
|
'status/context',
|
||||||
({ statusId }: { statusId: string }) => apiGetContext(statusId),
|
({ statusId }: { statusId: string; prefetchOnly?: boolean }) =>
|
||||||
({ context, refresh }, { dispatch }) => {
|
apiGetContext(statusId),
|
||||||
|
({ context, refresh }, { dispatch, actionArg: { prefetchOnly = false } }) => {
|
||||||
const statuses = context.ancestors.concat(context.descendants);
|
const statuses = context.ancestors.concat(context.descendants);
|
||||||
|
|
||||||
dispatch(importFetchedStatuses(statuses));
|
dispatch(importFetchedStatuses(statuses));
|
||||||
@ -18,6 +19,7 @@ export const fetchContext = createDataLoadingThunk(
|
|||||||
return {
|
return {
|
||||||
context,
|
context,
|
||||||
refresh,
|
refresh,
|
||||||
|
prefetchOnly,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -26,6 +28,14 @@ export const completeContextRefresh = createAction<{ statusId: string }>(
|
|||||||
'status/context/complete',
|
'status/context/complete',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const showPendingReplies = createAction<{ statusId: string }>(
|
||||||
|
'status/context/showPendingReplies',
|
||||||
|
);
|
||||||
|
|
||||||
|
export const clearPendingReplies = createAction<{ statusId: string }>(
|
||||||
|
'status/context/clearPendingReplies',
|
||||||
|
);
|
||||||
|
|
||||||
export const setStatusQuotePolicy = createDataLoadingThunk(
|
export const setStatusQuotePolicy = createDataLoadingThunk(
|
||||||
'status/setQuotePolicy',
|
'status/setQuotePolicy',
|
||||||
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
|
({ statusId, policy }: { statusId: string; policy: ApiQuotePolicy }) => {
|
||||||
|
|||||||
28
app/javascript/mastodon/api_types/announcements.ts
Normal file
28
app/javascript/mastodon/api_types/announcements.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
// See app/serializers/rest/announcement_serializer.rb
|
||||||
|
|
||||||
|
import type { ApiCustomEmojiJSON } from './custom_emoji';
|
||||||
|
import type { ApiMentionJSON, ApiStatusJSON, ApiTagJSON } from './statuses';
|
||||||
|
|
||||||
|
export interface ApiAnnouncementJSON {
|
||||||
|
id: string;
|
||||||
|
content: string;
|
||||||
|
starts_at: null | string;
|
||||||
|
ends_at: null | string;
|
||||||
|
all_day: boolean;
|
||||||
|
published_at: string;
|
||||||
|
updated_at: null | string;
|
||||||
|
read: boolean;
|
||||||
|
mentions: ApiMentionJSON[];
|
||||||
|
statuses: ApiStatusJSON[];
|
||||||
|
tags: ApiTagJSON[];
|
||||||
|
emojis: ApiCustomEmojiJSON[];
|
||||||
|
reactions: ApiAnnouncementReactionJSON[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiAnnouncementReactionJSON {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
me: boolean;
|
||||||
|
url?: string;
|
||||||
|
static_url?: string;
|
||||||
|
}
|
||||||
@ -5,6 +5,7 @@ import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||||
import {
|
import {
|
||||||
blockAccount,
|
blockAccount,
|
||||||
@ -331,9 +332,10 @@ export const Account: React.FC<AccountProps> = ({
|
|||||||
{account &&
|
{account &&
|
||||||
withBio &&
|
withBio &&
|
||||||
(account.note.length > 0 ? (
|
(account.note.length > 0 ? (
|
||||||
<div
|
<EmojiHTML
|
||||||
className='account__note translate'
|
className='account__note translate'
|
||||||
dangerouslySetInnerHTML={{ __html: account.note_emojified }}
|
htmlString={account.note_emojified}
|
||||||
|
extraEmojis={account.emojis}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className='account__note account__note--missing'>
|
<div className='account__note account__note--missing'>
|
||||||
|
|||||||
@ -7,8 +7,8 @@ import { useLinks } from 'mastodon/hooks/useLinks';
|
|||||||
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';
|
import { EmojiHTML } from './emoji/html';
|
||||||
|
import { useElementHandledLink } from './status/handled_link';
|
||||||
|
|
||||||
interface AccountBioProps {
|
interface AccountBioProps {
|
||||||
className: string;
|
className: string;
|
||||||
@ -24,19 +24,29 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||||||
const handleClick = useLinks(showDropdown);
|
const handleClick = useLinks(showDropdown);
|
||||||
const handleNodeChange = useCallback(
|
const handleNodeChange = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
if (!showDropdown || !node || node.childNodes.length === 0) {
|
if (
|
||||||
|
!showDropdown ||
|
||||||
|
!node ||
|
||||||
|
node.childNodes.length === 0 ||
|
||||||
|
isModernEmojiEnabled()
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
addDropdownToHashtags(node, accountId);
|
addDropdownToHashtags(node, accountId);
|
||||||
},
|
},
|
||||||
[showDropdown, accountId],
|
[showDropdown, accountId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const htmlHandlers = useElementHandledLink({
|
||||||
|
hashtagAccountId: showDropdown ? accountId : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
const note = useAppSelector((state) => {
|
const note = useAppSelector((state) => {
|
||||||
const account = state.accounts.get(accountId);
|
const account = state.accounts.get(accountId);
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return isModernEmojiEnabled() ? account.note : account.note_emojified;
|
return account.note_emojified;
|
||||||
});
|
});
|
||||||
const extraEmojis = useAppSelector((state) => {
|
const extraEmojis = useAppSelector((state) => {
|
||||||
const account = state.accounts.get(accountId);
|
const account = state.accounts.get(accountId);
|
||||||
@ -48,13 +58,14 @@ export const AccountBio: React.FC<AccountBioProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimateEmojiProvider
|
<EmojiHTML
|
||||||
|
htmlString={note}
|
||||||
|
extraEmojis={extraEmojis}
|
||||||
className={classNames(className, 'translate')}
|
className={classNames(className, 'translate')}
|
||||||
onClickCapture={handleClick}
|
onClickCapture={handleClick}
|
||||||
ref={handleNodeChange}
|
ref={handleNodeChange}
|
||||||
>
|
{...htmlHandlers}
|
||||||
<EmojiHTML htmlString={note} extraEmojis={extraEmojis} />
|
/>
|
||||||
</AnimateEmojiProvider>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -1,42 +1,70 @@
|
|||||||
|
import { useIntl } from 'react-intl';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { useLinks } from 'mastodon/hooks/useLinks';
|
|
||||||
import type { Account } from 'mastodon/models/account';
|
import type { Account } from 'mastodon/models/account';
|
||||||
|
|
||||||
export const AccountFields: React.FC<{
|
import { CustomEmojiProvider } from './emoji/context';
|
||||||
fields: Account['fields'];
|
import { EmojiHTML } from './emoji/html';
|
||||||
limit: number;
|
import { useElementHandledLink } from './status/handled_link';
|
||||||
}> = ({ fields, limit = -1 }) => {
|
|
||||||
const handleClick = useLinks();
|
export const AccountFields: React.FC<Pick<Account, 'fields' | 'emojis'>> = ({
|
||||||
|
fields,
|
||||||
|
emojis,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const htmlHandlers = useElementHandledLink();
|
||||||
|
|
||||||
if (fields.size === 0) {
|
if (fields.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-fields' onClickCapture={handleClick}>
|
<CustomEmojiProvider emojis={emojis}>
|
||||||
{fields.take(limit).map((pair, i) => (
|
{fields.map((pair, i) => (
|
||||||
<dl
|
<dl key={i} className={classNames({ verified: pair.verified_at })}>
|
||||||
key={i}
|
<EmojiHTML
|
||||||
className={classNames({ verified: pair.get('verified_at') })}
|
as='dt'
|
||||||
>
|
htmlString={pair.name_emojified}
|
||||||
<dt
|
|
||||||
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
|
|
||||||
className='translate'
|
className='translate'
|
||||||
|
{...htmlHandlers}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<dd className='translate' title={pair.get('value_plain') ?? ''}>
|
<dd className='translate' title={pair.value_plain ?? ''}>
|
||||||
{pair.get('verified_at') && (
|
{pair.verified_at && (
|
||||||
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
<span
|
||||||
)}
|
title={intl.formatMessage(
|
||||||
<span
|
{
|
||||||
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
|
id: 'account.link_verified_on',
|
||||||
|
defaultMessage:
|
||||||
|
'Ownership of this link was checked on {date}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
date: intl.formatDate(pair.verified_at, dateFormatOptions),
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon id='check' icon={CheckIcon} className='verified__mark' />
|
||||||
|
</span>
|
||||||
|
)}{' '}
|
||||||
|
<EmojiHTML
|
||||||
|
as='span'
|
||||||
|
htmlString={pair.value_emojified}
|
||||||
|
{...htmlHandlers}
|
||||||
/>
|
/>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
))}
|
))}
|
||||||
</div>
|
</CustomEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
};
|
||||||
|
|||||||
@ -150,10 +150,7 @@ const AutosuggestTextarea = forwardRef(({
|
|||||||
}, [suggestions, onSuggestionSelected, textareaRef]);
|
}, [suggestions, onSuggestionSelected, textareaRef]);
|
||||||
|
|
||||||
const handlePaste = useCallback((e) => {
|
const handlePaste = useCallback((e) => {
|
||||||
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
onPaste(e);
|
||||||
onPaste(e.clipboardData.files);
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}, [onPaste]);
|
}, [onPaste]);
|
||||||
|
|
||||||
// Show the suggestions again whenever they change and the textarea is focused
|
// Show the suggestions again whenever they change and the textarea is focused
|
||||||
|
|||||||
@ -1,15 +1,38 @@
|
|||||||
|
import type { List } from 'immutable';
|
||||||
|
|
||||||
|
import type { CustomEmoji } from '../models/custom_emoji';
|
||||||
|
import type { Status } from '../models/status';
|
||||||
|
|
||||||
|
import { EmojiHTML } from './emoji/html';
|
||||||
import { StatusBanner, BannerVariant } from './status_banner';
|
import { StatusBanner, BannerVariant } from './status_banner';
|
||||||
|
|
||||||
export const ContentWarning: React.FC<{
|
export const ContentWarning: React.FC<{
|
||||||
text: string;
|
status: Status;
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}> = ({ text, expanded, onClick }) => (
|
}> = ({ status, expanded, onClick }) => {
|
||||||
<StatusBanner
|
const hasSpoiler = !!status.get('spoiler_text');
|
||||||
expanded={expanded}
|
if (!hasSpoiler) {
|
||||||
onClick={onClick}
|
return null;
|
||||||
variant={BannerVariant.Warning}
|
}
|
||||||
>
|
|
||||||
<span dangerouslySetInnerHTML={{ __html: text }} />
|
const text =
|
||||||
</StatusBanner>
|
status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml');
|
||||||
);
|
if (typeof text !== 'string' || text.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StatusBanner
|
||||||
|
expanded={expanded}
|
||||||
|
onClick={onClick}
|
||||||
|
variant={BannerVariant.Warning}
|
||||||
|
>
|
||||||
|
<EmojiHTML
|
||||||
|
as='span'
|
||||||
|
htmlString={text}
|
||||||
|
extraEmojis={status.get('emoji') as List<CustomEmoji>}
|
||||||
|
/>
|
||||||
|
</StatusBanner>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import type { ComponentPropsWithoutRef, FC } from 'react';
|
|||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
|
||||||
|
|
||||||
import { AnimateEmojiProvider } from '../emoji/context';
|
import { AnimateEmojiProvider } from '../emoji/context';
|
||||||
import { EmojiHTML } from '../emoji/html';
|
import { EmojiHTML } from '../emoji/html';
|
||||||
import { Skeleton } from '../skeleton';
|
import { Skeleton } from '../skeleton';
|
||||||
@ -24,11 +22,7 @@ export const DisplayNameWithoutDomain: FC<
|
|||||||
{account ? (
|
{account ? (
|
||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
className='display-name__html'
|
className='display-name__html'
|
||||||
htmlString={
|
htmlString={account.get('display_name_html')}
|
||||||
isModernEmojiEnabled()
|
|
||||||
? account.get('display_name')
|
|
||||||
: account.get('display_name_html')
|
|
||||||
}
|
|
||||||
as='strong'
|
as='strong'
|
||||||
extraEmojis={account.get('emojis')}
|
extraEmojis={account.get('emojis')}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
import type { ComponentPropsWithoutRef, FC } from 'react';
|
import type { ComponentPropsWithoutRef, FC } from 'react';
|
||||||
|
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
|
||||||
|
|
||||||
import { EmojiHTML } from '../emoji/html';
|
import { EmojiHTML } from '../emoji/html';
|
||||||
|
|
||||||
import type { DisplayNameProps } from './index';
|
import type { DisplayNameProps } from './index';
|
||||||
@ -19,11 +17,7 @@ export const DisplayNameSimple: FC<
|
|||||||
<EmojiHTML
|
<EmojiHTML
|
||||||
{...props}
|
{...props}
|
||||||
as='span'
|
as='span'
|
||||||
htmlString={
|
htmlString={account.get('display_name_html')}
|
||||||
isModernEmojiEnabled()
|
|
||||||
? account.get('display_name')
|
|
||||||
: account.get('display_name_html')
|
|
||||||
}
|
|
||||||
extraEmojis={account.get('emojis')}
|
extraEmojis={account.get('emojis')}
|
||||||
/>
|
/>
|
||||||
</bdi>
|
</bdi>
|
||||||
|
|||||||
@ -1,60 +1,89 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } 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 type { CustomEmojiMapArg } from '@/mastodon/features/emoji/types';
|
||||||
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
import type {
|
||||||
|
OnAttributeHandler,
|
||||||
|
OnElementHandler,
|
||||||
|
} from '@/mastodon/utils/html';
|
||||||
import { htmlStringToComponents } from '@/mastodon/utils/html';
|
import { htmlStringToComponents } from '@/mastodon/utils/html';
|
||||||
|
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||||
|
|
||||||
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
import { AnimateEmojiProvider, CustomEmojiProvider } from './context';
|
||||||
import { textToEmojis } from './index';
|
import { textToEmojis } from './index';
|
||||||
|
|
||||||
type EmojiHTMLProps<Element extends ElementType = 'div'> = Omit<
|
interface EmojiHTMLProps {
|
||||||
ComponentPropsWithoutRef<Element>,
|
|
||||||
'dangerouslySetInnerHTML' | 'className'
|
|
||||||
> & {
|
|
||||||
htmlString: string;
|
htmlString: string;
|
||||||
extraEmojis?: CustomEmojiMapArg;
|
extraEmojis?: CustomEmojiMapArg;
|
||||||
as?: Element;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
onElement?: OnElementHandler;
|
||||||
|
onAttribute?: OnAttributeHandler;
|
||||||
|
}
|
||||||
|
|
||||||
export const ModernEmojiHTML = ({
|
export const ModernEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||||
extraEmojis,
|
(
|
||||||
htmlString,
|
{
|
||||||
as: asProp = 'div', // Rename for syntax highlighting
|
extraEmojis,
|
||||||
shallow,
|
htmlString,
|
||||||
className = '',
|
as: asProp = 'div', // Rename for syntax highlighting
|
||||||
...props
|
className = '',
|
||||||
}: EmojiHTMLProps<ElementType>) => {
|
onElement,
|
||||||
const contents = useMemo(
|
onAttribute,
|
||||||
() => htmlStringToComponents(htmlString, { onText: textToEmojis }),
|
...props
|
||||||
[htmlString],
|
},
|
||||||
);
|
ref,
|
||||||
|
) => {
|
||||||
|
const contents = useMemo(
|
||||||
|
() =>
|
||||||
|
htmlStringToComponents(htmlString, {
|
||||||
|
onText: textToEmojis,
|
||||||
|
onElement,
|
||||||
|
onAttribute,
|
||||||
|
}),
|
||||||
|
[htmlString, onAttribute, onElement],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CustomEmojiProvider emojis={extraEmojis}>
|
<CustomEmojiProvider emojis={extraEmojis}>
|
||||||
<AnimateEmojiProvider {...props} as={asProp} className={className}>
|
<AnimateEmojiProvider
|
||||||
{contents}
|
{...props}
|
||||||
</AnimateEmojiProvider>
|
as={asProp}
|
||||||
</CustomEmojiProvider>
|
className={className}
|
||||||
);
|
ref={ref}
|
||||||
};
|
>
|
||||||
|
{contents}
|
||||||
|
</AnimateEmojiProvider>
|
||||||
|
</CustomEmojiProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ModernEmojiHTML.displayName = 'ModernEmojiHTML';
|
||||||
|
|
||||||
export const LegacyEmojiHTML = <Element extends ElementType>(
|
export const LegacyEmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||||
props: EmojiHTMLProps<Element>,
|
(props, ref) => {
|
||||||
) => {
|
const {
|
||||||
const { as: asElement, htmlString, extraEmojis, className, ...rest } = props;
|
as: asElement,
|
||||||
const Wrapper = asElement ?? 'div';
|
htmlString,
|
||||||
return (
|
extraEmojis,
|
||||||
<Wrapper
|
className,
|
||||||
{...rest}
|
onElement,
|
||||||
dangerouslySetInnerHTML={{ __html: htmlString }}
|
onAttribute,
|
||||||
className={classNames(className, 'animate-parent')}
|
...rest
|
||||||
/>
|
} = props;
|
||||||
);
|
const Wrapper = asElement ?? 'div';
|
||||||
};
|
return (
|
||||||
|
<Wrapper
|
||||||
|
{...rest}
|
||||||
|
ref={ref}
|
||||||
|
dangerouslySetInnerHTML={{ __html: htmlString }}
|
||||||
|
className={classNames(className, 'animate-parent')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
LegacyEmojiHTML.displayName = 'LegacyEmojiHTML';
|
||||||
|
|
||||||
export const EmojiHTML = isModernEmojiEnabled()
|
export const EmojiHTML = isModernEmojiEnabled()
|
||||||
? ModernEmojiHTML
|
? ModernEmojiHTML
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import { domain } from 'mastodon/initial_state';
|
|||||||
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
import { getAccountHidden } from 'mastodon/selectors/accounts';
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { useLinks } from '../hooks/useLinks';
|
||||||
|
|
||||||
export const HoverCardAccount = forwardRef<
|
export const HoverCardAccount = forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{ accountId?: string }
|
{ accountId?: string }
|
||||||
@ -64,6 +66,8 @@ export const HoverCardAccount = forwardRef<
|
|||||||
!isMutual &&
|
!isMutual &&
|
||||||
!isFollower;
|
!isFollower;
|
||||||
|
|
||||||
|
const handleClick = useLinks();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@ -105,7 +109,14 @@ export const HoverCardAccount = forwardRef<
|
|||||||
accountId={account.id}
|
accountId={account.id}
|
||||||
className='hover-card__bio'
|
className='hover-card__bio'
|
||||||
/>
|
/>
|
||||||
<AccountFields fields={account.fields} limit={2} />
|
|
||||||
|
<div className='account-fields' onClickCapture={handleClick}>
|
||||||
|
<AccountFields
|
||||||
|
fields={account.fields.take(2)}
|
||||||
|
emojis={account.emojis}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{note && note.length > 0 && (
|
{note && note.length > 0 && (
|
||||||
<dl className='hover-card__note'>
|
<dl className='hover-card__note'>
|
||||||
<dt className='hover-card__note-label'>
|
<dt className='hover-card__note-label'>
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import classNames from 'classnames';
|
|||||||
import { animated, useSpring } from '@react-spring/web';
|
import { animated, useSpring } from '@react-spring/web';
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html';
|
||||||
|
|
||||||
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
import { fetchPoll, vote } from 'mastodon/actions/polls';
|
import { fetchPoll, vote } from 'mastodon/actions/polls';
|
||||||
@ -305,10 +306,11 @@ const PollOption: React.FC<PollOptionProps> = (props) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span
|
<EmojiHTML
|
||||||
className='poll__option__text translate'
|
className='poll__option__text translate'
|
||||||
lang={lang}
|
lang={lang}
|
||||||
dangerouslySetInnerHTML={{ __html: titleHtml }}
|
htmlString={titleHtml}
|
||||||
|
extraEmojis={poll.emojis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!!voted && (
|
{!!voted && (
|
||||||
|
|||||||
@ -118,7 +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,
|
shouldHighlightOnMount: PropTypes.bool,
|
||||||
getScrollPosition: PropTypes.func,
|
getScrollPosition: PropTypes.func,
|
||||||
updateScrollBottom: PropTypes.func,
|
updateScrollBottom: PropTypes.func,
|
||||||
cacheMediaWidth: PropTypes.func,
|
cacheMediaWidth: PropTypes.func,
|
||||||
@ -600,7 +600,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
|
|
||||||
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}
|
{matchedFilters && <FilterWarning title={matchedFilters.join(', ')} expanded={this.state.showDespiteFilter} onClick={this.handleFilterToggle} />}
|
||||||
|
|
||||||
{(status.get('spoiler_text').length > 0 && (!matchedFilters || this.state.showDespiteFilter)) && <ContentWarning text={status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml')} expanded={expanded} onClick={this.handleExpandedToggle} />}
|
{(!matchedFilters || this.state.showDespiteFilter) && <ContentWarning status={status} expanded={expanded} onClick={this.handleExpandedToggle} />}
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -0,0 +1,65 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||||
|
|
||||||
|
import { HashtagMenuController } from '@/mastodon/features/ui/components/hashtag_menu_controller';
|
||||||
|
import { accountFactoryState } from '@/testing/factories';
|
||||||
|
|
||||||
|
import { HoverCardController } from '../hover_card_controller';
|
||||||
|
|
||||||
|
import type { HandledLinkProps } from './handled_link';
|
||||||
|
import { HandledLink } from './handled_link';
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Components/Status/HandledLink',
|
||||||
|
render(args) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<HandledLink {...args} mentionAccountId='1' hashtagAccountId='1' />
|
||||||
|
<HashtagMenuController />
|
||||||
|
<HoverCardController />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
href: 'https://example.com/path/subpath?query=1#hash',
|
||||||
|
text: 'https://example.com',
|
||||||
|
},
|
||||||
|
parameters: {
|
||||||
|
state: {
|
||||||
|
accounts: {
|
||||||
|
'1': accountFactoryState(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies Meta<Pick<HandledLinkProps, 'href' | 'text'>>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const Hashtag: Story = {
|
||||||
|
args: {
|
||||||
|
text: '#example',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Mention: Story = {
|
||||||
|
args: {
|
||||||
|
text: '@user',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InternalLink: Story = {
|
||||||
|
args: {
|
||||||
|
href: '/about',
|
||||||
|
text: 'About',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const InvalidURL: Story = {
|
||||||
|
args: {
|
||||||
|
href: 'ht!tp://invalid-url',
|
||||||
|
text: 'ht!tp://invalid-url -- invalid!',
|
||||||
|
},
|
||||||
|
};
|
||||||
111
app/javascript/mastodon/components/status/handled_link.tsx
Normal file
111
app/javascript/mastodon/components/status/handled_link.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import type { ComponentProps, FC } from 'react';
|
||||||
|
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||||
|
|
||||||
|
export interface HandledLinkProps {
|
||||||
|
href: string;
|
||||||
|
text: string;
|
||||||
|
hashtagAccountId?: string;
|
||||||
|
mentionAccountId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HandledLink: FC<HandledLinkProps & ComponentProps<'a'>> = ({
|
||||||
|
href,
|
||||||
|
text,
|
||||||
|
hashtagAccountId,
|
||||||
|
mentionAccountId,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
// Handle hashtags
|
||||||
|
if (text.startsWith('#')) {
|
||||||
|
const hashtag = text.slice(1).trim();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className='mention hashtag'
|
||||||
|
to={`/tags/${hashtag}`}
|
||||||
|
rel='tag'
|
||||||
|
data-menu-hashtag={hashtagAccountId}
|
||||||
|
>
|
||||||
|
#<span>{hashtag}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else if (text.startsWith('@')) {
|
||||||
|
// Handle mentions
|
||||||
|
const mention = text.slice(1).trim();
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
{...props}
|
||||||
|
className='mention'
|
||||||
|
to={`/@${mention}`}
|
||||||
|
title={`@${mention}`}
|
||||||
|
data-hover-card-account={mentionAccountId}
|
||||||
|
>
|
||||||
|
@<span>{mention}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-absolute paths treated as internal links.
|
||||||
|
if (href.startsWith('/')) {
|
||||||
|
return (
|
||||||
|
<Link {...props} className='unhandled-link' to={href}>
|
||||||
|
{text}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(href);
|
||||||
|
const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash.
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
{...props}
|
||||||
|
href={href}
|
||||||
|
title={href}
|
||||||
|
className='unhandled-link'
|
||||||
|
target='_blank'
|
||||||
|
rel='noreferrer noopener'
|
||||||
|
translate='no'
|
||||||
|
>
|
||||||
|
<span className='invisible'>{url.protocol + '//'}</span>
|
||||||
|
<span className='ellipsis'>{`${url.hostname}/${first ?? ''}`}</span>
|
||||||
|
<span className='invisible'>{'/' + rest.join('/')}</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useElementHandledLink = ({
|
||||||
|
hashtagAccountId,
|
||||||
|
hrefToMentionAccountId,
|
||||||
|
}: {
|
||||||
|
hashtagAccountId?: string;
|
||||||
|
hrefToMentionAccountId?: (href: string) => string | undefined;
|
||||||
|
} = {}) => {
|
||||||
|
const onElement = useCallback<OnElementHandler>(
|
||||||
|
(element, { key, ...props }) => {
|
||||||
|
if (element instanceof HTMLAnchorElement) {
|
||||||
|
const mentionId = hrefToMentionAccountId?.(element.href);
|
||||||
|
return (
|
||||||
|
<HandledLink
|
||||||
|
{...props}
|
||||||
|
key={key as string} // React requires keys to not be part of spread props.
|
||||||
|
href={element.href}
|
||||||
|
text={element.innerText}
|
||||||
|
hashtagAccountId={hashtagAccountId}
|
||||||
|
mentionAccountId={mentionId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
[hashtagAccountId, hrefToMentionAccountId],
|
||||||
|
);
|
||||||
|
return { onElement };
|
||||||
|
};
|
||||||
@ -3,6 +3,8 @@ import { useCallback, useRef, useId } from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import { AnimateEmojiProvider } from './emoji/context';
|
||||||
|
|
||||||
export enum BannerVariant {
|
export enum BannerVariant {
|
||||||
Warning = 'warning',
|
Warning = 'warning',
|
||||||
Filter = 'filter',
|
Filter = 'filter',
|
||||||
@ -34,8 +36,7 @@ export const StatusBanner: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
// Element clicks are passed on to button
|
// Element clicks are passed on to button
|
||||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
<AnimateEmojiProvider
|
||||||
<div
|
|
||||||
className={
|
className={
|
||||||
variant === BannerVariant.Warning
|
variant === BannerVariant.Warning
|
||||||
? 'content-warning'
|
? 'content-warning'
|
||||||
@ -69,6 +70,6 @@ export const StatusBanner: React.FC<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</AnimateEmojiProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { languages as preloadedLanguages } from 'mastodon/initial_state';
|
|||||||
import { isModernEmojiEnabled } from '../utils/environment';
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
import { EmojiHTML } from './emoji/html';
|
import { EmojiHTML } from './emoji/html';
|
||||||
|
import { HandledLink } from './status/handled_link';
|
||||||
|
|
||||||
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
||||||
|
|
||||||
@ -27,9 +28,6 @@ const MAX_HEIGHT = 706; // 22px * 32 (+ 2px padding at the top)
|
|||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function getStatusContent(status) {
|
export function getStatusContent(status) {
|
||||||
if (isModernEmojiEnabled()) {
|
|
||||||
return status.getIn(['translation', 'content']) || status.get('content');
|
|
||||||
}
|
|
||||||
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,6 +97,23 @@ class StatusContent extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { status, onCollapsedToggle } = this.props;
|
const { status, onCollapsedToggle } = this.props;
|
||||||
|
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
||||||
|
const { collapsible, onClick } = this.props;
|
||||||
|
|
||||||
|
const collapsed =
|
||||||
|
collapsible
|
||||||
|
&& onClick
|
||||||
|
&& node.clientHeight > MAX_HEIGHT
|
||||||
|
&& status.get('spoiler_text').length === 0;
|
||||||
|
|
||||||
|
onCollapsedToggle(collapsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit if modern emoji is enabled, as it handles links using the HandledLink component.
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const links = node.querySelectorAll('a');
|
const links = node.querySelectorAll('a');
|
||||||
|
|
||||||
let link, mention;
|
let link, mention;
|
||||||
@ -128,18 +143,6 @@ class StatusContent extends PureComponent {
|
|||||||
link.classList.add('unhandled-link');
|
link.classList.add('unhandled-link');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status.get('collapsed', null) === null && onCollapsedToggle) {
|
|
||||||
const { collapsible, onClick } = this.props;
|
|
||||||
|
|
||||||
const collapsed =
|
|
||||||
collapsible
|
|
||||||
&& onClick
|
|
||||||
&& node.clientHeight > MAX_HEIGHT
|
|
||||||
&& status.get('spoiler_text').length === 0;
|
|
||||||
|
|
||||||
onCollapsedToggle(collapsed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
@ -201,6 +204,25 @@ class StatusContent extends PureComponent {
|
|||||||
this.node = c;
|
this.node = c;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
handleElement = (element, { key, ...props }) => {
|
||||||
|
if (element instanceof HTMLAnchorElement) {
|
||||||
|
const mention = this.props.status.get('mentions').find(item => element.href === item.get('url'));
|
||||||
|
return (
|
||||||
|
<HandledLink
|
||||||
|
{...props}
|
||||||
|
href={element.href}
|
||||||
|
text={element.innerText}
|
||||||
|
hashtagAccountId={this.props.status.getIn(['account', 'id'])}
|
||||||
|
mentionAccountId={mention?.get('id')}
|
||||||
|
key={key}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { status, intl, statusContent } = this.props;
|
const { status, intl, statusContent } = this.props;
|
||||||
|
|
||||||
@ -245,6 +267,7 @@ class StatusContent extends PureComponent {
|
|||||||
lang={language}
|
lang={language}
|
||||||
htmlString={content}
|
htmlString={content}
|
||||||
extraEmojis={status.get('emojis')}
|
extraEmojis={status.get('emojis')}
|
||||||
|
onElement={this.handleElement.bind(this)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
@ -262,6 +285,7 @@ class StatusContent extends PureComponent {
|
|||||||
lang={language}
|
lang={language}
|
||||||
htmlString={content}
|
htmlString={content}
|
||||||
extraEmojis={status.get('emojis')}
|
extraEmojis={status.get('emojis')}
|
||||||
|
onElement={this.handleElement.bind(this)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{poll}
|
{poll}
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
|
|
||||||
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
import type { OnAttributeHandler } from '../utils/html';
|
||||||
|
|
||||||
import { Icon } from './icon';
|
import { Icon } from './icon';
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser();
|
||||||
|
|
||||||
const stripRelMe = (html: string) => {
|
const stripRelMe = (html: string) => {
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
const document = domParser.parseFromString(html, 'text/html').documentElement;
|
||||||
|
|
||||||
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
|
document.querySelectorAll<HTMLAnchorElement>('a[rel]').forEach((link) => {
|
||||||
@ -15,7 +22,23 @@ const stripRelMe = (html: string) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const body = document.querySelector('body');
|
const body = document.querySelector('body');
|
||||||
return body ? { __html: body.innerHTML } : undefined;
|
return body?.innerHTML ?? '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAttribute: OnAttributeHandler = (name, value, tagName) => {
|
||||||
|
if (name === 'rel' && tagName === 'a') {
|
||||||
|
if (value === 'me') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
name,
|
||||||
|
value
|
||||||
|
.split(' ')
|
||||||
|
.filter((x) => x !== 'me')
|
||||||
|
.join(' '),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@ -24,6 +47,10 @@ interface Props {
|
|||||||
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
export const VerifiedBadge: React.FC<Props> = ({ link }) => (
|
||||||
<span className='verified-badge'>
|
<span className='verified-badge'>
|
||||||
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
<Icon id='check' icon={CheckIcon} className='verified-badge__mark' />
|
||||||
<span dangerouslySetInnerHTML={stripRelMe(link)} />
|
<EmojiHTML
|
||||||
|
as='span'
|
||||||
|
htmlString={stripRelMe(link)}
|
||||||
|
onAttribute={onAttribute}
|
||||||
|
/>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -7,9 +7,9 @@ import { Helmet } from 'react-helmet';
|
|||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
|
|
||||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||||
|
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||||
import { DisplayName } from '@/mastodon/components/display_name';
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||||
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';
|
||||||
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
|
||||||
@ -186,14 +186,6 @@ const titleFromAccount = (account: Account) => {
|
|||||||
return `${prefix} (@${acct})`;
|
return `${prefix} (@${acct})`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const dateFormatOptions: Intl.DateTimeFormatOptions = {
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const AccountHeader: React.FC<{
|
export const AccountHeader: React.FC<{
|
||||||
accountId: string;
|
accountId: string;
|
||||||
hideTabs?: boolean;
|
hideTabs?: boolean;
|
||||||
@ -891,46 +883,7 @@ export const AccountHeader: React.FC<{
|
|||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
{fields.map((pair, i) => (
|
<AccountFields fields={fields} emojis={account.emojis} />
|
||||||
<dl
|
|
||||||
key={i}
|
|
||||||
className={classNames({
|
|
||||||
verified: pair.verified_at,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<dt
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: pair.name_emojified,
|
|
||||||
}}
|
|
||||||
title={pair.name}
|
|
||||||
className='translate'
|
|
||||||
/>
|
|
||||||
|
|
||||||
<dd className='translate' title={pair.value_plain ?? ''}>
|
|
||||||
{pair.verified_at && (
|
|
||||||
<span
|
|
||||||
title={intl.formatMessage(messages.linkVerifiedOn, {
|
|
||||||
date: intl.formatDate(
|
|
||||||
pair.verified_at,
|
|
||||||
dateFormatOptions,
|
|
||||||
),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
id='check'
|
|
||||||
icon={CheckIcon}
|
|
||||||
className='verified__mark'
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
)}{' '}
|
|
||||||
<span
|
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: pair.value_emojified,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</dd>
|
|
||||||
</dl>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -50,9 +50,7 @@ export const EditIndicator = () => {
|
|||||||
|
|
||||||
<EmbeddedStatusContent
|
<EmbeddedStatusContent
|
||||||
className='edit-indicator__content translate'
|
className='edit-indicator__content translate'
|
||||||
content={status.get('contentHtml')}
|
status={status}
|
||||||
language={status.get('language')}
|
|
||||||
mentions={status.get('mentions')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||||
|
|||||||
@ -35,9 +35,7 @@ export const ReplyIndicator = () => {
|
|||||||
|
|
||||||
<EmbeddedStatusContent
|
<EmbeddedStatusContent
|
||||||
className='reply-indicator__content translate'
|
className='reply-indicator__content translate'
|
||||||
content={status.get('contentHtml')}
|
status={status}
|
||||||
language={status.get('language')}
|
|
||||||
mentions={status.get('mentions')}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
{(status.get('poll') || status.get('media_attachments').size > 0) && (
|
||||||
|
|||||||
@ -10,10 +10,13 @@ import {
|
|||||||
insertEmojiCompose,
|
insertEmojiCompose,
|
||||||
uploadCompose,
|
uploadCompose,
|
||||||
} from 'mastodon/actions/compose';
|
} from 'mastodon/actions/compose';
|
||||||
|
import { pasteLinkCompose } from 'mastodon/actions/compose_typed';
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
|
||||||
import ComposeForm from '../components/compose_form';
|
import ComposeForm from '../components/compose_form';
|
||||||
|
|
||||||
|
const urlLikeRegex = /^https?:\/\/[^\s]+\/[^\s]+$/i;
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
const mapStateToProps = state => ({
|
||||||
text: state.getIn(['compose', 'text']),
|
text: state.getIn(['compose', 'text']),
|
||||||
suggestions: state.getIn(['compose', 'suggestions']),
|
suggestions: state.getIn(['compose', 'suggestions']),
|
||||||
@ -71,8 +74,21 @@ const mapDispatchToProps = (dispatch, props) => ({
|
|||||||
dispatch(changeComposeSpoilerText(checked));
|
dispatch(changeComposeSpoilerText(checked));
|
||||||
},
|
},
|
||||||
|
|
||||||
onPaste (files) {
|
onPaste (e) {
|
||||||
dispatch(uploadCompose(files));
|
if (e.clipboardData && e.clipboardData.files.length === 1) {
|
||||||
|
dispatch(uploadCompose(e.clipboardData.files));
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.clipboardData && e.clipboardData.files.length === 0) {
|
||||||
|
const data = e.clipboardData.getData('text/plain');
|
||||||
|
if (!data.match(urlLikeRegex)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(data);
|
||||||
|
dispatch(pasteLinkCompose({ url }));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onPickEmoji (position, data, needsSpace) {
|
onPickEmoji (position, data, needsSpace) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { FormattedMessage } from 'react-intl';
|
|||||||
|
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
import { Avatar } from 'mastodon/components/avatar';
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
import { FollowButton } from 'mastodon/components/follow_button';
|
import { FollowButton } from 'mastodon/components/follow_button';
|
||||||
@ -39,9 +40,10 @@ export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{account.get('note').length > 0 && (
|
{account.get('note').length > 0 && (
|
||||||
<div
|
<EmojiHTML
|
||||||
className='account-card__bio translate animate-parent'
|
className='account-card__bio translate'
|
||||||
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
|
htmlString={account.get('note_emojified')}
|
||||||
|
extraEmojis={account.get('emojis')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Trie from 'substring-trie';
|
import Trie from 'substring-trie';
|
||||||
|
|
||||||
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
import { assetHost } from 'mastodon/utils/config';
|
import { assetHost } from 'mastodon/utils/config';
|
||||||
|
|
||||||
import { autoPlayGif } from '../../initial_state';
|
import { autoPlayGif } from '../../initial_state';
|
||||||
@ -148,7 +149,17 @@ const emojifyNode = (node, customEmojis) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const emojify = (str, customEmojis = {}) => {
|
/**
|
||||||
|
* Legacy emoji processing function.
|
||||||
|
* @param {string} str
|
||||||
|
* @param {object} customEmojis
|
||||||
|
* @param {boolean} force If true, always emojify even if modern emoji is enabled
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
const emojify = (str, customEmojis = {}, force = false) => {
|
||||||
|
if (isModernEmojiEnabled() && !force) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
const wrapper = document.createElement('div');
|
const wrapper = document.createElement('div');
|
||||||
wrapper.innerHTML = str;
|
wrapper.innerHTML = str;
|
||||||
|
|
||||||
|
|||||||
@ -154,15 +154,21 @@ export function cleanExtraEmojis(extraEmojis?: CustomEmojiMapArg) {
|
|||||||
if (!extraEmojis) {
|
if (!extraEmojis) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (!isList(extraEmojis)) {
|
if (Array.isArray(extraEmojis)) {
|
||||||
return extraEmojis;
|
return extraEmojis.reduce<ExtraCustomEmojiMap>(
|
||||||
}
|
|
||||||
return extraEmojis
|
|
||||||
.toJSON()
|
|
||||||
.reduce<ExtraCustomEmojiMap>(
|
|
||||||
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
{},
|
{},
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
if (isList(extraEmojis)) {
|
||||||
|
return extraEmojis
|
||||||
|
.toJS()
|
||||||
|
.reduce<ExtraCustomEmojiMap>(
|
||||||
|
(acc, emoji) => ({ ...acc, [emoji.shortcode]: emoji }),
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return extraEmojis;
|
||||||
}
|
}
|
||||||
|
|
||||||
function hexStringToNumbers(hexString: string): number[] {
|
function hexStringToNumbers(hexString: string): number[] {
|
||||||
|
|||||||
@ -56,7 +56,9 @@ export type EmojiStateMap = LimitedCache<string, EmojiState>;
|
|||||||
|
|
||||||
export type CustomEmojiMapArg =
|
export type CustomEmojiMapArg =
|
||||||
| ExtraCustomEmojiMap
|
| ExtraCustomEmojiMap
|
||||||
| ImmutableList<CustomEmoji>;
|
| ImmutableList<CustomEmoji>
|
||||||
|
| CustomEmoji[]
|
||||||
|
| ApiCustomEmojiJSON[];
|
||||||
|
|
||||||
export type ExtraCustomEmojiMap = Record<
|
export type ExtraCustomEmojiMap = Record<
|
||||||
string,
|
string,
|
||||||
|
|||||||
@ -10,9 +10,10 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||||||
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
|
||||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||||
|
|
||||||
import { Avatar } from '../../../components/avatar';
|
import { Avatar } from '@/mastodon/components/avatar';
|
||||||
import { DisplayName } from '../../../components/display_name';
|
import { DisplayName } from '@/mastodon/components/display_name';
|
||||||
import { IconButton } from '../../../components/icon_button';
|
import { IconButton } from '@/mastodon/components/icon_button';
|
||||||
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
authorize: { id: 'follow_request.authorize', defaultMessage: 'Authorize' },
|
||||||
@ -30,7 +31,6 @@ class AccountAuthorize extends ImmutablePureComponent {
|
|||||||
|
|
||||||
render () {
|
render () {
|
||||||
const { intl, account, onAuthorize, onReject } = this.props;
|
const { intl, account, onAuthorize, onReject } = this.props;
|
||||||
const content = { __html: account.get('note_emojified') };
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='account-authorize__wrapper'>
|
<div className='account-authorize__wrapper'>
|
||||||
@ -40,7 +40,11 @@ class AccountAuthorize extends ImmutablePureComponent {
|
|||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className='account__header__content translate' dangerouslySetInnerHTML={content} />
|
<EmojiHTML
|
||||||
|
className='account__header__content translate'
|
||||||
|
htmlString={account.get('note_emojified')}
|
||||||
|
extraEmojis={account.get('emojis')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='account--panel'>
|
<div className='account--panel'>
|
||||||
|
|||||||
@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { FormattedDate, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import type { ApiAnnouncementJSON } from '@/mastodon/api_types/announcements';
|
||||||
|
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||||
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
|
|
||||||
|
import { ReactionsBar } from './reactions';
|
||||||
|
|
||||||
|
export interface IAnnouncement extends ApiAnnouncementJSON {
|
||||||
|
contentHtml: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnnouncementProps {
|
||||||
|
announcement: IAnnouncement;
|
||||||
|
selected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Announcement: FC<AnnouncementProps> = ({
|
||||||
|
announcement,
|
||||||
|
selected,
|
||||||
|
}) => {
|
||||||
|
const [unread, setUnread] = useState(!announcement.read);
|
||||||
|
useEffect(() => {
|
||||||
|
// Only update `unread` marker once the announcement is out of view
|
||||||
|
if (!selected && unread !== !announcement.read) {
|
||||||
|
setUnread(!announcement.read);
|
||||||
|
}
|
||||||
|
}, [announcement.read, selected, unread]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimateEmojiProvider className='announcements__item'>
|
||||||
|
<strong className='announcements__item__range'>
|
||||||
|
<FormattedMessage
|
||||||
|
id='announcement.announcement'
|
||||||
|
defaultMessage='Announcement'
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{' · '}
|
||||||
|
<Timestamp announcement={announcement} />
|
||||||
|
</span>
|
||||||
|
</strong>
|
||||||
|
|
||||||
|
<EmojiHTML
|
||||||
|
className='announcements__item__content translate'
|
||||||
|
htmlString={announcement.contentHtml}
|
||||||
|
extraEmojis={announcement.emojis}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ReactionsBar reactions={announcement.reactions} id={announcement.id} />
|
||||||
|
|
||||||
|
{unread && <span className='announcements__item__unread' />}
|
||||||
|
</AnimateEmojiProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Timestamp: FC<Pick<AnnouncementProps, 'announcement'>> = ({
|
||||||
|
announcement,
|
||||||
|
}) => {
|
||||||
|
const startsAt = announcement.starts_at && new Date(announcement.starts_at);
|
||||||
|
const endsAt = announcement.ends_at && new Date(announcement.ends_at);
|
||||||
|
const now = new Date();
|
||||||
|
const hasTimeRange = startsAt && endsAt;
|
||||||
|
const skipTime = announcement.all_day;
|
||||||
|
|
||||||
|
if (hasTimeRange) {
|
||||||
|
const skipYear =
|
||||||
|
startsAt.getFullYear() === endsAt.getFullYear() &&
|
||||||
|
endsAt.getFullYear() === now.getFullYear();
|
||||||
|
const skipEndDate =
|
||||||
|
startsAt.getDate() === endsAt.getDate() &&
|
||||||
|
startsAt.getMonth() === endsAt.getMonth() &&
|
||||||
|
startsAt.getFullYear() === endsAt.getFullYear();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FormattedDate
|
||||||
|
value={startsAt}
|
||||||
|
year={
|
||||||
|
skipYear || startsAt.getFullYear() === now.getFullYear()
|
||||||
|
? undefined
|
||||||
|
: 'numeric'
|
||||||
|
}
|
||||||
|
month='short'
|
||||||
|
day='2-digit'
|
||||||
|
hour={skipTime ? undefined : '2-digit'}
|
||||||
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
|
/>{' '}
|
||||||
|
-{' '}
|
||||||
|
<FormattedDate
|
||||||
|
value={endsAt}
|
||||||
|
year={
|
||||||
|
skipYear || endsAt.getFullYear() === now.getFullYear()
|
||||||
|
? undefined
|
||||||
|
: 'numeric'
|
||||||
|
}
|
||||||
|
month={skipEndDate ? undefined : 'short'}
|
||||||
|
day={skipEndDate ? undefined : '2-digit'}
|
||||||
|
hour={skipTime ? undefined : '2-digit'}
|
||||||
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const publishedAt = new Date(announcement.published_at);
|
||||||
|
return (
|
||||||
|
<FormattedDate
|
||||||
|
value={publishedAt}
|
||||||
|
year={
|
||||||
|
publishedAt.getFullYear() === now.getFullYear() ? undefined : 'numeric'
|
||||||
|
}
|
||||||
|
month='short'
|
||||||
|
day='2-digit'
|
||||||
|
hour={skipTime ? undefined : '2-digit'}
|
||||||
|
minute={skipTime ? undefined : '2-digit'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -0,0 +1,118 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import type { FC } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import type { Map, List } from 'immutable';
|
||||||
|
|
||||||
|
import ReactSwipeableViews from 'react-swipeable-views';
|
||||||
|
|
||||||
|
import elephantUIPlane from '@/images/elephant_ui_plane.svg';
|
||||||
|
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||||
|
import { IconButton } from '@/mastodon/components/icon_button';
|
||||||
|
import LegacyAnnouncements from '@/mastodon/features/getting_started/containers/announcements_container';
|
||||||
|
import { mascot, reduceMotion } from '@/mastodon/initial_state';
|
||||||
|
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||||
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
|
||||||
|
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
|
||||||
|
|
||||||
|
import type { IAnnouncement } from './announcement';
|
||||||
|
import { Announcement } from './announcement';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
close: { id: 'lightbox.close', defaultMessage: 'Close' },
|
||||||
|
previous: { id: 'lightbox.previous', defaultMessage: 'Previous' },
|
||||||
|
next: { id: 'lightbox.next', defaultMessage: 'Next' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const announcementSelector = createAppSelector(
|
||||||
|
[(state) => state.announcements as Map<string, List<Map<string, unknown>>>],
|
||||||
|
(announcements) =>
|
||||||
|
(announcements.get('items')?.toJS() as IAnnouncement[] | undefined) ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ModernAnnouncements: FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
const announcements = useAppSelector(announcementSelector);
|
||||||
|
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||||
|
|
||||||
|
const [index, setIndex] = useState(0);
|
||||||
|
const handleChangeIndex = useCallback(
|
||||||
|
(idx: number) => {
|
||||||
|
setIndex(idx % announcements.length);
|
||||||
|
},
|
||||||
|
[announcements.length],
|
||||||
|
);
|
||||||
|
const handleNextIndex = useCallback(() => {
|
||||||
|
setIndex((prevIndex) => (prevIndex + 1) % announcements.length);
|
||||||
|
}, [announcements.length]);
|
||||||
|
const handlePrevIndex = useCallback(() => {
|
||||||
|
setIndex((prevIndex) =>
|
||||||
|
prevIndex === 0 ? announcements.length - 1 : prevIndex - 1,
|
||||||
|
);
|
||||||
|
}, [announcements.length]);
|
||||||
|
|
||||||
|
if (announcements.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='announcements'>
|
||||||
|
<img
|
||||||
|
className='announcements__mastodon'
|
||||||
|
alt=''
|
||||||
|
draggable='false'
|
||||||
|
src={mascot ?? elephantUIPlane}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className='announcements__container'>
|
||||||
|
<CustomEmojiProvider emojis={emojis}>
|
||||||
|
<ReactSwipeableViews
|
||||||
|
animateHeight
|
||||||
|
animateTransitions={!reduceMotion}
|
||||||
|
index={index}
|
||||||
|
onChangeIndex={handleChangeIndex}
|
||||||
|
>
|
||||||
|
{announcements
|
||||||
|
.map((announcement, idx) => (
|
||||||
|
<Announcement
|
||||||
|
key={announcement.id}
|
||||||
|
announcement={announcement}
|
||||||
|
selected={index === idx}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
.reverse()}
|
||||||
|
</ReactSwipeableViews>
|
||||||
|
</CustomEmojiProvider>
|
||||||
|
|
||||||
|
{announcements.length > 1 && (
|
||||||
|
<div className='announcements__pagination'>
|
||||||
|
<IconButton
|
||||||
|
disabled={announcements.length === 1}
|
||||||
|
title={intl.formatMessage(messages.previous)}
|
||||||
|
icon='chevron-left'
|
||||||
|
iconComponent={ChevronLeftIcon}
|
||||||
|
onClick={handlePrevIndex}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{index + 1} / {announcements.length}
|
||||||
|
</span>
|
||||||
|
<IconButton
|
||||||
|
disabled={announcements.length === 1}
|
||||||
|
title={intl.formatMessage(messages.next)}
|
||||||
|
icon='chevron-right'
|
||||||
|
iconComponent={ChevronRightIcon}
|
||||||
|
onClick={handleNextIndex}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Announcements = isModernEmojiEnabled()
|
||||||
|
? ModernAnnouncements
|
||||||
|
: LegacyAnnouncements;
|
||||||
@ -0,0 +1,108 @@
|
|||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
import type { FC, HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import type { AnimatedProps } from '@react-spring/web';
|
||||||
|
import { animated, useTransition } from '@react-spring/web';
|
||||||
|
|
||||||
|
import { addReaction, removeReaction } from '@/mastodon/actions/announcements';
|
||||||
|
import type { ApiAnnouncementReactionJSON } from '@/mastodon/api_types/announcements';
|
||||||
|
import { AnimatedNumber } from '@/mastodon/components/animated_number';
|
||||||
|
import { Emoji } from '@/mastodon/components/emoji';
|
||||||
|
import { Icon } from '@/mastodon/components/icon';
|
||||||
|
import EmojiPickerDropdown from '@/mastodon/features/compose/containers/emoji_picker_dropdown_container';
|
||||||
|
import { isUnicodeEmoji } from '@/mastodon/features/emoji/utils';
|
||||||
|
import { useAppDispatch } from '@/mastodon/store';
|
||||||
|
import AddIcon from '@/material-icons/400-24px/add.svg?react';
|
||||||
|
|
||||||
|
export const ReactionsBar: FC<{
|
||||||
|
reactions: ApiAnnouncementReactionJSON[];
|
||||||
|
id: string;
|
||||||
|
}> = ({ reactions, id }) => {
|
||||||
|
const visibleReactions = useMemo(
|
||||||
|
() => reactions.filter((x) => x.count > 0),
|
||||||
|
[reactions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleEmojiPick = useCallback(
|
||||||
|
(emoji: { native: string }) => {
|
||||||
|
dispatch(addReaction(id, emoji.native.replaceAll(/:/g, '')));
|
||||||
|
},
|
||||||
|
[dispatch, id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const transitions = useTransition(visibleReactions, {
|
||||||
|
from: {
|
||||||
|
scale: 0,
|
||||||
|
},
|
||||||
|
enter: {
|
||||||
|
scale: 1,
|
||||||
|
},
|
||||||
|
leave: {
|
||||||
|
scale: 0,
|
||||||
|
},
|
||||||
|
keys: visibleReactions.map((x) => x.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('reactions-bar', {
|
||||||
|
'reactions-bar--empty': visibleReactions.length === 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{transitions(({ scale }, reaction) => (
|
||||||
|
<Reaction
|
||||||
|
key={reaction.name}
|
||||||
|
reaction={reaction}
|
||||||
|
style={{ transform: scale.to((s) => `scale(${s})`) }}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{visibleReactions.length < 8 && (
|
||||||
|
<EmojiPickerDropdown
|
||||||
|
onPickEmoji={handleEmojiPick}
|
||||||
|
button={<Icon id='plus' icon={AddIcon} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Reaction: FC<{
|
||||||
|
reaction: ApiAnnouncementReactionJSON;
|
||||||
|
id: string;
|
||||||
|
style: AnimatedProps<HTMLAttributes<HTMLButtonElement>>['style'];
|
||||||
|
}> = ({ id, reaction, style }) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (reaction.me) {
|
||||||
|
dispatch(removeReaction(id, reaction.name));
|
||||||
|
} else {
|
||||||
|
dispatch(addReaction(id, reaction.name));
|
||||||
|
}
|
||||||
|
}, [dispatch, id, reaction.me, reaction.name]);
|
||||||
|
|
||||||
|
const code = isUnicodeEmoji(reaction.name)
|
||||||
|
? reaction.name
|
||||||
|
: `:${reaction.name}:`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<animated.button
|
||||||
|
className={classNames('reactions-bar__item', {
|
||||||
|
active: reaction.me,
|
||||||
|
})}
|
||||||
|
onClick={handleClick}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<span className='reactions-bar__item__emoji'>
|
||||||
|
<Emoji code={code} />
|
||||||
|
</span>
|
||||||
|
<span className='reactions-bar__item__count'>
|
||||||
|
<AnimatedNumber value={reaction.count} />
|
||||||
|
</span>
|
||||||
|
</animated.button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,7 +14,6 @@ import { SymbolLogo } from 'mastodon/components/logo';
|
|||||||
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
|
import { fetchAnnouncements, toggleShowAnnouncements } from 'mastodon/actions/announcements';
|
||||||
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
import { IconWithBadge } from 'mastodon/components/icon_with_badge';
|
||||||
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
import { NotSignedInIndicator } from 'mastodon/components/not_signed_in_indicator';
|
||||||
import AnnouncementsContainer from 'mastodon/features/getting_started/containers/announcements_container';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
||||||
import { criticalUpdatesPending } from 'mastodon/initial_state';
|
import { criticalUpdatesPending } from 'mastodon/initial_state';
|
||||||
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
import { withBreakpoint } from 'mastodon/features/ui/hooks/useBreakpoint';
|
||||||
@ -27,6 +26,7 @@ import StatusListContainer from '../ui/containers/status_list_container';
|
|||||||
|
|
||||||
import { ColumnSettings } from './components/column_settings';
|
import { ColumnSettings } from './components/column_settings';
|
||||||
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
import { CriticalUpdateBanner } from './components/critical_update_banner';
|
||||||
|
import { Announcements } from './components/announcements';
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'column.home', defaultMessage: 'Home' },
|
title: { id: 'column.home', defaultMessage: 'Home' },
|
||||||
@ -162,7 +162,7 @@ class HomeTimeline extends PureComponent {
|
|||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
multiColumn={multiColumn}
|
multiColumn={multiColumn}
|
||||||
extraButton={announcementsButton}
|
extraButton={announcementsButton}
|
||||||
appendContent={hasAnnouncements && showAnnouncements && <AnnouncementsContainer />}
|
appendContent={hasAnnouncements && showAnnouncements && <Announcements />}
|
||||||
>
|
>
|
||||||
<ColumnSettings />
|
<ColumnSettings />
|
||||||
</ColumnHeader>
|
</ColumnHeader>
|
||||||
|
|||||||
@ -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 type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||||
import { AnimateEmojiProvider } from '@/mastodon/components/emoji/context';
|
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';
|
||||||
@ -18,7 +19,7 @@ import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
|||||||
|
|
||||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||||
|
|
||||||
export type Mention = RecordOf<{ url: string; acct: string }>;
|
export type Mention = RecordOf<ApiMentionJSON>;
|
||||||
|
|
||||||
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
statusId,
|
statusId,
|
||||||
@ -86,12 +87,9 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
// Assign status attributes to variables with a forced type, as status is not yet properly typed
|
||||||
const contentHtml = status.get('contentHtml') as string;
|
const hasContentWarning = !!status.get('spoiler_text');
|
||||||
const contentWarning = status.get('spoilerHtml') as string;
|
|
||||||
const poll = status.get('poll');
|
const poll = status.get('poll');
|
||||||
const language = status.get('language') as string;
|
const expanded = !status.get('hidden') || !hasContentWarning;
|
||||||
const mentions = status.get('mentions') as ImmutableList<Mention>;
|
|
||||||
const expanded = !status.get('hidden') || !contentWarning;
|
|
||||||
const mediaAttachmentsSize = (
|
const mediaAttachmentsSize = (
|
||||||
status.get('media_attachments') as ImmutableList<unknown>
|
status.get('media_attachments') as ImmutableList<unknown>
|
||||||
).size;
|
).size;
|
||||||
@ -109,20 +107,16 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||||||
<DisplayName account={account} />
|
<DisplayName account={account} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{contentWarning && (
|
<ContentWarning
|
||||||
<ContentWarning
|
status={status}
|
||||||
text={contentWarning}
|
onClick={handleContentWarningClick}
|
||||||
onClick={handleContentWarningClick}
|
expanded={expanded}
|
||||||
expanded={expanded}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(!contentWarning || expanded) && (
|
{(!hasContentWarning || expanded) && (
|
||||||
<EmbeddedStatusContent
|
<EmbeddedStatusContent
|
||||||
className='notification-group__embedded-status__content reply-indicator__content translate'
|
className='notification-group__embedded-status__content reply-indicator__content translate'
|
||||||
content={contentHtml}
|
status={status}
|
||||||
language={language}
|
|
||||||
mentions={mentions}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
@ -6,16 +6,22 @@ import type { List } from 'immutable';
|
|||||||
|
|
||||||
import type { History } from 'history';
|
import type { History } from 'history';
|
||||||
|
|
||||||
|
import type { ApiMentionJSON } from '@/mastodon/api_types/statuses';
|
||||||
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
|
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||||
|
import type { Status } from '@/mastodon/models/status';
|
||||||
|
import { isModernEmojiEnabled } from '@/mastodon/utils/environment';
|
||||||
|
|
||||||
import type { Mention } from './embedded_status';
|
import type { Mention } from './embedded_status';
|
||||||
|
|
||||||
const handleMentionClick = (
|
const handleMentionClick = (
|
||||||
history: History,
|
history: History,
|
||||||
mention: Mention,
|
mention: ApiMentionJSON,
|
||||||
e: MouseEvent,
|
e: MouseEvent,
|
||||||
) => {
|
) => {
|
||||||
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
if (e.button === 0 && !(e.ctrlKey || e.metaKey)) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
history.push(`/@${mention.get('acct')}`);
|
history.push(`/@${mention.acct}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,16 +37,26 @@ const handleHashtagClick = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const EmbeddedStatusContent: React.FC<{
|
export const EmbeddedStatusContent: React.FC<{
|
||||||
content: string;
|
status: Status;
|
||||||
mentions: List<Mention>;
|
|
||||||
language: string;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ content, mentions, language, className }) => {
|
}> = ({ status, className }) => {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
|
const mentions = useMemo(
|
||||||
|
() => (status.get('mentions') as List<Mention>).toJS(),
|
||||||
|
[status],
|
||||||
|
);
|
||||||
|
const htmlHandlers = useElementHandledLink({
|
||||||
|
hashtagAccountId: status.get('account') as string | undefined,
|
||||||
|
hrefToMentionAccountId(href) {
|
||||||
|
const mention = mentions.find((item) => item.url === href);
|
||||||
|
return mention?.id;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleContentRef = useCallback(
|
const handleContentRef = useCallback(
|
||||||
(node: HTMLDivElement | null) => {
|
(node: HTMLDivElement | null) => {
|
||||||
if (!node) {
|
if (!node || isModernEmojiEnabled()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -53,7 +69,7 @@ export const EmbeddedStatusContent: React.FC<{
|
|||||||
|
|
||||||
link.classList.add('status-link');
|
link.classList.add('status-link');
|
||||||
|
|
||||||
const mention = mentions.find((item) => link.href === item.get('url'));
|
const mention = mentions.find((item) => link.href === item.url);
|
||||||
|
|
||||||
if (mention) {
|
if (mention) {
|
||||||
link.addEventListener(
|
link.addEventListener(
|
||||||
@ -61,8 +77,8 @@ export const EmbeddedStatusContent: React.FC<{
|
|||||||
handleMentionClick.bind(null, history, mention),
|
handleMentionClick.bind(null, history, mention),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
link.setAttribute('title', `@${mention.get('acct')}`);
|
link.setAttribute('title', `@${mention.acct}`);
|
||||||
link.setAttribute('href', `/@${mention.get('acct')}`);
|
link.setAttribute('href', `/@${mention.acct}`);
|
||||||
} else if (
|
} else if (
|
||||||
link.textContent.startsWith('#') ||
|
link.textContent.startsWith('#') ||
|
||||||
link.previousSibling?.textContent?.endsWith('#')
|
link.previousSibling?.textContent?.endsWith('#')
|
||||||
@ -83,11 +99,12 @@ export const EmbeddedStatusContent: React.FC<{
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<EmojiHTML
|
||||||
|
{...htmlHandlers}
|
||||||
className={className}
|
className={className}
|
||||||
ref={handleContentRef}
|
ref={handleContentRef}
|
||||||
lang={language}
|
lang={status.get('language') as string}
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
htmlString={status.get('contentHtml') as string}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -394,17 +394,13 @@ export const DetailedStatus: React.FC<{
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status.get('spoiler_text').length > 0 &&
|
{(!matchedFilters || showDespiteFilter) && (
|
||||||
(!matchedFilters || showDespiteFilter) && (
|
<ContentWarning
|
||||||
<ContentWarning
|
status={status}
|
||||||
text={
|
expanded={expanded}
|
||||||
status.getIn(['translation', 'spoilerHtml']) ||
|
onClick={handleExpandedToggle}
|
||||||
status.get('spoilerHtml')
|
/>
|
||||||
}
|
)}
|
||||||
expanded={expanded}
|
|
||||||
onClick={handleExpandedToggle}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{expanded && (
|
{expanded && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useIntl, defineMessages } from 'react-intl';
|
|||||||
import {
|
import {
|
||||||
fetchContext,
|
fetchContext,
|
||||||
completeContextRefresh,
|
completeContextRefresh,
|
||||||
|
showPendingReplies,
|
||||||
|
clearPendingReplies,
|
||||||
} 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';
|
||||||
@ -34,10 +36,6 @@ const messages = defineMessages({
|
|||||||
id: 'status.context.loading',
|
id: 'status.context.loading',
|
||||||
defaultMessage: 'Loading',
|
defaultMessage: 'Loading',
|
||||||
},
|
},
|
||||||
loadingMore: {
|
|
||||||
id: 'status.context.loading_more',
|
|
||||||
defaultMessage: 'Loading more replies',
|
|
||||||
},
|
|
||||||
success: {
|
success: {
|
||||||
id: 'status.context.loading_success',
|
id: 'status.context.loading_success',
|
||||||
defaultMessage: 'All replies loaded',
|
defaultMessage: 'All replies loaded',
|
||||||
@ -52,36 +50,33 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoadingState =
|
type LoadingState = 'idle' | 'more-available' | 'loading' | 'success' | 'error';
|
||||||
| '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(
|
|
||||||
(state) => state.contexts.refreshing[statusId],
|
|
||||||
);
|
|
||||||
const currentReplyCount = useAppSelector(
|
|
||||||
(state) => state.contexts.replies[statusId]?.length ?? 0,
|
|
||||||
);
|
|
||||||
const autoRefresh = !currentReplyCount;
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const [loadingState, setLoadingState] = useState<LoadingState>(
|
const refreshHeader = useAppSelector(
|
||||||
refresh && autoRefresh ? 'loading-initial' : 'idle',
|
(state) => state.contexts.refreshing[statusId],
|
||||||
);
|
);
|
||||||
|
const hasPendingReplies = useAppSelector(
|
||||||
|
(state) => !!state.contexts.pendingReplies[statusId]?.length,
|
||||||
|
);
|
||||||
|
const [partialLoadingState, setLoadingState] = useState<LoadingState>(
|
||||||
|
refreshHeader ? 'loading' : 'idle',
|
||||||
|
);
|
||||||
|
const loadingState = hasPendingReplies
|
||||||
|
? 'more-available'
|
||||||
|
: partialLoadingState;
|
||||||
|
|
||||||
const [wasDismissed, setWasDismissed] = useState(false);
|
const [wasDismissed, setWasDismissed] = useState(false);
|
||||||
const dismissPrompt = useCallback(() => {
|
const dismissPrompt = useCallback(() => {
|
||||||
setWasDismissed(true);
|
setWasDismissed(true);
|
||||||
setLoadingState('idle');
|
setLoadingState('idle');
|
||||||
}, []);
|
dispatch(clearPendingReplies({ statusId }));
|
||||||
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timeoutId: ReturnType<typeof setTimeout>;
|
let timeoutId: ReturnType<typeof setTimeout>;
|
||||||
@ -89,36 +84,51 @@ export const RefreshController: React.FC<{
|
|||||||
const scheduleRefresh = (refresh: AsyncRefreshHeader) => {
|
const scheduleRefresh = (refresh: AsyncRefreshHeader) => {
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(() => {
|
||||||
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
void apiGetAsyncRefresh(refresh.id).then((result) => {
|
||||||
if (result.async_refresh.status === 'finished') {
|
// If the refresh status is not finished,
|
||||||
dispatch(completeContextRefresh({ statusId }));
|
// schedule another refresh and exit
|
||||||
|
if (result.async_refresh.status !== 'finished') {
|
||||||
if (result.async_refresh.result_count > 0) {
|
|
||||||
if (autoRefresh) {
|
|
||||||
void dispatch(fetchContext({ statusId })).then(() => {
|
|
||||||
setLoadingState('idle');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setLoadingState('more-available');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setLoadingState('idle');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
scheduleRefresh(refresh);
|
scheduleRefresh(refresh);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Refresh status is finished. The action below will clear `refreshHeader`
|
||||||
|
dispatch(completeContextRefresh({ statusId }));
|
||||||
|
|
||||||
|
// Exit if there's nothing to fetch
|
||||||
|
if (result.async_refresh.result_count === 0) {
|
||||||
|
setLoadingState('idle');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A positive result count means there _might_ be new replies,
|
||||||
|
// so we fetch the context in the background to check if there
|
||||||
|
// are any new replies.
|
||||||
|
// If so, they will populate `contexts.pendingReplies[statusId]`
|
||||||
|
void dispatch(fetchContext({ statusId, prefetchOnly: true }))
|
||||||
|
.then(() => {
|
||||||
|
// Reset loading state to `idle` – but if the fetch
|
||||||
|
// has resulted in new pending replies, the `hasPendingReplies`
|
||||||
|
// flag will switch the loading state to 'more-available'
|
||||||
|
setLoadingState('idle');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Show an error if the fetch failed
|
||||||
|
setLoadingState('error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, refresh.retry * 1000);
|
}, refresh.retry * 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (refresh && !wasDismissed) {
|
// Initialise a refresh
|
||||||
scheduleRefresh(refresh);
|
if (refreshHeader && !wasDismissed) {
|
||||||
setLoadingState('loading-initial');
|
scheduleRefresh(refreshHeader);
|
||||||
|
setLoadingState('loading');
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [dispatch, statusId, refresh, autoRefresh, wasDismissed]);
|
}, [dispatch, statusId, refreshHeader, wasDismissed]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Hide success message after a short delay
|
// Hide success message after a short delay
|
||||||
@ -134,20 +144,19 @@ export const RefreshController: React.FC<{
|
|||||||
return () => '';
|
return () => '';
|
||||||
}, [loadingState]);
|
}, [loadingState]);
|
||||||
|
|
||||||
const handleClick = useCallback(() => {
|
useEffect(() => {
|
||||||
setLoadingState('loading-more');
|
// Clear pending replies on unmount
|
||||||
|
return () => {
|
||||||
dispatch(fetchContext({ statusId }))
|
dispatch(clearPendingReplies({ statusId }));
|
||||||
.then(() => {
|
};
|
||||||
setLoadingState('success');
|
|
||||||
return '';
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLoadingState('error');
|
|
||||||
});
|
|
||||||
}, [dispatch, statusId]);
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
if (loadingState === 'loading-initial') {
|
const handleClick = useCallback(() => {
|
||||||
|
dispatch(showPendingReplies({ statusId }));
|
||||||
|
setLoadingState('success');
|
||||||
|
}, [dispatch, statusId]);
|
||||||
|
|
||||||
|
if (loadingState === 'loading') {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='load-more load-gap'
|
className='load-more load-gap'
|
||||||
@ -170,13 +179,6 @@ export const RefreshController: React.FC<{
|
|||||||
onDismiss={dismissPrompt}
|
onDismiss={dismissPrompt}
|
||||||
animateFrom='below'
|
animateFrom='below'
|
||||||
/>
|
/>
|
||||||
<AnimatedAlert
|
|
||||||
isLoading
|
|
||||||
withEntryDelay
|
|
||||||
isActive={loadingState === 'loading-more'}
|
|
||||||
message={intl.formatMessage(messages.loadingMore)}
|
|
||||||
animateFrom='below'
|
|
||||||
/>
|
|
||||||
<AnimatedAlert
|
<AnimatedAlert
|
||||||
withEntryDelay
|
withEntryDelay
|
||||||
isActive={loadingState === 'error'}
|
isActive={loadingState === 'error'}
|
||||||
|
|||||||
@ -603,7 +603,7 @@ class Status extends ImmutablePureComponent {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll} childRef={this.setContainerRef}>
|
<ScrollContainer scrollKey='thread' shouldUpdateScroll={this.shouldUpdateScroll} childRef={this.setContainerRef}>
|
||||||
<div className={classNames('scrollable item-list', { fullscreen })} ref={this.setContainerRef}>
|
<div className={classNames('item-list scrollable scrollable--flex', { fullscreen })} ref={this.setContainerRef}>
|
||||||
{ancestors}
|
{ancestors}
|
||||||
|
|
||||||
<Hotkeys handlers={handlers}>
|
<Hotkeys handlers={handlers}>
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import InlineAccount from 'mastodon/components/inline_account';
|
|||||||
import MediaAttachments from 'mastodon/components/media_attachments';
|
import MediaAttachments from 'mastodon/components/media_attachments';
|
||||||
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
|
||||||
import emojify from 'mastodon/features/emoji/emoji';
|
import emojify from 'mastodon/features/emoji/emoji';
|
||||||
|
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||||
|
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||||
|
|
||||||
const mapStateToProps = (state, { statusId }) => ({
|
const mapStateToProps = (state, { statusId }) => ({
|
||||||
language: state.getIn(['statuses', statusId, 'language']),
|
language: state.getIn(['statuses', statusId, 'language']),
|
||||||
@ -51,8 +53,8 @@ class CompareHistoryModal extends PureComponent {
|
|||||||
return obj;
|
return obj;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
const content = { __html: emojify(currentVersion.get('content'), emojiMap) };
|
const content = emojify(currentVersion.get('content'), emojiMap);
|
||||||
const spoilerContent = { __html: emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap) };
|
const spoilerContent = emojify(escapeTextContentForBrowser(currentVersion.get('spoiler_text')), emojiMap);
|
||||||
|
|
||||||
const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
|
const formattedDate = <RelativeTimestamp timestamp={currentVersion.get('created_at')} short={false} />;
|
||||||
const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
|
const formattedName = <InlineAccount accountId={currentVersion.get('account')} />;
|
||||||
@ -65,43 +67,50 @@ class CompareHistoryModal extends PureComponent {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='modal-root__modal compare-history-modal'>
|
<div className='modal-root__modal compare-history-modal'>
|
||||||
<div className='report-modal__target'>
|
<CustomEmojiProvider emojis={currentVersion.get('emojis')}>
|
||||||
<IconButton className='report-modal__close' icon='times' iconComponent={CloseIcon} onClick={onClose} size={20} />
|
<div className='report-modal__target'>
|
||||||
{label}
|
<IconButton className='report-modal__close' icon='times' iconComponent={CloseIcon} onClick={onClose} size={20} />
|
||||||
</div>
|
{label}
|
||||||
|
|
||||||
<div className='compare-history-modal__container'>
|
|
||||||
<div className='status__content'>
|
|
||||||
{currentVersion.get('spoiler_text').length > 0 && (
|
|
||||||
<>
|
|
||||||
<div className='translate' dangerouslySetInnerHTML={spoilerContent} lang={language} />
|
|
||||||
<hr />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className='status__content__text status__content__text--visible translate' dangerouslySetInnerHTML={content} lang={language} />
|
|
||||||
|
|
||||||
{!!currentVersion.get('poll') && (
|
|
||||||
<div className='poll'>
|
|
||||||
<ul>
|
|
||||||
{currentVersion.getIn(['poll', 'options']).map(option => (
|
|
||||||
<li key={option.get('title')}>
|
|
||||||
<span className='poll__input disabled' />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className='poll__option__text translate'
|
|
||||||
dangerouslySetInnerHTML={{ __html: emojify(escapeTextContentForBrowser(option.get('title')), emojiMap) }}
|
|
||||||
lang={language}
|
|
||||||
/>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<MediaAttachments status={currentVersion} lang={language} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className='compare-history-modal__container'>
|
||||||
|
<div className='status__content'>
|
||||||
|
{currentVersion.get('spoiler_text').length > 0 && (
|
||||||
|
<>
|
||||||
|
<EmojiHTML className='translate' htmlString={spoilerContent} lang={language} />
|
||||||
|
<hr />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EmojiHTML
|
||||||
|
className='status__content__text status__content__text--visible translate'
|
||||||
|
htmlString={content}
|
||||||
|
lang={language}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!!currentVersion.get('poll') && (
|
||||||
|
<div className='poll'>
|
||||||
|
<ul>
|
||||||
|
{currentVersion.getIn(['poll', 'options']).map(option => (
|
||||||
|
<li key={option.get('title')}>
|
||||||
|
<span className='poll__input disabled' />
|
||||||
|
|
||||||
|
<EmojiHTML
|
||||||
|
as="span"
|
||||||
|
className='poll__option__text translate'
|
||||||
|
htmlString={emojify(escapeTextContentForBrowser(option.get('title')), emojiMap)}
|
||||||
|
lang={language}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MediaAttachments status={currentVersion} lang={language} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CustomEmojiProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { isFulfilled, isRejected } from '@reduxjs/toolkit';
|
|||||||
import { openURL } from 'mastodon/actions/search';
|
import { openURL } from 'mastodon/actions/search';
|
||||||
import { useAppDispatch } from 'mastodon/store';
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
import { isModernEmojiEnabled } from '../utils/environment';
|
||||||
|
|
||||||
const isMentionClick = (element: HTMLAnchorElement) =>
|
const isMentionClick = (element: HTMLAnchorElement) =>
|
||||||
element.classList.contains('mention') &&
|
element.classList.contains('mention') &&
|
||||||
!element.classList.contains('hashtag');
|
!element.classList.contains('hashtag');
|
||||||
@ -53,6 +55,11 @@ export const useLinks = (skipHashtags?: boolean) => {
|
|||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
|
// Exit early if modern emoji is enabled, as this is handled by HandledLink.
|
||||||
|
if (isModernEmojiEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const target = (e.target as HTMLElement).closest('a');
|
const target = (e.target as HTMLElement).closest('a');
|
||||||
|
|
||||||
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
|
||||||
|
|||||||
@ -875,7 +875,6 @@
|
|||||||
"status.contains_quote": "Утрымлівае цытату",
|
"status.contains_quote": "Утрымлівае цытату",
|
||||||
"status.context.loading": "Загружаюцца іншыя адказы",
|
"status.context.loading": "Загружаюцца іншыя адказы",
|
||||||
"status.context.loading_error": "Немагчыма загрузіць новыя адказы",
|
"status.context.loading_error": "Немагчыма загрузіць новыя адказы",
|
||||||
"status.context.loading_more": "Загружаюцца іншыя адказы",
|
|
||||||
"status.context.loading_success": "Усе адказы загружаныя",
|
"status.context.loading_success": "Усе адказы загружаныя",
|
||||||
"status.context.more_replies_found": "Знойдзеныя іншыя адказы",
|
"status.context.more_replies_found": "Знойдзеныя іншыя адказы",
|
||||||
"status.context.retry": "Паспрабаваць зноў",
|
"status.context.retry": "Паспрабаваць зноў",
|
||||||
|
|||||||
@ -28,6 +28,7 @@
|
|||||||
"account.disable_notifications": "Paouez d'am c'hemenn pa vez embannet traoù gant @{name}",
|
"account.disable_notifications": "Paouez d'am c'hemenn pa vez embannet traoù gant @{name}",
|
||||||
"account.domain_blocking": "Domani stanket",
|
"account.domain_blocking": "Domani stanket",
|
||||||
"account.edit_profile": "Kemmañ ar profil",
|
"account.edit_profile": "Kemmañ ar profil",
|
||||||
|
"account.edit_profile_short": "Kemmañ",
|
||||||
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
|
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
|
||||||
"account.endorse": "Lakaat en a-raok war ar profil",
|
"account.endorse": "Lakaat en a-raok war ar profil",
|
||||||
"account.familiar_followers_one": "Heuliet gant {name1}",
|
"account.familiar_followers_one": "Heuliet gant {name1}",
|
||||||
@ -39,6 +40,7 @@
|
|||||||
"account.featured_tags.last_status_never": "Embann ebet",
|
"account.featured_tags.last_status_never": "Embann ebet",
|
||||||
"account.follow": "Heuliañ",
|
"account.follow": "Heuliañ",
|
||||||
"account.follow_back": "Heuliañ d'ho tro",
|
"account.follow_back": "Heuliañ d'ho tro",
|
||||||
|
"account.follow_request_cancel_short": "Nullañ",
|
||||||
"account.followers": "Tud koumanantet",
|
"account.followers": "Tud koumanantet",
|
||||||
"account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.",
|
"account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.",
|
||||||
"account.followers_counter": "{count, plural, one {{counter} heulier} two {{counter} heulier} few {{counter} heulier} many {{counter} heulier} other {{counter} heulier}}",
|
"account.followers_counter": "{count, plural, one {{counter} heulier} two {{counter} heulier} few {{counter} heulier} many {{counter} heulier} other {{counter} heulier}}",
|
||||||
@ -216,7 +218,12 @@
|
|||||||
"confirmations.remove_from_followers.title": "Dilemel an heulier·ez?",
|
"confirmations.remove_from_followers.title": "Dilemel an heulier·ez?",
|
||||||
"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.unblock.confirm": "Distankañ",
|
||||||
|
"confirmations.unblock.title": "Distankañ {name}?",
|
||||||
"confirmations.unfollow.confirm": "Diheuliañ",
|
"confirmations.unfollow.confirm": "Diheuliañ",
|
||||||
|
"confirmations.unfollow.title": "Diheuliañ {name}?",
|
||||||
|
"confirmations.withdraw_request.confirm": "Nullañ ar reked",
|
||||||
|
"confirmations.withdraw_request.title": "Nullañ ho reked da heuliañ {name}?",
|
||||||
"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",
|
||||||
|
|||||||
@ -871,7 +871,6 @@
|
|||||||
"status.contains_quote": "Conté una cita",
|
"status.contains_quote": "Conté una cita",
|
||||||
"status.context.loading": "Es carreguen més respostes",
|
"status.context.loading": "Es carreguen més respostes",
|
||||||
"status.context.loading_error": "No s'han pogut carregar respostes noves",
|
"status.context.loading_error": "No s'han pogut carregar respostes noves",
|
||||||
"status.context.loading_more": "Es carreguen més respostes",
|
|
||||||
"status.context.loading_success": "S'han carregat totes les respostes",
|
"status.context.loading_success": "S'han carregat totes les respostes",
|
||||||
"status.context.more_replies_found": "S'han trobat més respostes",
|
"status.context.more_replies_found": "S'han trobat més respostes",
|
||||||
"status.context.retry": "Torna-ho a provar",
|
"status.context.retry": "Torna-ho a provar",
|
||||||
|
|||||||
@ -875,7 +875,6 @@
|
|||||||
"status.contains_quote": "Obsahuje citaci",
|
"status.contains_quote": "Obsahuje citaci",
|
||||||
"status.context.loading": "Načítání dalších odpovědí",
|
"status.context.loading": "Načítání dalších odpovědí",
|
||||||
"status.context.loading_error": "Nelze načíst nové odpovědi",
|
"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.loading_success": "Všechny odpovědi načteny",
|
||||||
"status.context.more_replies_found": "Nalezeny další odpovědi",
|
"status.context.more_replies_found": "Nalezeny další odpovědi",
|
||||||
"status.context.retry": "Zkusit znovu",
|
"status.context.retry": "Zkusit znovu",
|
||||||
|
|||||||
@ -870,7 +870,6 @@
|
|||||||
"status.contains_quote": "Yn cynnwys dyfyniad",
|
"status.contains_quote": "Yn cynnwys dyfyniad",
|
||||||
"status.context.loading": "Yn llwytho mwy o atebion",
|
"status.context.loading": "Yn llwytho mwy o atebion",
|
||||||
"status.context.loading_error": "Wedi methu llwytho atebion newydd",
|
"status.context.loading_error": "Wedi methu llwytho atebion newydd",
|
||||||
"status.context.loading_more": "Yn llwytho mwy o atebion",
|
|
||||||
"status.context.loading_success": "Wedi llwytho'r holl atebion",
|
"status.context.loading_success": "Wedi llwytho'r holl atebion",
|
||||||
"status.context.more_replies_found": "Mwy o atebion wedi'u canfod",
|
"status.context.more_replies_found": "Mwy o atebion wedi'u canfod",
|
||||||
"status.context.retry": "Ceisio eto",
|
"status.context.retry": "Ceisio eto",
|
||||||
|
|||||||
@ -875,7 +875,6 @@
|
|||||||
"status.contains_quote": "Indeholder citat",
|
"status.contains_quote": "Indeholder citat",
|
||||||
"status.context.loading": "Indlæser flere svar",
|
"status.context.loading": "Indlæser flere svar",
|
||||||
"status.context.loading_error": "Kunne ikke indlæse nye svar",
|
"status.context.loading_error": "Kunne ikke indlæse nye svar",
|
||||||
"status.context.loading_more": "Indlæser flere svar",
|
|
||||||
"status.context.loading_success": "Alle svar indlæst",
|
"status.context.loading_success": "Alle svar indlæst",
|
||||||
"status.context.more_replies_found": "Flere svar fundet",
|
"status.context.more_replies_found": "Flere svar fundet",
|
||||||
"status.context.retry": "Prøv igen",
|
"status.context.retry": "Prøv igen",
|
||||||
|
|||||||
@ -875,7 +875,6 @@
|
|||||||
"status.contains_quote": "Enthält Zitat",
|
"status.contains_quote": "Enthält Zitat",
|
||||||
"status.context.loading": "Weitere Antworten laden",
|
"status.context.loading": "Weitere Antworten laden",
|
||||||
"status.context.loading_error": "Weitere Antworten konnten nicht geladen werden",
|
"status.context.loading_error": "Weitere Antworten konnten nicht geladen werden",
|
||||||
"status.context.loading_more": "Weitere Antworten laden",
|
|
||||||
"status.context.loading_success": "Alle weiteren Antworten geladen",
|
"status.context.loading_success": "Alle weiteren Antworten geladen",
|
||||||
"status.context.more_replies_found": "Weitere Antworten verfügbar",
|
"status.context.more_replies_found": "Weitere Antworten verfügbar",
|
||||||
"status.context.retry": "Erneut versuchen",
|
"status.context.retry": "Erneut versuchen",
|
||||||
|
|||||||
@ -875,7 +875,6 @@
|
|||||||
"status.contains_quote": "Περιέχει παράθεση",
|
"status.contains_quote": "Περιέχει παράθεση",
|
||||||
"status.context.loading": "Φόρτωση περισσότερων απαντήσεων",
|
"status.context.loading": "Φόρτωση περισσότερων απαντήσεων",
|
||||||
"status.context.loading_error": "Αδυναμία φόρτωσης νέων απαντήσεων",
|
"status.context.loading_error": "Αδυναμία φόρτωσης νέων απαντήσεων",
|
||||||
"status.context.loading_more": "Φόρτωση περισσότερων απαντήσεων",
|
|
||||||
"status.context.loading_success": "Όλες οι απαντήσεις φορτώθηκαν",
|
"status.context.loading_success": "Όλες οι απαντήσεις φορτώθηκαν",
|
||||||
"status.context.more_replies_found": "Βρέθηκαν περισσότερες απαντήσεις",
|
"status.context.more_replies_found": "Βρέθηκαν περισσότερες απαντήσεις",
|
||||||
"status.context.retry": "Επανάληψη",
|
"status.context.retry": "Επανάληψη",
|
||||||
|
|||||||
@ -875,7 +875,6 @@
|
|||||||
"status.contains_quote": "Contains quote",
|
"status.contains_quote": "Contains quote",
|
||||||
"status.context.loading": "Loading more replies",
|
"status.context.loading": "Loading more replies",
|
||||||
"status.context.loading_error": "Couldn't load new replies",
|
"status.context.loading_error": "Couldn't load new replies",
|
||||||
"status.context.loading_more": "Loading more replies",
|
|
||||||
"status.context.loading_success": "All replies loaded",
|
"status.context.loading_success": "All replies loaded",
|
||||||
"status.context.more_replies_found": "More replies found",
|
"status.context.more_replies_found": "More replies found",
|
||||||
"status.context.retry": "Retry",
|
"status.context.retry": "Retry",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user