Merge pull request #3220 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to b8444d9bb7885d75112a3cae74b6a5c711c7d547
This commit is contained in:
Claire 2025-10-08 20:15:31 +02:00 committed by GitHub
commit 44b45ab0b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
188 changed files with 3356 additions and 1208 deletions

View File

@ -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

View File

@ -1 +1 @@
3.4.6 3.4.7

26
Gemfile
View File

@ -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

View File

@ -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

View File

@ -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 +

View File

@ -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

View File

@ -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>(

View File

@ -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 }) => {

View 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;
}

View File

@ -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'>

View File

@ -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>
); );
}; };

View File

@ -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',
};

View File

@ -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

View File

@ -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> };
);

View File

@ -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')}
/> />

View File

@ -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>

View File

@ -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

View File

@ -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'>

View File

@ -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 && (

View File

@ -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 && (
<> <>

View File

@ -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!',
},
};

View 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 '@/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 };
};

View File

@ -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>
); );
}; };

View File

@ -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}

View File

@ -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>
); );

View File

@ -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

View File

@ -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>

View File

@ -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) && (

View File

@ -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) && (

View File

@ -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) {

View File

@ -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')}
/> />
)} )}

View File

@ -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;

View File

@ -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[] {

View File

@ -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,

View File

@ -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'>

View File

@ -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'}
/>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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}
/> />
)} )}

View File

@ -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}
/> />
); );
}; };

View File

@ -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 && (
<> <>

View File

@ -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'}

View File

@ -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}>

View File

@ -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>
); );
} }

View File

@ -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) {

View File

@ -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(),
]); ]);
} }

View File

@ -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];
}) })

View File

@ -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;

View File

@ -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);
} }
& > * { & > * {

View File

@ -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;
} }

View File

@ -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,

View File

@ -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>(

View File

@ -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 }) => {

View 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;
}

View File

@ -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'>

View File

@ -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>
); );
}; };

View File

@ -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',
};

View File

@ -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

View File

@ -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>
);
};

View File

@ -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')}
/> />

View File

@ -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>

View File

@ -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

View File

@ -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'>

View File

@ -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 && (

View File

@ -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 && (
<> <>

View File

@ -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!',
},
};

View 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 };
};

View File

@ -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>
); );
}; };

View File

@ -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}

View File

@ -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>
); );

View File

@ -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>

View File

@ -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) && (

View File

@ -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) && (

View File

@ -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) {

View File

@ -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')}
/> />
)} )}

View File

@ -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;

View File

@ -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[] {

View File

@ -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,

View File

@ -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'>

View File

@ -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'}
/>
);
};

View File

@ -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;

View File

@ -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>
);
};

View File

@ -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>

View File

@ -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}
/> />
)} )}

View File

@ -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}
/> />
); );
}; };

View File

@ -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 && (
<> <>

View File

@ -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'}

View File

@ -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}>

View File

@ -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>
); );
} }

View File

@ -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) {

View File

@ -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": "Паспрабаваць зноў",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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": "Επανάληψη",

View File

@ -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