mirror of
https://github.com/mastodon/mastodon.git
synced 2026-04-21 22:54:58 -05:00
Merge branch 'main' into improve-devcontainer-config
This commit is contained in:
commit
f492b97739
|
|
@ -56,7 +56,7 @@ services:
|
|||
- internal_network
|
||||
|
||||
es:
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch-oss:7.10.2
|
||||
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.29
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ES_JAVA_OPTS: -Xms512m -Xmx512m
|
||||
|
|
|
|||
17
.github/workflows/haml-lint-problem-matcher.json
vendored
17
.github/workflows/haml-lint-problem-matcher.json
vendored
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "haml-lint",
|
||||
"severity": "warning",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.*):(\\d+)\\s\\[W]\\s(.*):\\s(.*)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"code": 3,
|
||||
"message": 4
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
2
.github/workflows/lint-css.yml
vendored
2
.github/workflows/lint-css.yml
vendored
|
|
@ -9,7 +9,6 @@ on:
|
|||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '.nvmrc'
|
||||
- '.prettier*'
|
||||
- 'stylelint.config.js'
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
|
|
@ -21,7 +20,6 @@ on:
|
|||
- 'package.json'
|
||||
- 'yarn.lock'
|
||||
- '.nvmrc'
|
||||
- '.prettier*'
|
||||
- 'stylelint.config.js'
|
||||
- '**/*.css'
|
||||
- '**/*.scss'
|
||||
|
|
|
|||
1
.github/workflows/lint-haml.yml
vendored
1
.github/workflows/lint-haml.yml
vendored
|
|
@ -42,5 +42,4 @@ jobs:
|
|||
|
||||
- name: Run haml-lint
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/haml-lint-problem-matcher.json"
|
||||
bin/haml-lint --reporter github
|
||||
|
|
|
|||
2
.github/workflows/lint-js.yml
vendored
2
.github/workflows/lint-js.yml
vendored
|
|
@ -10,7 +10,6 @@ on:
|
|||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- '.nvmrc'
|
||||
- '.prettier*'
|
||||
- 'eslint.config.mjs'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
|
|
@ -24,7 +23,6 @@ on:
|
|||
- 'yarn.lock'
|
||||
- 'tsconfig.json'
|
||||
- '.nvmrc'
|
||||
- '.prettier*'
|
||||
- 'eslint.config.mjs'
|
||||
- '**/*.js'
|
||||
- '**/*.jsx'
|
||||
|
|
|
|||
4
.github/workflows/test-ruby.yml
vendored
4
.github/workflows/test-ruby.yml
vendored
|
|
@ -352,10 +352,10 @@ jobs:
|
|||
- '3.3'
|
||||
- '.ruby-version'
|
||||
search-image:
|
||||
- docker.elastic.co/elasticsearch/elasticsearch:7.17.13
|
||||
- docker.elastic.co/elasticsearch/elasticsearch:7.17.29
|
||||
include:
|
||||
- ruby-version: '.ruby-version'
|
||||
search-image: docker.elastic.co/elasticsearch/elasticsearch:8.10.2
|
||||
search-image: docker.elastic.co/elasticsearch/elasticsearch:8.19.2
|
||||
- ruby-version: '.ruby-version'
|
||||
search-image: opensearchproject/opensearch:2
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,6 @@ linters:
|
|||
MiddleDot:
|
||||
enabled: true
|
||||
LineLength:
|
||||
max: 300
|
||||
max: 240 # Override default value of 80 inherited from rubocop
|
||||
ViewLength:
|
||||
max: 200 # Override default value of 100 inherited from rubocop
|
||||
|
|
|
|||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -2,6 +2,26 @@
|
|||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [4.5.7] - 2026-02-24
|
||||
|
||||
### Security
|
||||
|
||||
- Reject unconfirmed FASPs (#37926 by @oneiros, [GHSA-qgmm-vr4c-ggjg](https://github.com/mastodon/mastodon/security/advisories/GHSA-qgmm-vr4c-ggjg))
|
||||
- Re-use custom socket class for FASP requests (#37925 by @oneiros, [GHSA-46w6-g98f-wxqm](https://github.com/mastodon/mastodon/security/advisories/GHSA-46w6-g98f-wxqm))
|
||||
|
||||
### Added
|
||||
|
||||
- Add `--suspended-only` option to `tootctl emoji purge` (#37828 and #37861 by @ClearlyClaire and @mjankowski)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix emoji data not being properly cached (#37858 by @ChaosExAnima)
|
||||
- Fix delete & redraft of pending posts (#37839 by @ClearlyClaire)
|
||||
- Fix processing separate key documents without the ActivityStreams context (#37826 by @ClearlyClaire)
|
||||
- Fix custom emojis not being purged on domain suspension (#37808 by @ClearlyClaire)
|
||||
- Fix users without special permissions being able to stream disabled timelines (#37791 by @ClearlyClaire)
|
||||
- Fix processing of object updates with duplicate hashtags (#37756 by @ClearlyClaire)
|
||||
|
||||
## [4.5.6] - 2026-02-03
|
||||
|
||||
### Security
|
||||
|
|
|
|||
7
Gemfile
7
Gemfile
|
|
@ -5,7 +5,7 @@ ruby '>= 3.2.0', '< 3.5.0'
|
|||
|
||||
gem 'propshaft'
|
||||
gem 'puma', '~> 7.0'
|
||||
gem 'rails', '~> 8.0'
|
||||
gem 'rails', '~> 8.1.0'
|
||||
gem 'thor', '~> 1.2'
|
||||
|
||||
gem 'dotenv'
|
||||
|
|
@ -129,9 +129,6 @@ group :test do
|
|||
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
|
||||
gem 'rspec-github', '~> 3.0', require: false
|
||||
|
||||
# RSpec helpers for email specs
|
||||
gem 'email_spec'
|
||||
|
||||
# Extra RSpec extension methods and helpers for sidekiq
|
||||
gem 'rspec-sidekiq', '~> 5.0'
|
||||
|
||||
|
|
@ -180,7 +177,7 @@ group :development do
|
|||
|
||||
# Enhanced error message pages for development
|
||||
gem 'better_errors', '~> 2.9'
|
||||
gem 'binding_of_caller', '~> 1.0'
|
||||
gem 'binding_of_caller'
|
||||
|
||||
# Preview mail in the browser
|
||||
gem 'letter_opener', '~> 1.8'
|
||||
|
|
|
|||
178
Gemfile.lock
178
Gemfile.lock
|
|
@ -10,29 +10,31 @@ GIT
|
|||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
action_text-trix (2.1.16)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
rack-session (>= 1.0.1)
|
||||
|
|
@ -40,15 +42,16 @@ GEM
|
|||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
actiontext (8.1.2)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
|
|
@ -58,35 +61,35 @@ GEM
|
|||
activemodel (>= 4.1)
|
||||
case_transform (>= 0.2)
|
||||
jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
|
||||
activejob (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
activerecord (8.0.3)
|
||||
activemodel (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.0.3)
|
||||
activesupport (8.1.2)
|
||||
base64
|
||||
benchmark (>= 0.3)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.8)
|
||||
addressable (2.8.9)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
android_key_attestation (0.3.0)
|
||||
|
|
@ -96,7 +99,7 @@ GEM
|
|||
ast (2.4.3)
|
||||
attr_required (1.0.2)
|
||||
aws-eventstream (1.4.0)
|
||||
aws-partitions (1.1213.0)
|
||||
aws-partitions (1.1220.0)
|
||||
aws-sdk-core (3.242.0)
|
||||
aws-eventstream (~> 1, >= 1.3.0)
|
||||
aws-partitions (~> 1, >= 1.992.0)
|
||||
|
|
@ -105,7 +108,7 @@ GEM
|
|||
bigdecimal
|
||||
jmespath (~> 1, >= 1.6.1)
|
||||
logger
|
||||
aws-sdk-kms (1.121.0)
|
||||
aws-sdk-kms (1.122.0)
|
||||
aws-sdk-core (~> 3, >= 3.241.4)
|
||||
aws-sigv4 (~> 1.5)
|
||||
aws-sdk-s3 (1.213.0)
|
||||
|
|
@ -126,12 +129,12 @@ GEM
|
|||
rouge (>= 1.0.0)
|
||||
bigdecimal (3.3.1)
|
||||
bindata (2.5.1)
|
||||
binding_of_caller (1.0.1)
|
||||
binding_of_caller (2.0.0)
|
||||
debug_inspector (>= 1.2.0)
|
||||
blurhash (0.1.8)
|
||||
bootsnap (1.22.0)
|
||||
bootsnap (1.23.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (8.0.2)
|
||||
brakeman (8.0.4)
|
||||
racc
|
||||
browser (6.2.0)
|
||||
builder (3.3.0)
|
||||
|
|
@ -221,13 +224,9 @@ GEM
|
|||
base64
|
||||
faraday (>= 1, < 3)
|
||||
multi_json
|
||||
email_spec (2.3.0)
|
||||
htmlentities (~> 4.3.3)
|
||||
launchy (>= 2.1, < 4.0)
|
||||
mail (~> 2.7)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.1)
|
||||
erb (6.0.2)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.4.0)
|
||||
tzinfo
|
||||
|
|
@ -291,7 +290,7 @@ GEM
|
|||
activesupport (>= 5.1)
|
||||
haml (>= 4.0.6)
|
||||
railties (>= 5.1)
|
||||
haml_lint (0.69.0)
|
||||
haml_lint (0.72.0)
|
||||
haml (>= 5.0)
|
||||
parallel (~> 1.10)
|
||||
rainbow
|
||||
|
|
@ -308,7 +307,7 @@ GEM
|
|||
hiredis-client (0.26.4)
|
||||
redis-client (= 0.26.4)
|
||||
hkdf (0.3.0)
|
||||
htmlentities (4.3.4)
|
||||
htmlentities (4.4.2)
|
||||
http (5.3.1)
|
||||
addressable (~> 2.8)
|
||||
http-cookie (~> 1.0)
|
||||
|
|
@ -411,12 +410,12 @@ GEM
|
|||
rexml
|
||||
link_header (0.0.8)
|
||||
lint_roller (1.1.0)
|
||||
linzer (0.7.7)
|
||||
cgi (~> 0.4.2)
|
||||
linzer (0.7.8)
|
||||
cgi (>= 0.4.2, < 0.6.0)
|
||||
forwardable (~> 1.3, >= 1.3.3)
|
||||
logger (~> 1.7, >= 1.7.0)
|
||||
net-http (~> 0.6.0)
|
||||
openssl (~> 3.0, >= 3.0.0)
|
||||
net-http (>= 0.6, < 0.10)
|
||||
openssl (>= 3, < 5)
|
||||
rack (>= 2.2, < 4.0)
|
||||
starry (~> 0.2)
|
||||
stringio (~> 3.1, >= 3.1.2)
|
||||
|
|
@ -447,17 +446,18 @@ GEM
|
|||
mime-types (3.7.0)
|
||||
logger
|
||||
mime-types-data (~> 3.2025, >= 3.2025.0507)
|
||||
mime-types-data (3.2026.0203)
|
||||
mime-types-data (3.2026.0224)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.9)
|
||||
minitest (6.0.1)
|
||||
minitest (6.0.2)
|
||||
drb (~> 2.0)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
multi_json (1.19.1)
|
||||
mutex_m (0.3.0)
|
||||
net-http (0.6.0)
|
||||
uri
|
||||
net-imap (0.6.2)
|
||||
net-imap (0.6.3)
|
||||
date
|
||||
net-protocol
|
||||
net-ldap (0.20.0)
|
||||
|
|
@ -488,7 +488,7 @@ GEM
|
|||
omniauth-rails_csrf_protection (2.0.1)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth-saml (2.2.4)
|
||||
omniauth-saml (2.2.5)
|
||||
omniauth (~> 2.1)
|
||||
ruby-saml (~> 1.18)
|
||||
omniauth_openid_connect (0.8.0)
|
||||
|
|
@ -590,7 +590,7 @@ GEM
|
|||
ox (2.14.23)
|
||||
bigdecimal (>= 3.0)
|
||||
parallel (1.27.0)
|
||||
parser (3.3.10.1)
|
||||
parser (3.3.10.2)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
parslet (2.0.0)
|
||||
|
|
@ -624,7 +624,7 @@ GEM
|
|||
psych (5.3.1)
|
||||
date
|
||||
stringio
|
||||
public_suffix (7.0.2)
|
||||
public_suffix (7.0.5)
|
||||
puma (7.2.0)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.5.2)
|
||||
|
|
@ -657,33 +657,33 @@ GEM
|
|||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rack (>= 3)
|
||||
rails (8.0.3)
|
||||
actioncable (= 8.0.3)
|
||||
actionmailbox (= 8.0.3)
|
||||
actionmailer (= 8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
actiontext (= 8.0.3)
|
||||
actionview (= 8.0.3)
|
||||
activejob (= 8.0.3)
|
||||
activemodel (= 8.0.3)
|
||||
activerecord (= 8.0.3)
|
||||
activestorage (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.3)
|
||||
railties (= 8.1.2)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
loofah (~> 2.21)
|
||||
rails-html-sanitizer (1.7.0)
|
||||
loofah (~> 2.25)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.1.0)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.0.3)
|
||||
actionpack (= 8.0.3)
|
||||
activesupport (= 8.0.3)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
|
|
@ -701,7 +701,7 @@ GEM
|
|||
readline (~> 0.0)
|
||||
rdf-normalize (0.7.0)
|
||||
rdf (~> 3.3)
|
||||
rdoc (7.1.0)
|
||||
rdoc (7.2.0)
|
||||
erb
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
|
|
@ -749,7 +749,7 @@ GEM
|
|||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-sidekiq (5.2.0)
|
||||
rspec-sidekiq (5.3.0)
|
||||
rspec-core (~> 3.0)
|
||||
rspec-expectations (~> 3.0)
|
||||
rspec-mocks (~> 3.0)
|
||||
|
|
@ -792,8 +792,9 @@ GEM
|
|||
lint_roller (~> 1.1)
|
||||
rubocop (~> 1.72, >= 1.72.1)
|
||||
rubocop-rspec (~> 3.5)
|
||||
ruby-prof (1.7.2)
|
||||
ruby-prof (2.0.2)
|
||||
base64
|
||||
ostruct
|
||||
ruby-progressbar (1.13.0)
|
||||
ruby-saml (1.18.1)
|
||||
nokogiri (>= 1.13.10)
|
||||
|
|
@ -887,7 +888,7 @@ GEM
|
|||
unf (~> 0.1.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
tzinfo-data (1.2025.3)
|
||||
tzinfo-data (1.2026.1)
|
||||
tzinfo (>= 1.0.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
|
|
@ -903,7 +904,7 @@ GEM
|
|||
vite_rails (3.0.20)
|
||||
railties (>= 5.1, < 9)
|
||||
vite_ruby (~> 3.0, >= 3.2.2)
|
||||
vite_ruby (3.9.2)
|
||||
vite_ruby (3.9.3)
|
||||
dry-cli (>= 0.7, < 2)
|
||||
logger (~> 1.6)
|
||||
mutex_m
|
||||
|
|
@ -936,7 +937,7 @@ GEM
|
|||
xorcist (1.1.3)
|
||||
xpath (3.2.0)
|
||||
nokogiri (~> 1.8)
|
||||
zeitwerk (2.7.4)
|
||||
zeitwerk (2.7.5)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
|
@ -948,7 +949,7 @@ DEPENDENCIES
|
|||
aws-sdk-core
|
||||
aws-sdk-s3 (~> 1.123)
|
||||
better_errors (~> 2.9)
|
||||
binding_of_caller (~> 1.0)
|
||||
binding_of_caller
|
||||
blurhash (~> 0.1)
|
||||
bootsnap
|
||||
brakeman (~> 8.0)
|
||||
|
|
@ -972,7 +973,6 @@ DEPENDENCIES
|
|||
discard (~> 1.2)
|
||||
doorkeeper (~> 5.6)
|
||||
dotenv
|
||||
email_spec
|
||||
fabrication
|
||||
faker (~> 3.2)
|
||||
faraday-httpclient
|
||||
|
|
@ -1050,7 +1050,7 @@ DEPENDENCIES
|
|||
rack-attack (~> 6.6)
|
||||
rack-cors
|
||||
rack-test (~> 2.1)
|
||||
rails (~> 8.0)
|
||||
rails (~> 8.1.0)
|
||||
rails-i18n (~> 8.0)
|
||||
rdf-normalize (~> 0.5)
|
||||
redcarpet (~> 3.6)
|
||||
|
|
@ -1100,4 +1100,4 @@ RUBY VERSION
|
|||
ruby 3.4.8
|
||||
|
||||
BUNDLED WITH
|
||||
4.0.6
|
||||
4.0.7
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ class AccountsController < ApplicationController
|
|||
respond_to do |format|
|
||||
format.html do
|
||||
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.hour) unless user_signed_in?
|
||||
|
||||
redirect_to short_account_path(@account) if account_id_param.present? && username_param.blank?
|
||||
end
|
||||
|
||||
format.rss do
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class ActivityPub::FeatureAuthorizationsController < ActivityPub::BaseController
|
||||
include Authorization
|
||||
|
||||
vary_by -> { 'Signature' if authorized_fetch_mode? }
|
||||
|
||||
before_action :require_account_signature!, if: :authorized_fetch_mode?
|
||||
before_action :set_collection_item
|
||||
|
||||
def show
|
||||
expires_in 30.seconds, public: true if public_fetch_mode?
|
||||
render json: @collection_item, serializer: ActivityPub::FeatureAuthorizationSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pundit_user
|
||||
signed_request_account
|
||||
end
|
||||
|
||||
def set_collection_item
|
||||
@collection_item = @account.collection_items.accepted.find(params[:id])
|
||||
|
||||
authorize @collection_item.collection, :show?
|
||||
rescue ActiveRecord::RecordNotFound, Mastodon::NotPermittedError
|
||||
not_found
|
||||
end
|
||||
end
|
||||
|
|
@ -54,7 +54,7 @@ module Admin
|
|||
end
|
||||
|
||||
# Allow transparently upgrading a domain block
|
||||
if existing_domain_block.present? && existing_domain_block.domain == TagManager.instance.normalize_domain(@domain_block.domain.strip)
|
||||
if existing_domain_block.present? && existing_domain_block.domain == TagManager.instance.normalize_domain(@domain_block.domain)
|
||||
@domain_block = existing_domain_block
|
||||
@domain_block.assign_attributes(resource_params)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -34,8 +34,11 @@ class Admin::Instances::ModerationNotesController < Admin::BaseController
|
|||
end
|
||||
|
||||
def set_instance
|
||||
domain = params[:instance_id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
@instance = Instance.find_or_initialize_by(domain: normalized_domain)
|
||||
end
|
||||
|
||||
def normalized_domain
|
||||
TagManager.instance.normalize_domain(params[:instance_id])
|
||||
end
|
||||
|
||||
def set_instance_note
|
||||
|
|
|
|||
|
|
@ -55,8 +55,11 @@ module Admin
|
|||
private
|
||||
|
||||
def set_instance
|
||||
domain = params[:id]&.strip
|
||||
@instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(domain))
|
||||
@instance = Instance.find_or_initialize_by(domain: normalized_domain)
|
||||
end
|
||||
|
||||
def normalized_domain
|
||||
TagManager.instance.normalize_domain(params[:id])
|
||||
end
|
||||
|
||||
def set_instances
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
|
||||
case action_from_button
|
||||
when 'delete', 'mark_as_sensitive'
|
||||
Admin::StatusBatchAction.new(status_batch_action_params).save!
|
||||
Admin::ModerationAction.new(moderation_action_params).save!
|
||||
when 'silence', 'suspend'
|
||||
Admin::AccountAction.new(account_action_params).save!
|
||||
else
|
||||
|
|
@ -25,9 +25,8 @@ class Admin::Reports::ActionsController < Admin::BaseController
|
|||
|
||||
private
|
||||
|
||||
def status_batch_action_params
|
||||
def moderation_action_params
|
||||
shared_params
|
||||
.merge(status_ids: @report.status_ids)
|
||||
end
|
||||
|
||||
def account_action_params
|
||||
|
|
|
|||
|
|
@ -78,8 +78,6 @@ module Admin
|
|||
'report'
|
||||
elsif params[:remove_from_report]
|
||||
'remove_from_report'
|
||||
elsif params[:delete]
|
||||
'delete'
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -47,10 +47,6 @@ class Api::V1::Peers::SearchController < Api::BaseController
|
|||
end
|
||||
|
||||
def normalized_domain
|
||||
TagManager.instance.normalize_domain(query_value)
|
||||
end
|
||||
|
||||
def query_value
|
||||
params[:q].strip
|
||||
TagManager.instance.normalize_domain(params[:q])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,11 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::ProfilesController < Api::BaseController
|
||||
before_action -> { doorkeeper_authorize! :profile, :read, :'read:accounts' }
|
||||
before_action -> { doorkeeper_authorize! :profile, :read, :'read:accounts' }, except: [:update]
|
||||
before_action -> { doorkeeper_authorize! :write, :'write:accounts' }, only: [:update]
|
||||
before_action :require_user!
|
||||
|
||||
def show
|
||||
@account = current_account
|
||||
render json: @account, serializer: REST::ProfileSerializer
|
||||
end
|
||||
|
||||
def update
|
||||
@account = current_account
|
||||
UpdateAccountService.new.call(@account, account_params, raise_error: true)
|
||||
ActivityPub::UpdateDistributionWorker.perform_in(ActivityPub::UpdateDistributionWorker::DEBOUNCE_DELAY, @account.id)
|
||||
|
||||
render json: @account, serializer: REST::ProfileSerializer
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
render json: ValidationErrorFormatter.new(e).as_json, status: 422
|
||||
end
|
||||
|
||||
def account_params
|
||||
params.permit(
|
||||
:display_name,
|
||||
:note,
|
||||
:avatar,
|
||||
:header,
|
||||
:locked,
|
||||
:bot,
|
||||
:discoverable,
|
||||
:hide_collections,
|
||||
:indexable,
|
||||
:show_media,
|
||||
:show_media_replies,
|
||||
:show_featured,
|
||||
attribution_domains: [],
|
||||
fields_attributes: [:name, :value]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -127,6 +127,8 @@ class Api::V1::StatusesController < Api::BaseController
|
|||
@status = Status.where(account: current_account).find(params[:id])
|
||||
authorize @status, :destroy?
|
||||
|
||||
# JSON is generated before `discard_with_reblogs` in order to have the proper URL
|
||||
# for media attachments, as it would otherwise redirect to the media proxy
|
||||
json = render_to_body json: @status, serializer: REST::StatusSerializer, source_requested: true
|
||||
|
||||
@status.discard_with_reblogs
|
||||
|
|
|
|||
|
|
@ -39,6 +39,6 @@ class Api::V1::TagsController < Api::BaseController
|
|||
def set_or_create_tag
|
||||
return not_found unless Tag::HASHTAG_NAME_RE.match?(params[:id])
|
||||
|
||||
@tag = Tag.find_normalized(params[:id]) || Tag.new(name: Tag.normalize(params[:id]), display_name: params[:id])
|
||||
@tag = Tag.find_normalized(params[:id]) || Tag.new(name: params[:id], display_name: params[:id])
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
|
|||
|
||||
before_action :set_collection
|
||||
before_action :set_account, only: [:create]
|
||||
before_action :set_collection_item, only: [:destroy]
|
||||
before_action :set_collection_item, only: [:destroy, :revoke]
|
||||
|
||||
after_action :verify_authorized
|
||||
|
||||
|
|
@ -32,6 +32,14 @@ class Api::V1Alpha::CollectionItemsController < Api::BaseController
|
|||
head 200
|
||||
end
|
||||
|
||||
def revoke
|
||||
authorize @collection_item, :revoke?
|
||||
|
||||
RevokeCollectionItemService.new.call(@collection_item)
|
||||
|
||||
head 200
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_collection
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ class StatusesController < ApplicationController
|
|||
respond_to do |format|
|
||||
format.html do
|
||||
expires_in 10.seconds, public: true if current_account.nil?
|
||||
|
||||
redirect_to short_account_status_path(@account, @status) if account_id_param.present? && username_param.blank?
|
||||
end
|
||||
|
||||
format.json do
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ module Admin::ActionLogsHelper
|
|||
link_to "##{log.human_identifier.presence || log.target_id}", admin_report_path(log.target_id)
|
||||
when 'Instance', 'DomainBlock', 'DomainAllow', 'UnavailableDomain'
|
||||
log.human_identifier.present? ? link_to(log.human_identifier, admin_instance_path(log.human_identifier)) : I18n.t('admin.action_logs.unavailable_instance')
|
||||
when 'Status'
|
||||
when 'Status', 'Collection'
|
||||
link_to log.human_identifier, log.permalink
|
||||
when 'AccountWarning'
|
||||
link_to log.human_identifier, disputes_strike_path(log.target_id)
|
||||
|
|
|
|||
|
|
@ -18,4 +18,12 @@ module RegistrationHelper
|
|||
def ip_blocked?(remote_ip)
|
||||
IpBlock.severity_sign_up_block.containing(remote_ip.to_s).exists?
|
||||
end
|
||||
|
||||
def terms_agreement_label
|
||||
if TermsOfService.live.exists?
|
||||
t('auth.user_agreement_html', privacy_policy_path: privacy_policy_path, terms_of_service_path: terms_of_service_path)
|
||||
else
|
||||
t('auth.user_privacy_agreement_html', privacy_policy_path: privacy_policy_path)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -23,6 +23,16 @@ module SettingsHelper
|
|||
)
|
||||
end
|
||||
|
||||
def author_attribution_name(account)
|
||||
return if account.nil?
|
||||
|
||||
link_to(root_url, class: 'story__details__shared__author-link') do
|
||||
safe_join(
|
||||
[image_tag(account.avatar.url, class: 'account__avatar', size: 16, alt: ''), tag.bdi(display_name(account))]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def session_device_icon(session)
|
||||
device = session.detection.device
|
||||
|
||||
|
|
|
|||
|
|
@ -182,15 +182,25 @@ function loaded() {
|
|||
({ target }) => {
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
|
||||
if (target.value && target.value.length > 0) {
|
||||
const checkedUsername = target.value;
|
||||
if (checkedUsername && checkedUsername.length > 0) {
|
||||
axios
|
||||
.get('/api/v1/accounts/lookup', { params: { acct: target.value } })
|
||||
.get('/api/v1/accounts/lookup', {
|
||||
params: { acct: checkedUsername },
|
||||
})
|
||||
.then(() => {
|
||||
target.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
// Only update the validity if the result is for the currently-typed username
|
||||
if (checkedUsername === target.value) {
|
||||
target.setCustomValidity(formatMessage(messages.usernameTaken));
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.catch(() => {
|
||||
target.setCustomValidity('');
|
||||
// Only update the validity if the result is for the currently-typed username
|
||||
if (checkedUsername === target.value) {
|
||||
target.setCustomValidity('');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
target.setCustomValidity('');
|
||||
|
|
|
|||
|
|
@ -179,3 +179,10 @@ export async function apiRequestDelete<
|
|||
>(url: ApiUrl, params?: RequestParamsOrData<ApiParams>) {
|
||||
return apiRequest<ApiResponse>('DELETE', url, { params });
|
||||
}
|
||||
|
||||
export async function apiRequestPatch<ApiResponse = unknown, ApiData = unknown>(
|
||||
url: ApiUrl,
|
||||
data?: RequestParamsOrData<ApiData>,
|
||||
) {
|
||||
return apiRequest<ApiResponse>('PATCH', url, { data });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
|
||||
import {
|
||||
apiRequestPost,
|
||||
apiRequestGet,
|
||||
apiRequestDelete,
|
||||
apiRequestPatch,
|
||||
} from 'mastodon/api';
|
||||
import type {
|
||||
ApiAccountJSON,
|
||||
ApiFamiliarFollowersJSON,
|
||||
} from 'mastodon/api_types/accounts';
|
||||
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
|
||||
import type { ApiHashtagJSON } from 'mastodon/api_types/tags';
|
||||
import type {
|
||||
ApiFeaturedTagJSON,
|
||||
ApiHashtagJSON,
|
||||
} from 'mastodon/api_types/tags';
|
||||
|
||||
import type {
|
||||
ApiProfileJSON,
|
||||
ApiProfileUpdateParams,
|
||||
} from '../api_types/profile';
|
||||
|
||||
export const apiSubmitAccountNote = (id: string, value: string) =>
|
||||
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
|
||||
|
|
@ -30,7 +43,19 @@ export const apiRemoveAccountFromFollowers = (id: string) =>
|
|||
);
|
||||
|
||||
export const apiGetFeaturedTags = (id: string) =>
|
||||
apiRequestGet<ApiHashtagJSON>(`v1/accounts/${id}/featured_tags`);
|
||||
apiRequestGet<ApiHashtagJSON[]>(`v1/accounts/${id}/featured_tags`);
|
||||
|
||||
export const apiGetCurrentFeaturedTags = () =>
|
||||
apiRequestGet<ApiFeaturedTagJSON[]>(`v1/featured_tags`);
|
||||
|
||||
export const apiPostFeaturedTag = (name: string) =>
|
||||
apiRequestPost<ApiFeaturedTagJSON>('v1/featured_tags', { name });
|
||||
|
||||
export const apiDeleteFeaturedTag = (id: string) =>
|
||||
apiRequestDelete(`v1/featured_tags/${id}`);
|
||||
|
||||
export const apiGetTagSuggestions = () =>
|
||||
apiRequestGet<ApiHashtagJSON[]>('v1/featured_tags/suggestions');
|
||||
|
||||
export const apiGetEndorsedAccounts = (id: string) =>
|
||||
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
|
||||
|
|
@ -39,3 +64,8 @@ export const apiGetFamiliarFollowers = (id: string) =>
|
|||
apiRequestGet<ApiFamiliarFollowersJSON>('v1/accounts/familiar_followers', {
|
||||
id,
|
||||
});
|
||||
|
||||
export const apiGetProfile = () => apiRequestGet<ApiProfileJSON>('v1/profile');
|
||||
|
||||
export const apiPatchProfile = (params: ApiProfileUpdateParams) =>
|
||||
apiRequestPatch<ApiProfileJSON>('v1/profile', params);
|
||||
|
|
|
|||
44
app/javascript/mastodon/api_types/profile.ts
Normal file
44
app/javascript/mastodon/api_types/profile.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { ApiAccountFieldJSON } from './accounts';
|
||||
import type { ApiFeaturedTagJSON } from './tags';
|
||||
|
||||
export interface ApiProfileJSON {
|
||||
id: string;
|
||||
display_name: string;
|
||||
note: string;
|
||||
fields: ApiAccountFieldJSON[];
|
||||
avatar: string;
|
||||
avatar_static: string;
|
||||
avatar_description: string;
|
||||
header: string;
|
||||
header_static: string;
|
||||
header_description: string;
|
||||
locked: boolean;
|
||||
bot: boolean;
|
||||
hide_collections: boolean;
|
||||
discoverable: boolean;
|
||||
indexable: boolean;
|
||||
show_media: boolean;
|
||||
show_media_replies: boolean;
|
||||
show_featured: boolean;
|
||||
attribution_domains: string[];
|
||||
featured_tags: ApiFeaturedTagJSON[];
|
||||
}
|
||||
|
||||
export type ApiProfileUpdateParams = Partial<
|
||||
Pick<
|
||||
ApiProfileJSON,
|
||||
| 'display_name'
|
||||
| 'note'
|
||||
| 'locked'
|
||||
| 'bot'
|
||||
| 'hide_collections'
|
||||
| 'discoverable'
|
||||
| 'indexable'
|
||||
| 'show_media'
|
||||
| 'show_media_replies'
|
||||
| 'show_featured'
|
||||
>
|
||||
> & {
|
||||
attribution_domains?: string[];
|
||||
fields_attributes?: Pick<ApiAccountFieldJSON, 'name' | 'value'>[];
|
||||
};
|
||||
|
|
@ -4,11 +4,19 @@ interface ApiHistoryJSON {
|
|||
uses: string;
|
||||
}
|
||||
|
||||
export interface ApiHashtagJSON {
|
||||
interface ApiHashtagBase {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface ApiHashtagJSON extends ApiHashtagBase {
|
||||
history: [ApiHistoryJSON, ...ApiHistoryJSON[]];
|
||||
following?: boolean;
|
||||
featuring?: boolean;
|
||||
}
|
||||
|
||||
export interface ApiFeaturedTagJSON extends ApiHashtagBase {
|
||||
statuses_count: string;
|
||||
last_status_at: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ exports[`<AvatarOverlay > renders a overlay avatar 1`] = `
|
|||
>
|
||||
<img
|
||||
alt="alice"
|
||||
onError={[Function]}
|
||||
src="/static/alice.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -44,6 +45,7 @@ exports[`<AvatarOverlay > renders a overlay avatar 1`] = `
|
|||
>
|
||||
<img
|
||||
alt="eve@blackhat.lair"
|
||||
onError={[Function]}
|
||||
src="/static/eve.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ const meta = {
|
|||
type: 'boolean',
|
||||
description: 'Whether to display the account menu or not',
|
||||
},
|
||||
withBorder: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to display the bottom border or not',
|
||||
},
|
||||
},
|
||||
args: {
|
||||
name: 'Test User',
|
||||
|
|
@ -60,6 +64,7 @@ const meta = {
|
|||
defaultAction: 'mute',
|
||||
withBio: false,
|
||||
withMenu: true,
|
||||
withBorder: true,
|
||||
},
|
||||
parameters: {
|
||||
state: {
|
||||
|
|
@ -103,6 +108,12 @@ export const NoMenu: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const NoBorder: Story = {
|
||||
args: {
|
||||
withBorder: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const Blocked: Story = {
|
||||
args: {
|
||||
defaultAction: 'block',
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ interface AccountProps {
|
|||
defaultAction?: 'block' | 'mute';
|
||||
withBio?: boolean;
|
||||
withMenu?: boolean;
|
||||
withBorder?: boolean;
|
||||
extraAccountInfo?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
|
@ -85,6 +86,7 @@ export const Account: React.FC<AccountProps> = ({
|
|||
defaultAction,
|
||||
withBio,
|
||||
withMenu = true,
|
||||
withBorder = true,
|
||||
extraAccountInfo,
|
||||
children,
|
||||
}) => {
|
||||
|
|
@ -290,6 +292,7 @@ export const Account: React.FC<AccountProps> = ({
|
|||
<div
|
||||
className={classNames('account', {
|
||||
'account--minimal': minimal,
|
||||
'account--without-border': !withBorder,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useCallback, useRef, useId } from 'react';
|
||||
import { useState, useCallback, useRef, useId, Fragment } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import type {
|
||||
OffsetValue,
|
||||
|
|
@ -8,24 +8,37 @@ import type {
|
|||
} from 'react-overlays/esm/usePopper';
|
||||
import Overlay from 'react-overlays/Overlay';
|
||||
|
||||
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
|
||||
import { useSelectableClick } from 'mastodon/hooks/useSelectableClick';
|
||||
|
||||
import { IconButton } from '../icon_button';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const offset = [0, 4] as OffsetValue;
|
||||
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
|
||||
|
||||
export const AltTextBadge: React.FC<{ description: string }> = ({
|
||||
description,
|
||||
}) => {
|
||||
const accessibilityId = useId();
|
||||
const anchorRef = useRef<HTMLButtonElement>(null);
|
||||
const intl = useIntl();
|
||||
const uniqueId = useId();
|
||||
const popoverId = `${uniqueId}-popover`;
|
||||
const titleId = `${uniqueId}-title`;
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setOpen((v) => !v);
|
||||
setTimeout(() => {
|
||||
popoverRef.current?.focus();
|
||||
}, 0);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
buttonRef.current?.focus();
|
||||
}, [setOpen]);
|
||||
|
||||
const [handleMouseDown, handleMouseUp] = useSelectableClick(handleClose);
|
||||
|
|
@ -34,11 +47,12 @@ export const AltTextBadge: React.FC<{ description: string }> = ({
|
|||
<>
|
||||
<button
|
||||
type='button'
|
||||
ref={anchorRef}
|
||||
ref={buttonRef}
|
||||
className='media-gallery__alt__label'
|
||||
onClick={handleClick}
|
||||
aria-expanded={open}
|
||||
aria-controls={accessibilityId}
|
||||
aria-controls={popoverId}
|
||||
aria-haspopup='dialog'
|
||||
>
|
||||
ALT
|
||||
</button>
|
||||
|
|
@ -47,7 +61,7 @@ export const AltTextBadge: React.FC<{ description: string }> = ({
|
|||
rootClose
|
||||
onHide={handleClose}
|
||||
show={open}
|
||||
target={anchorRef}
|
||||
target={buttonRef}
|
||||
placement='top-end'
|
||||
flip
|
||||
offset={offset}
|
||||
|
|
@ -57,17 +71,34 @@ export const AltTextBadge: React.FC<{ description: string }> = ({
|
|||
<div {...props} className='hover-card-controller'>
|
||||
<div // eslint-disable-line jsx-a11y/no-noninteractive-element-interactions
|
||||
className='info-tooltip dropdown-animation'
|
||||
role='region'
|
||||
id={accessibilityId}
|
||||
role='dialog'
|
||||
aria-labelledby={titleId}
|
||||
ref={popoverRef}
|
||||
id={popoverId}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
|
||||
tabIndex={0}
|
||||
>
|
||||
<h4>
|
||||
<h4 id={titleId}>
|
||||
<FormattedMessage
|
||||
id='alt_text_badge.title'
|
||||
defaultMessage='Alt text'
|
||||
tagName={Fragment}
|
||||
/>
|
||||
</h4>
|
||||
|
||||
<IconButton
|
||||
title={intl.formatMessage({
|
||||
id: 'lightbox.close',
|
||||
defaultMessage: 'Close',
|
||||
})}
|
||||
icon='close'
|
||||
iconComponent={CloseIcon}
|
||||
onClick={handleClose}
|
||||
className={classes.closeButton}
|
||||
/>
|
||||
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
.closeButton {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
inset-inline-end: 2px;
|
||||
padding: 10px;
|
||||
|
||||
--default-icon-color: var(--color-text-on-media);
|
||||
--default-bg-color: transparent;
|
||||
--hover-icon-color: var(--color-text-on-media);
|
||||
--hover-bg-color: rgb(from var(--color-text-on-media) r g b / 10%);
|
||||
--focus-outline-color: var(--color-text-on-media);
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import { useHovering } from 'mastodon/hooks/useHovering';
|
|||
import { autoPlayGif } from 'mastodon/initial_state';
|
||||
import type { Account } from 'mastodon/models/account';
|
||||
|
||||
import { useAccount } from '../hooks/useAccount';
|
||||
|
||||
interface Props {
|
||||
account:
|
||||
| Pick<Account, 'id' | 'acct' | 'avatar' | 'avatar_static'>
|
||||
|
|
@ -91,3 +93,10 @@ export const Avatar: React.FC<Props> = ({
|
|||
|
||||
return avatar;
|
||||
};
|
||||
|
||||
export const AvatarById: React.FC<
|
||||
{ accountId: string } & Omit<Props, 'account'>
|
||||
> = ({ accountId, ...otherProps }) => {
|
||||
const account = useAccount(accountId);
|
||||
return <Avatar account={account} {...otherProps} />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,14 @@ interface Props {
|
|||
overlaySize?: number;
|
||||
}
|
||||
|
||||
const handleImgLoadError = (error: { currentTarget: HTMLElement }) => {
|
||||
//
|
||||
// When the img tag fails to load the image, set the img tag to display: none. This prevents the
|
||||
// alt-text from overrunning the containing div.
|
||||
//
|
||||
error.currentTarget.style.display = 'none';
|
||||
};
|
||||
|
||||
export const AvatarOverlay: React.FC<Props> = ({
|
||||
account,
|
||||
friend,
|
||||
|
|
@ -38,7 +46,13 @@ export const AvatarOverlay: React.FC<Props> = ({
|
|||
className='account__avatar'
|
||||
style={{ width: `${baseSize}px`, height: `${baseSize}px` }}
|
||||
>
|
||||
{accountSrc && <img src={accountSrc} alt={account?.get('acct')} />}
|
||||
{accountSrc && (
|
||||
<img
|
||||
src={accountSrc}
|
||||
alt={account?.get('acct')}
|
||||
onError={handleImgLoadError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='account__avatar-overlay-overlay'>
|
||||
|
|
@ -46,7 +60,13 @@ export const AvatarOverlay: React.FC<Props> = ({
|
|||
className='account__avatar'
|
||||
style={{ width: `${overlaySize}px`, height: `${overlaySize}px` }}
|
||||
>
|
||||
{friendSrc && <img src={friendSrc} alt={friend?.get('acct')} />}
|
||||
{friendSrc && (
|
||||
<img
|
||||
src={friendSrc}
|
||||
alt={friend?.get('acct')}
|
||||
onError={handleImgLoadError}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import { CharacterCounter } from './index';
|
||||
|
||||
const meta = {
|
||||
component: CharacterCounter,
|
||||
title: 'Components/CharacterCounter',
|
||||
} satisfies Meta<typeof CharacterCounter>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
currentString: 'Hello, world!',
|
||||
maxLength: 100,
|
||||
},
|
||||
};
|
||||
|
||||
export const ExceedingLimit: Story = {
|
||||
args: {
|
||||
currentString: 'Hello, world!',
|
||||
maxLength: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const Recommended: Story = {
|
||||
args: {
|
||||
currentString: 'Hello, world!',
|
||||
maxLength: 10,
|
||||
recommended: true,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useMemo } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
interface CharacterCounterProps {
|
||||
currentString: string;
|
||||
maxLength: number;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
const segmenter = new Intl.Segmenter();
|
||||
|
||||
export const CharacterCounter = polymorphicForwardRef<
|
||||
'span',
|
||||
CharacterCounterProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
currentString,
|
||||
maxLength,
|
||||
as: Component = 'span',
|
||||
recommended = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const currentLength = useMemo(
|
||||
() => [...segmenter.segment(currentString)].length,
|
||||
[currentString],
|
||||
);
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
classes.counter,
|
||||
currentLength > maxLength && !recommended && classes.counterError,
|
||||
)}
|
||||
>
|
||||
{recommended ? (
|
||||
<FormattedMessage
|
||||
id='character_counter.recommended'
|
||||
defaultMessage='{currentLength}/{maxLength} recommended characters'
|
||||
values={{ currentLength, maxLength }}
|
||||
/>
|
||||
) : (
|
||||
<FormattedMessage
|
||||
id='character_counter.required'
|
||||
defaultMessage='{currentLength}/{maxLength} characters'
|
||||
values={{ currentLength, maxLength }}
|
||||
/>
|
||||
)}
|
||||
</Component>
|
||||
);
|
||||
},
|
||||
);
|
||||
CharacterCounter.displayName = 'CharCounter';
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
.counter {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.counterError {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
|
@ -4,8 +4,11 @@ import { FormattedMessage } from 'react-intl';
|
|||
|
||||
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { getColumnSkipLinkId } from 'mastodon/features/ui/components/skip_links';
|
||||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
||||
|
||||
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
||||
|
||||
import { useAppHistory } from './router';
|
||||
|
||||
type OnClickCallback = () => void;
|
||||
|
|
@ -28,9 +31,15 @@ export const ColumnBackButton: React.FC<{ onClick?: OnClickCallback }> = ({
|
|||
onClick,
|
||||
}) => {
|
||||
const handleClick = useHandleClick(onClick);
|
||||
const columnIndex = useColumnIndexContext();
|
||||
|
||||
const component = (
|
||||
<button onClick={handleClick} className='column-back-button' type='button'>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
id={getColumnSkipLinkId(columnIndex)}
|
||||
className='column-back-button'
|
||||
type='button'
|
||||
>
|
||||
<Icon
|
||||
id='chevron-left'
|
||||
icon={ArrowBackIcon}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import { Icon } from 'mastodon/components/icon';
|
|||
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
|
||||
import { useIdentity } from 'mastodon/identity_context';
|
||||
|
||||
import { useColumnIndexContext } from '../features/ui/components/columns_area';
|
||||
import { getColumnSkipLinkId } from '../features/ui/components/skip_links';
|
||||
|
||||
import { useAppHistory } from './router';
|
||||
|
||||
export const messages = defineMessages({
|
||||
|
|
@ -33,10 +36,11 @@ export const messages = defineMessages({
|
|||
});
|
||||
|
||||
const BackButton: React.FC<{
|
||||
onlyIcon: boolean;
|
||||
}> = ({ onlyIcon }) => {
|
||||
hasTitle: boolean;
|
||||
}> = ({ hasTitle }) => {
|
||||
const history = useAppHistory();
|
||||
const intl = useIntl();
|
||||
const columnIndex = useColumnIndexContext();
|
||||
|
||||
const handleBackClick = useCallback(() => {
|
||||
if (history.location.state?.fromMastodon) {
|
||||
|
|
@ -50,8 +54,9 @@ const BackButton: React.FC<{
|
|||
<button
|
||||
onClick={handleBackClick}
|
||||
className={classNames('column-header__back-button', {
|
||||
compact: onlyIcon,
|
||||
compact: hasTitle,
|
||||
})}
|
||||
id={!hasTitle ? getColumnSkipLinkId(columnIndex) : undefined}
|
||||
aria-label={intl.formatMessage(messages.back)}
|
||||
type='button'
|
||||
>
|
||||
|
|
@ -60,7 +65,7 @@ const BackButton: React.FC<{
|
|||
icon={ArrowBackIcon}
|
||||
className='column-back-button__icon'
|
||||
/>
|
||||
{!onlyIcon && (
|
||||
{!hasTitle && (
|
||||
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
|
||||
)}
|
||||
</button>
|
||||
|
|
@ -221,7 +226,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||
!pinned &&
|
||||
((multiColumn && history.location.state?.fromMastodon) || showBackButton)
|
||||
) {
|
||||
backButton = <BackButton onlyIcon={!!title} />;
|
||||
backButton = <BackButton hasTitle={!!title} />;
|
||||
}
|
||||
|
||||
const collapsedContent = [extraContent];
|
||||
|
|
@ -260,6 +265,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||
const hasIcon = icon && iconComponent;
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
const hasTitle = (hasIcon || backButton) && title;
|
||||
const columnIndex = useColumnIndexContext();
|
||||
|
||||
const component = (
|
||||
<div className={wrapperClassName}>
|
||||
|
|
@ -272,6 +278,7 @@ export const ColumnHeader: React.FC<Props> = ({
|
|||
onClick={handleTitleClick}
|
||||
className='column-header__title'
|
||||
type='button'
|
||||
id={getColumnSkipLinkId(columnIndex)}
|
||||
>
|
||||
{!backButton && hasIcon && (
|
||||
<Icon
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ const messages = defineMessages({
|
|||
export const CopyIconButton: React.FC<{
|
||||
title: string;
|
||||
value: string;
|
||||
className: string;
|
||||
}> = ({ title, value, className }) => {
|
||||
className?: string;
|
||||
'aria-describedby'?: string;
|
||||
}> = ({ title, value, className, 'aria-describedby': ariaDescribedBy }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
|
|
@ -38,8 +39,9 @@ export const CopyIconButton: React.FC<{
|
|||
className={classNames(className, copied ? 'copied' : 'copyable')}
|
||||
title={title}
|
||||
onClick={handleClick}
|
||||
icon=''
|
||||
icon='copy-icon'
|
||||
iconComponent={ContentCopyIcon}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -20,18 +20,7 @@ export interface EmojiHTMLProps {
|
|||
}
|
||||
|
||||
export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
||||
(
|
||||
{
|
||||
extraEmojis,
|
||||
htmlString,
|
||||
as: asProp = 'div', // Rename for syntax highlighting
|
||||
className,
|
||||
onElement,
|
||||
onAttribute,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
({ extraEmojis, htmlString, onElement, onAttribute, ...props }, ref) => {
|
||||
const contents = useMemo(
|
||||
() =>
|
||||
htmlStringToComponents(htmlString, {
|
||||
|
|
@ -44,12 +33,7 @@ export const EmojiHTML = polymorphicForwardRef<'div', EmojiHTMLProps>(
|
|||
|
||||
return (
|
||||
<CustomEmojiProvider emojis={extraEmojis}>
|
||||
<AnimateEmojiProvider
|
||||
{...props}
|
||||
as={asProp}
|
||||
className={className}
|
||||
ref={ref}
|
||||
>
|
||||
<AnimateEmojiProvider {...props} ref={ref}>
|
||||
{contents}
|
||||
</AnimateEmojiProvider>
|
||||
</CustomEmojiProvider>
|
||||
|
|
|
|||
29
app/javascript/mastodon/components/emoji/picker_button.tsx
Normal file
29
app/javascript/mastodon/components/emoji/picker_button.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import EmojiPickerDropdown from '@/mastodon/features/compose/containers/emoji_picker_dropdown_container';
|
||||
|
||||
export const EmojiPickerButton: FC<{
|
||||
onPick: (emoji: string) => void;
|
||||
disabled?: boolean;
|
||||
}> = ({ onPick, disabled }) => {
|
||||
const handlePick = useCallback(
|
||||
(emoji: unknown) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
if (typeof emoji === 'object' && emoji !== null) {
|
||||
if ('native' in emoji && typeof emoji.native === 'string') {
|
||||
onPick(emoji.native);
|
||||
} else if (
|
||||
'shortcode' in emoji &&
|
||||
typeof emoji.shortcode === 'string'
|
||||
) {
|
||||
onPick(`:${emoji.shortcode}:`);
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onPick],
|
||||
);
|
||||
return <EmojiPickerDropdown onPickEmoji={handlePick} disabled={disabled} />;
|
||||
};
|
||||
|
|
@ -138,6 +138,8 @@ export const FollowButton: React.FC<{
|
|||
: messages.follow;
|
||||
|
||||
let label;
|
||||
let disabled =
|
||||
relationship?.blocked_by || account?.suspended || !!account?.moved;
|
||||
|
||||
if (!signedIn) {
|
||||
label = intl.formatMessage(followMessage);
|
||||
|
|
@ -147,12 +149,16 @@ export const FollowButton: React.FC<{
|
|||
label = <LoadingIndicator />;
|
||||
} else if (relationship.muting && withUnmute) {
|
||||
label = intl.formatMessage(messages.unmute);
|
||||
disabled = false;
|
||||
} else if (relationship.following) {
|
||||
label = intl.formatMessage(messages.unfollow);
|
||||
disabled = false;
|
||||
} else if (relationship.blocking) {
|
||||
label = intl.formatMessage(messages.unblock);
|
||||
disabled = false;
|
||||
} else if (relationship.requested) {
|
||||
label = intl.formatMessage(messages.followRequestCancel);
|
||||
disabled = false;
|
||||
} else if (relationship.followed_by && !account?.locked) {
|
||||
label = intl.formatMessage(messages.followBack);
|
||||
} else {
|
||||
|
|
@ -187,11 +193,7 @@ export const FollowButton: React.FC<{
|
|||
return (
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
disabled={
|
||||
relationship?.blocked_by ||
|
||||
(!(relationship?.following || relationship?.requested) &&
|
||||
(account?.suspended || !!account?.moved))
|
||||
}
|
||||
disabled={disabled}
|
||||
secondary={following || relationship?.blocking}
|
||||
compact={compact}
|
||||
className={classNames(className, { 'button--destructive': following })}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
}
|
||||
|
||||
.input {
|
||||
padding-right: 45px;
|
||||
padding-inline-end: 45px;
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
|
|
|
|||
|
|
@ -82,11 +82,23 @@ const ComboboxDemo: React.FC = () => {
|
|||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/ComboboxField',
|
||||
component: ComboboxDemo,
|
||||
} satisfies Meta<typeof ComboboxDemo>;
|
||||
component: ComboboxField,
|
||||
render: () => <ComboboxDemo />,
|
||||
} satisfies Meta<typeof ComboboxField>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Example: Story = {};
|
||||
export const Example: Story = {
|
||||
args: {
|
||||
// Adding these types to keep TS happy, they're not passed on to `ComboboxDemo`
|
||||
label: '',
|
||||
value: '',
|
||||
onChange: () => undefined,
|
||||
items: [],
|
||||
getItemId: () => '',
|
||||
renderItem: () => <>Nothing</>,
|
||||
onSelectItem: () => undefined,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { forwardRef, useCallback, useId, useRef, useState } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
|
@ -9,6 +8,7 @@ import Overlay from 'react-overlays/Overlay';
|
|||
|
||||
import KeyboardArrowDownIcon from '@/material-icons/400-24px/keyboard_arrow_down.svg?react';
|
||||
import KeyboardArrowUpIcon from '@/material-icons/400-24px/keyboard_arrow_up.svg?react';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
import { matchWidth } from 'mastodon/components/dropdown/utils';
|
||||
import { IconButton } from 'mastodon/components/icon_button';
|
||||
import { useOnClickOutside } from 'mastodon/hooks/useOnClickOutside';
|
||||
|
|
@ -17,6 +17,7 @@ import classes from './combobox.module.scss';
|
|||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import { TextInput } from './text_input_field';
|
||||
import type { TextInputProps } from './text_input_field';
|
||||
|
||||
interface ComboboxItem {
|
||||
id: string;
|
||||
|
|
@ -27,17 +28,48 @@ export interface ComboboxItemState {
|
|||
isDisabled: boolean;
|
||||
}
|
||||
|
||||
interface ComboboxProps<
|
||||
T extends ComboboxItem,
|
||||
> extends ComponentPropsWithoutRef<'input'> {
|
||||
interface ComboboxProps<T extends ComboboxItem> extends TextInputProps {
|
||||
/**
|
||||
* The value of the combobox's text input
|
||||
*/
|
||||
value: string;
|
||||
/**
|
||||
* Change handler for the text input field
|
||||
*/
|
||||
onChange: React.ChangeEventHandler<HTMLInputElement>;
|
||||
/**
|
||||
* Set this to true when the list of options is dynamic and currently loading.
|
||||
* Causes a loading indicator to be displayed inside of the dropdown menu.
|
||||
*/
|
||||
isLoading?: boolean;
|
||||
/**
|
||||
* The set of options/suggestions that should be rendered in the dropdown menu.
|
||||
*/
|
||||
items: T[];
|
||||
getItemId: (item: T) => string;
|
||||
/**
|
||||
* A function that must return a unique id for each option passed via `items`
|
||||
*/
|
||||
getItemId?: (item: T) => string;
|
||||
/**
|
||||
* Providing this function turns the combobox into a multi-select box that assumes
|
||||
* multiple options to be selectable. Single-selection is handled automatically.
|
||||
*/
|
||||
getIsItemSelected?: (item: T) => boolean;
|
||||
/**
|
||||
* Use this function to mark items as disabled, if needed
|
||||
*/
|
||||
getIsItemDisabled?: (item: T) => boolean;
|
||||
renderItem: (item: T, state: ComboboxItemState) => React.ReactElement;
|
||||
/**
|
||||
* Customise the rendering of each option.
|
||||
* The rendered content must not contain other interactive content!
|
||||
*/
|
||||
renderItem: (
|
||||
item: T,
|
||||
state: ComboboxItemState,
|
||||
) => React.ReactElement | string;
|
||||
/**
|
||||
* The main selection handler, called when an option is selected or deselected.
|
||||
*/
|
||||
onSelectItem: (item: T) => void;
|
||||
}
|
||||
|
||||
|
|
@ -45,8 +77,12 @@ interface Props<T extends ComboboxItem>
|
|||
extends ComboboxProps<T>, CommonFieldWrapperProps {}
|
||||
|
||||
/**
|
||||
* The combobox field allows users to select one or multiple items
|
||||
* from a large list of options by searching or filtering.
|
||||
* The combobox field allows users to select one or more items
|
||||
* by searching or filtering a large or dynamic list of options.
|
||||
*
|
||||
* It is an implementation of the [APG Combobox pattern](https://www.w3.org/WAI/ARIA/apg/patterns/combobox/),
|
||||
* with inspiration taken from Sarah Higley's extensive combobox
|
||||
* [research & implementations](https://sarahmhigley.com/writing/select-your-poison/).
|
||||
*/
|
||||
|
||||
export const ComboboxFieldWithRef = <T extends ComboboxItem>(
|
||||
|
|
@ -80,7 +116,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||
value,
|
||||
isLoading = false,
|
||||
items,
|
||||
getItemId,
|
||||
getItemId = (item) => item.id,
|
||||
getIsItemDisabled,
|
||||
getIsItemSelected,
|
||||
disabled,
|
||||
|
|
@ -88,6 +124,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||
onSelectItem,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
icon = SearchIcon,
|
||||
className,
|
||||
...otherProps
|
||||
}: ComboboxProps<T>,
|
||||
|
|
@ -306,6 +343,7 @@ const ComboboxWithRef = <T extends ComboboxItem>(
|
|||
value={value}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
icon={icon}
|
||||
className={classNames(classes.input, className)}
|
||||
ref={mergeRefs}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
.wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding-inline-end: 45px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
top: 0;
|
||||
padding: 9px;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { forwardRef, useCallback, useRef } from 'react';
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { CopyIconButton } from 'mastodon/components/copy_icon_button';
|
||||
|
||||
import classes from './copy_link_field.module.scss';
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import { TextInput } from './text_input_field';
|
||||
import type { TextInputProps } from './text_input_field';
|
||||
|
||||
interface CopyLinkFieldProps extends CommonFieldWrapperProps, TextInputProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only text field with a button for copying the field value
|
||||
*/
|
||||
|
||||
export const CopyLinkField = forwardRef<HTMLInputElement, CopyLinkFieldProps>(
|
||||
(
|
||||
{ id, label, hint, hasError, value, required, className, ...otherProps },
|
||||
ref,
|
||||
) => {
|
||||
const intl = useIntl();
|
||||
const inputRef = useRef<HTMLInputElement | null>();
|
||||
const handleFocus = useCallback(() => {
|
||||
inputRef.current?.select();
|
||||
}, []);
|
||||
|
||||
const mergeRefs = useCallback(
|
||||
(element: HTMLInputElement | null) => {
|
||||
inputRef.current = element;
|
||||
if (typeof ref === 'function') {
|
||||
ref(element);
|
||||
} else if (ref) {
|
||||
ref.current = element;
|
||||
}
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<div className={classes.wrapper}>
|
||||
<TextInput
|
||||
readOnly
|
||||
{...otherProps}
|
||||
{...inputProps}
|
||||
ref={mergeRefs}
|
||||
value={value}
|
||||
onFocus={handleFocus}
|
||||
className={classNames(className, classes.input)}
|
||||
/>
|
||||
<CopyIconButton
|
||||
value={value}
|
||||
title={intl.formatMessage({
|
||||
id: 'copy_icon_button.copy_this_text',
|
||||
defaultMessage: 'Copy link to clipboard',
|
||||
})}
|
||||
className={classes.copyButton}
|
||||
aria-describedby={inputProps.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CopyLinkField.displayName = 'CopyLinkField';
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
.fieldWrapper div:has(:global(.emoji-picker-dropdown)) {
|
||||
position: relative;
|
||||
|
||||
> input,
|
||||
> textarea {
|
||||
padding-inline-end: 36px;
|
||||
}
|
||||
|
||||
> textarea {
|
||||
min-height: 40px; // Button size with 8px margin
|
||||
}
|
||||
}
|
||||
|
||||
.fieldWrapper :global(.emoji-picker-dropdown) {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
|
||||
:global(.icon-button) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import type { EmojiInputProps } from './emoji_text_field';
|
||||
import { EmojiTextAreaField, EmojiTextInputField } from './emoji_text_field';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Form Fields/EmojiTextInputField',
|
||||
args: {
|
||||
label: 'Label',
|
||||
hint: 'Hint text',
|
||||
value: 'Insert text with emoji',
|
||||
},
|
||||
render({ value: initialValue = '', ...args }) {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return <EmojiTextInputField {...args} value={value} onChange={setValue} />;
|
||||
},
|
||||
} satisfies Meta<EmojiInputProps & { disabled?: boolean }>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Simple: Story = {};
|
||||
|
||||
export const WithMaxLength: Story = {
|
||||
args: {
|
||||
maxLength: 20,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRecommended: Story = {
|
||||
args: {
|
||||
maxLength: 20,
|
||||
recommended: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const TextArea: Story = {
|
||||
render(args) {
|
||||
const [value, setValue] = useState('Insert text with emoji');
|
||||
return (
|
||||
<EmojiTextAreaField
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
label='Label'
|
||||
maxLength={100}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
import type {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
ComponentPropsWithoutRef,
|
||||
Dispatch,
|
||||
FC,
|
||||
ReactNode,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useCallback, useId, useRef } from 'react';
|
||||
|
||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
||||
import type { OmitUnion } from '@/mastodon/utils/types';
|
||||
|
||||
import { CharacterCounter } from '../character_counter';
|
||||
import { EmojiPickerButton } from '../emoji/picker_button';
|
||||
|
||||
import classes from './emoji_text_field.module.scss';
|
||||
import type { CommonFieldWrapperProps, InputProps } from './form_field_wrapper';
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import { TextArea } from './text_area_field';
|
||||
import type { TextAreaProps } from './text_area_field';
|
||||
import { TextInput } from './text_input_field';
|
||||
|
||||
export type EmojiInputProps = {
|
||||
value?: string;
|
||||
onChange?: Dispatch<SetStateAction<string>>;
|
||||
maxLength?: number;
|
||||
recommended?: boolean;
|
||||
} & Omit<CommonFieldWrapperProps, 'wrapperClassName'>;
|
||||
|
||||
export const EmojiTextInputField: FC<
|
||||
OmitUnion<ComponentPropsWithoutRef<'input'>, EmojiInputProps>
|
||||
> = ({
|
||||
onChange,
|
||||
value,
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
maxLength,
|
||||
recommended,
|
||||
disabled,
|
||||
...otherProps
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const wrapperProps = {
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
maxLength,
|
||||
recommended,
|
||||
disabled,
|
||||
inputRef,
|
||||
value,
|
||||
onChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<EmojiFieldWrapper {...wrapperProps}>
|
||||
{(inputProps) => (
|
||||
<TextInput
|
||||
{...inputProps}
|
||||
{...otherProps}
|
||||
value={value}
|
||||
ref={inputRef}
|
||||
/>
|
||||
)}
|
||||
</EmojiFieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const EmojiTextAreaField: FC<
|
||||
OmitUnion<Omit<TextAreaProps, 'style'>, EmojiInputProps>
|
||||
> = ({
|
||||
onChange,
|
||||
value,
|
||||
label,
|
||||
maxLength,
|
||||
recommended = false,
|
||||
disabled,
|
||||
hint,
|
||||
hasError,
|
||||
...otherProps
|
||||
}) => {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const wrapperProps = {
|
||||
label,
|
||||
hint,
|
||||
hasError,
|
||||
maxLength,
|
||||
recommended,
|
||||
disabled,
|
||||
inputRef: textareaRef,
|
||||
value,
|
||||
onChange,
|
||||
};
|
||||
|
||||
return (
|
||||
<EmojiFieldWrapper {...wrapperProps}>
|
||||
{(inputProps) => (
|
||||
<TextArea
|
||||
{...otherProps}
|
||||
{...inputProps}
|
||||
value={value}
|
||||
ref={textareaRef}
|
||||
/>
|
||||
)}
|
||||
</EmojiFieldWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const EmojiFieldWrapper: FC<
|
||||
EmojiInputProps & {
|
||||
disabled?: boolean;
|
||||
children: (
|
||||
inputProps: InputProps & { onChange: ChangeEventHandler },
|
||||
) => ReactNode;
|
||||
inputRef: RefObject<HTMLTextAreaElement | HTMLInputElement>;
|
||||
}
|
||||
> = ({
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
disabled,
|
||||
inputRef,
|
||||
maxLength,
|
||||
recommended = false,
|
||||
...otherProps
|
||||
}) => {
|
||||
const counterId = useId();
|
||||
|
||||
const handlePickEmoji = useCallback(
|
||||
(emoji: string) => {
|
||||
onChange?.((prev) => {
|
||||
const position = inputRef.current?.selectionStart ?? prev.length;
|
||||
return insertEmojiAtPosition(prev, emoji, position);
|
||||
});
|
||||
},
|
||||
[onChange, inputRef],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(event.target.value);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormFieldWrapper
|
||||
className={classes.fieldWrapper}
|
||||
describedById={counterId}
|
||||
{...otherProps}
|
||||
>
|
||||
{(inputProps) => (
|
||||
<>
|
||||
{children({ ...inputProps, onChange: handleChange })}
|
||||
<EmojiPickerButton onPick={handlePickEmoji} disabled={disabled} />
|
||||
{maxLength && (
|
||||
<CharacterCounter
|
||||
currentString={value ?? ''}
|
||||
maxLength={maxLength}
|
||||
recommended={recommended}
|
||||
id={counterId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
};
|
||||
|
|
@ -5,10 +5,12 @@ import { useContext, useId } from 'react';
|
|||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FieldsetNameContext } from './fieldset';
|
||||
import classes from './form_field_wrapper.module.scss';
|
||||
|
||||
interface InputProps {
|
||||
export interface InputProps {
|
||||
id: string;
|
||||
required?: boolean;
|
||||
'aria-describedby'?: string;
|
||||
|
|
@ -20,8 +22,10 @@ interface FieldWrapperProps {
|
|||
required?: boolean;
|
||||
hasError?: boolean;
|
||||
inputId?: string;
|
||||
describedById?: string;
|
||||
inputPlacement?: 'inline-start' | 'inline-end';
|
||||
children: (inputProps: InputProps) => ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -30,7 +34,7 @@ interface FieldWrapperProps {
|
|||
export type CommonFieldWrapperProps = Pick<
|
||||
FieldWrapperProps,
|
||||
'label' | 'hint' | 'hasError'
|
||||
>;
|
||||
> & { wrapperClassName?: string };
|
||||
|
||||
/**
|
||||
* A simple form field wrapper for adding a label and hint to enclosed components.
|
||||
|
|
@ -42,10 +46,12 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
|||
inputId: inputIdProp,
|
||||
label,
|
||||
hint,
|
||||
describedById,
|
||||
required,
|
||||
hasError,
|
||||
inputPlacement,
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
const uniqueId = useId();
|
||||
const inputId = inputIdProp || `${uniqueId}-input`;
|
||||
|
|
@ -59,7 +65,9 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
|||
id: inputId,
|
||||
};
|
||||
if (hasHint) {
|
||||
inputProps['aria-describedby'] = hintId;
|
||||
inputProps['aria-describedby'] = describedById
|
||||
? `${describedById} ${hintId}`
|
||||
: hintId;
|
||||
}
|
||||
|
||||
const input = (
|
||||
|
|
@ -68,7 +76,7 @@ export const FormFieldWrapper: FC<FieldWrapperProps> = ({
|
|||
|
||||
return (
|
||||
<div
|
||||
className={classes.wrapper}
|
||||
className={classNames(classes.wrapper, className)}
|
||||
data-has-error={hasError}
|
||||
data-input-placement={inputPlacement}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export { FormFieldWrapper } from './form_field_wrapper';
|
||||
export { FormStack } from './form_stack';
|
||||
export { Fieldset } from './fieldset';
|
||||
export { TextInputField, TextInput } from './text_input_field';
|
||||
|
|
@ -8,6 +9,8 @@ export {
|
|||
Combobox,
|
||||
type ComboboxItemState,
|
||||
} from './combobox_field';
|
||||
export { CopyLinkField } from './copy_link_field';
|
||||
export { EmojiTextInputField, EmojiTextAreaField } from './emoji_text_field';
|
||||
export { RadioButtonField, RadioButton } from './radio_button_field';
|
||||
export { ToggleField, Toggle } from './toggle_field';
|
||||
export { SelectField, Select } from './select_field';
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { FormFieldWrapper } from './form_field_wrapper';
|
|||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './text_input.module.scss';
|
||||
|
||||
type TextAreaProps =
|
||||
export type TextAreaProps =
|
||||
| ({ autoSize?: false } & ComponentPropsWithoutRef<'textarea'>)
|
||||
| ({ autoSize: true } & TextareaAutosizeProps);
|
||||
|
||||
|
|
@ -24,17 +24,23 @@ type TextAreaProps =
|
|||
export const TextAreaField = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
TextAreaProps & CommonFieldWrapperProps
|
||||
>(({ id, label, hint, required, hasError, ...otherProps }, ref) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
>
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{ id, label, hint, required, hasError, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
{(inputProps) => <TextArea {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
),
|
||||
);
|
||||
|
||||
TextAreaField.displayName = 'TextAreaField';
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,15 @@
|
|||
font-size: 16px;
|
||||
}
|
||||
|
||||
.iconWrapper & {
|
||||
// Make space for icon displayed at start of input
|
||||
padding-inline-start: 36px;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline-color: var(--color-text-brand);
|
||||
}
|
||||
|
|
@ -40,3 +49,17 @@
|
|||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.iconWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.icon {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
inset-inline-start: 10px;
|
||||
inset-block-start: 10px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
|
||||
import { TextInputField, TextInput } from './text_input_field';
|
||||
|
||||
const meta = {
|
||||
|
|
@ -42,6 +44,14 @@ export const WithError: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const WithIcon: Story = {
|
||||
args: {
|
||||
label: 'Search',
|
||||
hint: undefined,
|
||||
icon: SearchIcon,
|
||||
},
|
||||
};
|
||||
|
||||
export const Plain: Story = {
|
||||
render(args) {
|
||||
return <TextInput {...args} />;
|
||||
|
|
|
|||
|
|
@ -3,12 +3,18 @@ import { forwardRef } from 'react';
|
|||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import type { IconProp } from 'mastodon/components/icon';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
|
||||
import { FormFieldWrapper } from './form_field_wrapper';
|
||||
import type { CommonFieldWrapperProps } from './form_field_wrapper';
|
||||
import classes from './text_input.module.scss';
|
||||
|
||||
interface Props
|
||||
extends ComponentPropsWithoutRef<'input'>, CommonFieldWrapperProps {}
|
||||
export interface TextInputProps extends ComponentPropsWithoutRef<'input'> {
|
||||
icon?: IconProp;
|
||||
}
|
||||
|
||||
interface Props extends TextInputProps, CommonFieldWrapperProps {}
|
||||
|
||||
/**
|
||||
* A simple form field for single-line text.
|
||||
|
|
@ -18,13 +24,17 @@ interface Props
|
|||
*/
|
||||
|
||||
export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
||||
({ id, label, hint, hasError, required, ...otherProps }, ref) => (
|
||||
(
|
||||
{ id, label, hint, hasError, required, wrapperClassName, ...otherProps },
|
||||
ref,
|
||||
) => (
|
||||
<FormFieldWrapper
|
||||
label={label}
|
||||
hint={hint}
|
||||
required={required}
|
||||
hasError={hasError}
|
||||
inputId={id}
|
||||
className={wrapperClassName}
|
||||
>
|
||||
{(inputProps) => <TextInput {...otherProps} {...inputProps} ref={ref} />}
|
||||
</FormFieldWrapper>
|
||||
|
|
@ -33,16 +43,33 @@ export const TextInputField = forwardRef<HTMLInputElement, Props>(
|
|||
|
||||
TextInputField.displayName = 'TextInputField';
|
||||
|
||||
export const TextInput = forwardRef<
|
||||
HTMLInputElement,
|
||||
ComponentPropsWithoutRef<'input'>
|
||||
>(({ type = 'text', className, ...otherProps }, ref) => (
|
||||
<input
|
||||
type={type}
|
||||
{...otherProps}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
export const TextInput = forwardRef<HTMLInputElement, TextInputProps>(
|
||||
({ type = 'text', icon, className, ...otherProps }, ref) => (
|
||||
<WrapFieldWithIcon icon={icon}>
|
||||
<input
|
||||
type={type}
|
||||
{...otherProps}
|
||||
className={classNames(className, classes.input)}
|
||||
ref={ref}
|
||||
/>
|
||||
</WrapFieldWithIcon>
|
||||
),
|
||||
);
|
||||
|
||||
TextInput.displayName = 'TextInput';
|
||||
|
||||
const WrapFieldWithIcon: React.FC<{
|
||||
icon?: IconProp;
|
||||
children: React.ReactElement;
|
||||
}> = ({ icon, children }) => {
|
||||
if (icon) {
|
||||
return (
|
||||
<div className={classes.iconWrapper}>
|
||||
<Icon icon={icon} id='input-icon' className={classes.icon} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ const hotkeyTest: Story['play'] = async ({ canvas, userEvent }) => {
|
|||
await userEvent.keyboard('gh');
|
||||
await confirmHotkey('goToHome');
|
||||
|
||||
await userEvent.keyboard('ge');
|
||||
await confirmHotkey('goToExplore');
|
||||
|
||||
await userEvent.keyboard('gn');
|
||||
await confirmHotkey('goToNotifications');
|
||||
|
||||
|
|
@ -106,6 +109,9 @@ export const Default = {
|
|||
goToHome: () => {
|
||||
setMatchedHotkey('goToHome');
|
||||
},
|
||||
goToExplore: () => {
|
||||
setMatchedHotkey('goToExplore');
|
||||
},
|
||||
goToNotifications: () => {
|
||||
setMatchedHotkey('goToNotifications');
|
||||
},
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ const hotkeyMatcherMap = {
|
|||
openMedia: just('e'),
|
||||
onTranslate: just('t'),
|
||||
goToHome: sequence('g', 'h'),
|
||||
goToExplore: sequence('g', 'e'),
|
||||
goToNotifications: sequence('g', 'n'),
|
||||
goToLocal: sequence('g', 'l'),
|
||||
goToFederated: sequence('g', 't'),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export const HoverCardController: React.FC = () => {
|
|||
const [open, setOpen] = useState(false);
|
||||
const [accountId, setAccountId] = useState<string | undefined>();
|
||||
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||
const isUsingTouchRef = useRef(false);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
|
||||
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
|
||||
|
|
@ -62,6 +63,12 @@ export const HoverCardController: React.FC = () => {
|
|||
setAccountId(undefined);
|
||||
};
|
||||
|
||||
const handleTouchStart = () => {
|
||||
// Keeping track of touch events to prevent the
|
||||
// hover card from being displayed on touch devices
|
||||
isUsingTouchRef.current = true;
|
||||
};
|
||||
|
||||
const handleMouseEnter = (e: MouseEvent) => {
|
||||
const { target } = e;
|
||||
|
||||
|
|
@ -71,6 +78,11 @@ export const HoverCardController: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Bail out if a touch is active
|
||||
if (isUsingTouchRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We've entered an anchor
|
||||
if (!isScrolling && isHoverCardAnchor(target)) {
|
||||
cancelLeaveTimeout();
|
||||
|
|
@ -129,9 +141,16 @@ export const HoverCardController: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (isUsingTouchRef.current) {
|
||||
isUsingTouchRef.current = false;
|
||||
}
|
||||
delayEnterTimeout(enterDelay);
|
||||
};
|
||||
|
||||
document.body.addEventListener('touchstart', handleTouchStart, {
|
||||
passive: true,
|
||||
});
|
||||
|
||||
document.body.addEventListener('mouseenter', handleMouseEnter, {
|
||||
passive: true,
|
||||
capture: true,
|
||||
|
|
@ -153,6 +172,7 @@ export const HoverCardController: React.FC = () => {
|
|||
});
|
||||
|
||||
return () => {
|
||||
document.body.removeEventListener('touchstart', handleTouchStart);
|
||||
document.body.removeEventListener('mouseenter', handleMouseEnter);
|
||||
document.body.removeEventListener('mousemove', handleMouseMove);
|
||||
document.body.removeEventListener('mouseleave', handleMouseLeave);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,17 @@ export type MiniCardProps = OmitUnion<
|
|||
|
||||
export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
|
||||
(
|
||||
{ label, value, className, hidden, icon, iconId, iconClassName, ...props },
|
||||
{
|
||||
label,
|
||||
value,
|
||||
className,
|
||||
hidden,
|
||||
icon,
|
||||
iconId,
|
||||
iconClassName,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
if (!label) {
|
||||
|
|
@ -50,6 +60,7 @@ export const MiniCard = forwardRef<HTMLDivElement, MiniCardProps>(
|
|||
)}
|
||||
<dt className={classes.label}>{label}</dt>
|
||||
<dd className={classes.value}>{value}</dd>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
51
app/javascript/mastodon/components/modal_shell/index.tsx
Normal file
51
app/javascript/mastodon/components/modal_shell/index.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import classNames from 'classnames';
|
||||
|
||||
interface ModalShellProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ModalShell: React.FC<ModalShellProps> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'modal-root__modal',
|
||||
'safety-action-modal',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalShellBody: React.FC<ModalShellProps> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className='safety-action-modal__top'>
|
||||
<div
|
||||
className={classNames('safety-action-modal__confirmation', className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ModalShellActions: React.FC<ModalShellProps> = ({
|
||||
children,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<div className='safety-action-modal__bottom'>
|
||||
<div className={classNames('safety-action-modal__actions', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { Children, forwardRef } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { LoadingIndicator } from '../loading_indicator';
|
||||
|
||||
export const Scrollable = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<'div'> & {
|
||||
flex?: boolean;
|
||||
fullscreen?: boolean;
|
||||
}
|
||||
>(({ flex = true, fullscreen, className, children, ...otherProps }, ref) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'scrollable',
|
||||
{ 'scrollable--flex': flex, fullscreen },
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Scrollable.displayName = 'Scrollable';
|
||||
|
||||
export const ItemList = forwardRef<
|
||||
HTMLDivElement,
|
||||
ComponentPropsWithoutRef<'div'> & {
|
||||
isLoading?: boolean;
|
||||
emptyMessage?: React.ReactNode;
|
||||
}
|
||||
>(({ isLoading, emptyMessage, className, children, ...otherProps }, ref) => {
|
||||
if (!isLoading && Children.count(children) === 0 && emptyMessage) {
|
||||
return <div className='empty-column-indicator'>{emptyMessage}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role='feed'
|
||||
className={classNames('item-list', className)}
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
>
|
||||
{!isLoading && children}
|
||||
</div>
|
||||
{isLoading && (
|
||||
<div className='scrollable__append'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
ItemList.displayName = 'ItemList';
|
||||
|
||||
export const Article = forwardRef<
|
||||
HTMLElement,
|
||||
ComponentPropsWithoutRef<'article'> & {
|
||||
focusable?: boolean;
|
||||
'data-id'?: string;
|
||||
'aria-posinset': number;
|
||||
'aria-setsize': number;
|
||||
}
|
||||
>(({ focusable, className, children, ...otherProps }, ref) => {
|
||||
return (
|
||||
<article
|
||||
ref={ref}
|
||||
className={classNames(className, { focusable })}
|
||||
tabIndex={-1}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
Article.displayName = 'Article';
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { Children, cloneElement, PureComponent } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { List as ImmutableList } from 'immutable';
|
||||
|
|
@ -12,13 +11,14 @@ import { throttle } from 'lodash';
|
|||
|
||||
import { ScrollContainer } from 'mastodon/containers/scroll_container';
|
||||
|
||||
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
|
||||
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
|
||||
import IntersectionObserverArticleContainer from '../../containers/intersection_observer_article_container';
|
||||
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../../features/ui/util/fullscreen';
|
||||
import IntersectionObserverWrapper from '../../features/ui/util/intersection_observer_wrapper';
|
||||
|
||||
import { LoadMore } from './load_more';
|
||||
import { LoadPending } from './load_pending';
|
||||
import { LoadingIndicator } from './loading_indicator';
|
||||
import { LoadMore } from '../load_more';
|
||||
import { LoadPending } from '../load_pending';
|
||||
import { LoadingIndicator } from '../loading_indicator';
|
||||
import { Scrollable, ItemList } from './components';
|
||||
|
||||
const MOUSE_IDLE_DELAY = 300;
|
||||
|
||||
|
|
@ -336,24 +336,20 @@ class ScrollableList extends PureComponent {
|
|||
|
||||
if (showLoading) {
|
||||
scrollableArea = (
|
||||
<div className='scrollable scrollable--flex' ref={this.setRef}>
|
||||
<Scrollable ref={this.setRef}>
|
||||
{prepend}
|
||||
|
||||
<div role='feed' className='item-list' />
|
||||
|
||||
<div className='scrollable__append'>
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
<ItemList isLoading />
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
} else if (isLoading || childrenCount > 0 || numPending > 0 || hasMore || !emptyMessage) {
|
||||
scrollableArea = (
|
||||
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
<Scrollable fullscreen={fullscreen} ref={this.setRef} onMouseMove={this.handleMouseMove}>
|
||||
{prepend}
|
||||
|
||||
<div role='feed' className={classNames('item-list', className)}>
|
||||
<ItemList className={className}>
|
||||
{loadPending}
|
||||
|
||||
{Children.map(this.props.children, (child, index) => (
|
||||
|
|
@ -378,14 +374,14 @@ class ScrollableList extends PureComponent {
|
|||
{loadMore}
|
||||
|
||||
{!hasMore && append}
|
||||
</div>
|
||||
</ItemList>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
} else {
|
||||
scrollableArea = (
|
||||
<div className={classNames('scrollable scrollable--flex', { fullscreen })} ref={this.setRef}>
|
||||
<Scrollable fullscreen={fullscreen} ref={this.setRef}>
|
||||
{alwaysPrepend && prepend}
|
||||
|
||||
<div className='empty-column-indicator'>
|
||||
|
|
@ -393,7 +389,7 @@ class ScrollableList extends PureComponent {
|
|||
</div>
|
||||
|
||||
{footer}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { cloneElement, Component } from 'react';
|
||||
|
||||
import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
|
||||
import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
|
||||
import getRectFromEntry from '../../features/ui/util/get_rect_from_entry';
|
||||
import scheduleIdleTask from '../../features/ui/util/schedule_idle_task';
|
||||
import { Article } from './components';
|
||||
|
||||
// Diff these props in the "unrendered" state
|
||||
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
|
||||
|
|
@ -108,23 +109,22 @@ export default class IntersectionObserverArticle extends Component {
|
|||
|
||||
if (!isIntersecting && (isHidden || cachedHeight)) {
|
||||
return (
|
||||
<article
|
||||
<Article
|
||||
ref={this.handleRef}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={listLength}
|
||||
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
|
||||
data-id={id}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{children && cloneElement(children, { hidden: true })}
|
||||
</article>
|
||||
</Article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id} tabIndex={-1}>
|
||||
<Article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id}>
|
||||
{children && cloneElement(children, { hidden: false })}
|
||||
</article>
|
||||
</Article>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
import { setHeight } from '../actions/height_cache';
|
||||
import IntersectionObserverArticle from '../components/intersection_observer_article';
|
||||
import IntersectionObserverArticle from '../components/scrollable_list/intersection_observer_article';
|
||||
|
||||
const makeMapStateToProps = (state, props) => ({
|
||||
cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),
|
||||
|
|
|
|||
|
|
@ -1,125 +0,0 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnBackButton } from '@/mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import type { AccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from '@/mastodon/hooks/useAccountVisibility';
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { AccountHeader } from '../account_timeline/components/account_header';
|
||||
import { AccountHeaderFields } from '../account_timeline/components/fields';
|
||||
import { LimitedAccountHint } from '../account_timeline/components/limited_account_hint';
|
||||
|
||||
import classes from './styles.module.css';
|
||||
|
||||
const selectIsProfileEmpty = createAppSelector(
|
||||
[(state) => state.accounts, (_, accountId: AccountId) => accountId],
|
||||
(accounts, accountId) => {
|
||||
// Null means still loading, otherwise it's a boolean.
|
||||
if (!accountId) {
|
||||
return null;
|
||||
}
|
||||
const account = accounts.get(accountId);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
return !account.note && !account.fields.size;
|
||||
},
|
||||
);
|
||||
|
||||
export const AccountAbout: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
const accountId = useAccountId();
|
||||
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||
const forceEmptyState = blockedBy || hidden || suspended;
|
||||
|
||||
const isProfileEmpty = useAppSelector((state) =>
|
||||
selectIsProfileEmpty(state, accountId),
|
||||
);
|
||||
|
||||
if (accountId === null) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
|
||||
if (!accountId || isProfileEmpty === null) {
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const showEmptyMessage = forceEmptyState || isProfileEmpty;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn}>
|
||||
<ColumnBackButton />
|
||||
<div className='scrollable scrollable--flex'>
|
||||
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
|
||||
<div className={classes.wrapper}>
|
||||
{!showEmptyMessage ? (
|
||||
<>
|
||||
<AccountBio
|
||||
accountId={accountId}
|
||||
className={`${classes.bio} account__header__content`}
|
||||
/>
|
||||
<AccountHeaderFields accountId={accountId} />
|
||||
</>
|
||||
) : (
|
||||
<div className='empty-column-indicator'>
|
||||
<EmptyMessage accountId={accountId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyMessage: FC<{ accountId: string }> = ({ accountId }) => {
|
||||
const { blockedBy, hidden, suspended } = useAccountVisibility(accountId);
|
||||
const currentUserId = useAppSelector(
|
||||
(state) => state.meta.get('me') as string | null,
|
||||
);
|
||||
const { acct } = useParams<{ acct?: string }>();
|
||||
|
||||
if (suspended) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_suspended'
|
||||
defaultMessage='Account suspended'
|
||||
/>
|
||||
);
|
||||
} else if (hidden) {
|
||||
return <LimitedAccountHint accountId={accountId} />;
|
||||
} else if (blockedBy) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_unavailable'
|
||||
defaultMessage='Profile unavailable'
|
||||
/>
|
||||
);
|
||||
} else if (accountId === currentUserId) {
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_about.me'
|
||||
defaultMessage='You have not added any information about yourself yet.'
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormattedMessage
|
||||
id='empty_column.account_about.other'
|
||||
defaultMessage='{acct} has not added any information about themselves yet.'
|
||||
values={{ acct }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
.wrapper {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.bio {
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { TextArea } from '@/mastodon/components/form_fields';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CharCounter } from './char_counter';
|
||||
import { EmojiPicker } from './emoji_picker';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.bio_modal.add_title',
|
||||
defaultMessage: 'Add bio',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account_edit.bio_modal.edit_title',
|
||||
defaultMessage: 'Edit bio',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_BIO_LENGTH = 500;
|
||||
|
||||
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const counterId = useId();
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const [newBio, setNewBio] = useState(account?.note_plain ?? '');
|
||||
const handleChange: ChangeEventHandler<HTMLTextAreaElement> = useCallback(
|
||||
(event) => {
|
||||
setNewBio(event.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handlePickEmoji = useCallback((emoji: string) => {
|
||||
setNewBio((prev) => {
|
||||
const position = textAreaRef.current?.selectionStart ?? prev.length;
|
||||
return insertEmojiAtPosition(prev, emoji, position);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!account) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(
|
||||
account.note_plain ? messages.editTitle : messages.addTitle,
|
||||
)}
|
||||
titleId={titleId}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={onClose} // To be implemented
|
||||
onClose={onClose}
|
||||
noFocusButton
|
||||
>
|
||||
<div className={classes.inputWrapper}>
|
||||
<TextArea
|
||||
value={newBio}
|
||||
ref={textAreaRef}
|
||||
onChange={handleChange}
|
||||
className={classes.inputText}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={counterId}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
autoSize
|
||||
/>
|
||||
<EmojiPicker onPick={handlePickEmoji} />
|
||||
</div>
|
||||
<CharCounter
|
||||
currentLength={newBio.length}
|
||||
maxLength={MAX_BIO_LENGTH}
|
||||
id={counterId}
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { polymorphicForwardRef } from '@/types/polymorphic';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const CharCounter = polymorphicForwardRef<
|
||||
'p',
|
||||
{ currentLength: number; maxLength: number }
|
||||
>(({ currentLength, maxLength, as: Component = 'p' }, ref) => (
|
||||
<Component
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
classes.counter,
|
||||
currentLength > maxLength && classes.counterError,
|
||||
)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.char_counter'
|
||||
defaultMessage='{currentLength}/{maxLength} characters'
|
||||
values={{ currentLength, maxLength }}
|
||||
/>
|
||||
</Component>
|
||||
));
|
||||
CharCounter.displayName = 'CharCounter';
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnHeader } from '@/mastodon/components/column_header';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
|
||||
import { useColumnsContext } from '../../ui/util/columns_context';
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
export const AccountEditEmptyColumn: FC<{
|
||||
notFound?: boolean;
|
||||
}> = ({ notFound }) => {
|
||||
const { multiColumn } = useColumnsContext();
|
||||
|
||||
if (notFound) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountEditColumn: FC<{
|
||||
title: string;
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ to, title, children }) => {
|
||||
const { multiColumn } = useColumnsContext();
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||
<ColumnHeader
|
||||
title={title}
|
||||
className={classes.columnHeader}
|
||||
showBackButton
|
||||
extraButton={
|
||||
<Link to={to} className='button'>
|
||||
<FormattedMessage
|
||||
id='account_edit.column_button'
|
||||
defaultMessage='Done'
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
|
||||
{children}
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import type { FC, MouseEventHandler } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import DeleteIcon from '@/material-icons/400-24px/delete.svg?react';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
add: {
|
||||
id: 'account_edit.button.add',
|
||||
defaultMessage: 'Add {item}',
|
||||
},
|
||||
edit: {
|
||||
id: 'account_edit.button.edit',
|
||||
defaultMessage: 'Edit {item}',
|
||||
},
|
||||
delete: {
|
||||
id: 'account_edit.button.delete',
|
||||
defaultMessage: 'Delete {item}',
|
||||
},
|
||||
});
|
||||
|
||||
export interface EditButtonProps {
|
||||
onClick: MouseEventHandler;
|
||||
item: string | MessageDescriptor;
|
||||
edit?: boolean;
|
||||
icon?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const EditButton: FC<EditButtonProps> = ({
|
||||
onClick,
|
||||
item,
|
||||
edit = false,
|
||||
icon = edit,
|
||||
disabled,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const itemText = typeof item === 'string' ? item : intl.formatMessage(item);
|
||||
const label = intl.formatMessage(messages[edit ? 'edit' : 'add'], {
|
||||
item: itemText,
|
||||
});
|
||||
|
||||
if (icon) {
|
||||
return (
|
||||
<EditIconButton title={label} onClick={onClick} disabled={disabled} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={classes.editButton}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditIconButton: FC<{
|
||||
onClick: MouseEventHandler;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ title, onClick, disabled }) => (
|
||||
<IconButton
|
||||
icon='pencil'
|
||||
iconComponent={EditIcon}
|
||||
onClick={onClick}
|
||||
className={classes.editButton}
|
||||
title={title}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
|
||||
export const DeleteIconButton: FC<{
|
||||
onClick: MouseEventHandler;
|
||||
item: string;
|
||||
disabled?: boolean;
|
||||
}> = ({ onClick, item, disabled }) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<IconButton
|
||||
icon='delete'
|
||||
iconComponent={DeleteIcon}
|
||||
onClick={onClick}
|
||||
className={classNames(classes.editButton, classes.deleteButton)}
|
||||
title={intl.formatMessage(messages.delete, { item })}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { useCallback } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { isPlainObject } from '@reduxjs/toolkit';
|
||||
|
||||
import EmojiPickerDropdown from '../../compose/containers/emoji_picker_dropdown_container';
|
||||
|
||||
export const EmojiPicker: FC<{ onPick: (emoji: string) => void }> = ({
|
||||
onPick,
|
||||
}) => {
|
||||
const handlePick = useCallback(
|
||||
(emoji: unknown) => {
|
||||
if (isPlainObject(emoji)) {
|
||||
if ('native' in emoji && typeof emoji.native === 'string') {
|
||||
onPick(emoji.native);
|
||||
} else if (
|
||||
'shortcode' in emoji &&
|
||||
typeof emoji.shortcode === 'string'
|
||||
) {
|
||||
onPick(`:${emoji.shortcode}:`);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onPick],
|
||||
);
|
||||
return <EmojiPickerDropdown onPickEmoji={handlePick} />;
|
||||
};
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { FC } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
|
||||
import { EditButton, DeleteIconButton } from './edit_button';
|
||||
|
||||
export const AccountFieldActions: FC<{ item: string; id: string }> = ({
|
||||
item,
|
||||
id,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleEdit = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_EDIT_FIELD_EDIT',
|
||||
modalProps: { fieldKey: id },
|
||||
}),
|
||||
);
|
||||
}, [dispatch, id]);
|
||||
const handleDelete = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_EDIT_FIELD_DELETE',
|
||||
modalProps: { fieldKey: id },
|
||||
}),
|
||||
);
|
||||
}, [dispatch, id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditButton item={item} edit onClick={handleEdit} />
|
||||
<DeleteIconButton item={item} onClick={handleDelete} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { useCallback } from 'react';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { DeleteIconButton, EditButton } from './edit_button';
|
||||
|
||||
interface AnyItem {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AccountEditItemListProps<Item extends AnyItem = AnyItem> {
|
||||
renderItem?: (item: Item) => React.ReactNode;
|
||||
items: Item[];
|
||||
onEdit?: (item: Item) => void;
|
||||
onDelete?: (item: Item) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AccountEditItemList = <Item extends AnyItem>({
|
||||
renderItem,
|
||||
items,
|
||||
onEdit,
|
||||
onDelete,
|
||||
disabled,
|
||||
}: AccountEditItemListProps<Item>) => {
|
||||
if (items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className={classes.itemList}>
|
||||
{items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<span>{renderItem?.(item) ?? item.name}</span>
|
||||
<AccountEditItemButtons
|
||||
item={item}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
type AccountEditItemButtonsProps<Item extends AnyItem = AnyItem> = Pick<
|
||||
AccountEditItemListProps<Item>,
|
||||
'onEdit' | 'onDelete' | 'disabled'
|
||||
> & { item: Item };
|
||||
|
||||
const AccountEditItemButtons = <Item extends AnyItem>({
|
||||
item,
|
||||
onDelete,
|
||||
onEdit,
|
||||
disabled,
|
||||
}: AccountEditItemButtonsProps<Item>) => {
|
||||
const handleEdit = useCallback(() => {
|
||||
onEdit?.(item);
|
||||
}, [item, onEdit]);
|
||||
const handleDelete = useCallback(() => {
|
||||
onDelete?.(item);
|
||||
}, [item, onDelete]);
|
||||
|
||||
if (!onEdit && !onDelete) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.itemListButtons}>
|
||||
{onEdit && (
|
||||
<EditButton
|
||||
edit
|
||||
item={item.name}
|
||||
disabled={disabled}
|
||||
onClick={handleEdit}
|
||||
/>
|
||||
)}
|
||||
{onDelete && (
|
||||
<DeleteIconButton
|
||||
item={item.name}
|
||||
disabled={disabled}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { useCallback, useId, useRef, useState } from 'react';
|
||||
import type { ChangeEventHandler, FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { TextInput } from '@/mastodon/components/form_fields';
|
||||
import { insertEmojiAtPosition } from '@/mastodon/features/emoji/utils';
|
||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
import { CharCounter } from './char_counter';
|
||||
import { EmojiPicker } from './emoji_picker';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.name_modal.add_title',
|
||||
defaultMessage: 'Add display name',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account_edit.name_modal.edit_title',
|
||||
defaultMessage: 'Edit display name',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
const MAX_NAME_LENGTH = 30;
|
||||
|
||||
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
const counterId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
|
||||
const [newName, setNewName] = useState(account?.display_name ?? '');
|
||||
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
setNewName(event.currentTarget.value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const handlePickEmoji = useCallback((emoji: string) => {
|
||||
setNewName((prev) => {
|
||||
const position = inputRef.current?.selectionStart ?? prev.length;
|
||||
return insertEmojiAtPosition(prev, emoji, position);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.editTitle)}
|
||||
titleId={titleId}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={onClose} // To be implemented
|
||||
onClose={onClose}
|
||||
noCloseOnConfirm
|
||||
noFocusButton
|
||||
>
|
||||
<div className={classes.inputWrapper}>
|
||||
<TextInput
|
||||
value={newName}
|
||||
ref={inputRef}
|
||||
onChange={handleChange}
|
||||
className={classes.inputText}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={counterId}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
/>
|
||||
<EmojiPicker onPick={handlePickEmoji} />
|
||||
</div>
|
||||
<CharCounter
|
||||
currentLength={newName.length}
|
||||
maxLength={MAX_NAME_LENGTH}
|
||||
id={counterId}
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,55 +1,36 @@
|
|||
import type { FC, ReactNode } from 'react';
|
||||
|
||||
import type { MessageDescriptor } from 'react-intl';
|
||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import EditIcon from '@/material-icons/400-24px/edit.svg?react';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
const buttonMessage = defineMessage({
|
||||
id: 'account_edit.section_edit_button',
|
||||
defaultMessage: 'Edit',
|
||||
});
|
||||
|
||||
interface AccountEditSectionProps {
|
||||
title: MessageDescriptor;
|
||||
description?: MessageDescriptor;
|
||||
showDescription?: boolean;
|
||||
onEdit?: () => void;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
extraButtons?: ReactNode;
|
||||
buttons?: ReactNode;
|
||||
}
|
||||
|
||||
export const AccountEditSection: FC<AccountEditSectionProps> = ({
|
||||
title,
|
||||
description,
|
||||
showDescription,
|
||||
onEdit,
|
||||
children,
|
||||
className,
|
||||
extraButtons,
|
||||
buttons,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<section className={classNames(className, classes.section)}>
|
||||
<header className={classes.sectionHeader}>
|
||||
<h3 className={classes.sectionTitle}>
|
||||
<FormattedMessage {...title} />
|
||||
</h3>
|
||||
{onEdit && (
|
||||
<IconButton
|
||||
icon='pencil'
|
||||
iconComponent={EditIcon}
|
||||
onClick={onEdit}
|
||||
title={`${intl.formatMessage(buttonMessage)} ${intl.formatMessage(title)}`}
|
||||
/>
|
||||
)}
|
||||
{extraButtons}
|
||||
{buttons}
|
||||
</header>
|
||||
{showDescription && (
|
||||
<p className={classes.sectionSubtitle}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,95 @@
|
|||
import type { ChangeEventHandler, FC } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import type { ApiHashtagJSON } from '@/mastodon/api_types/tags';
|
||||
import { Combobox } from '@/mastodon/components/form_fields';
|
||||
import {
|
||||
addFeaturedTag,
|
||||
clearSearch,
|
||||
updateSearchQuery,
|
||||
} from '@/mastodon/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
import SearchIcon from '@/material-icons/400-24px/search.svg?react';
|
||||
|
||||
import classes from '../styles.module.scss';
|
||||
|
||||
type SearchResult = Omit<ApiHashtagJSON, 'url' | 'history'> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const messages = defineMessages({
|
||||
placeholder: {
|
||||
id: 'account_edit_tags.search_placeholder',
|
||||
defaultMessage: 'Enter a hashtag…',
|
||||
},
|
||||
addTag: {
|
||||
id: 'account_edit_tags.add_tag',
|
||||
defaultMessage: 'Add #{tagName}',
|
||||
},
|
||||
});
|
||||
|
||||
export const AccountEditTagSearch: FC = () => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
query,
|
||||
isLoading,
|
||||
results: rawResults,
|
||||
} = useAppSelector((state) => state.profileEdit.search);
|
||||
const results = useMemo(() => {
|
||||
if (!rawResults) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const results: SearchResult[] = [...rawResults]; // Make array mutable
|
||||
const trimmedQuery = query.trim();
|
||||
if (
|
||||
trimmedQuery.length > 0 &&
|
||||
results.every(
|
||||
(result) => result.name.toLowerCase() !== trimmedQuery.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
results.push({
|
||||
id: 'new',
|
||||
name: trimmedQuery,
|
||||
label: intl.formatMessage(messages.addTag, { tagName: trimmedQuery }),
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, [intl, query, rawResults]);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleSearchChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(e) => {
|
||||
void dispatch(updateSearchQuery(e.target.value));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: SearchResult) => {
|
||||
void dispatch(clearSearch());
|
||||
void dispatch(addFeaturedTag({ name: item.name }));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
value={query}
|
||||
onChange={handleSearchChange}
|
||||
placeholder={intl.formatMessage(messages.placeholder)}
|
||||
items={results}
|
||||
isLoading={isLoading}
|
||||
renderItem={renderItem}
|
||||
onSelectItem={handleSelect}
|
||||
className={classes.autoComplete}
|
||||
icon={SearchIcon}
|
||||
type='search'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderItem = (item: SearchResult) => item.label ?? `#${item.name}`;
|
||||
134
app/javascript/mastodon/features/account_edit/featured_tags.tsx
Normal file
134
app/javascript/mastodon/features/account_edit/featured_tags.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useCallback, useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { Tag } from '@/mastodon/components/tags/tag';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import type { TagData } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import {
|
||||
addFeaturedTag,
|
||||
deleteFeaturedTag,
|
||||
fetchProfile,
|
||||
fetchSuggestedTags,
|
||||
} from '@/mastodon/reducers/slices/profile_edit';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
|
||||
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
|
||||
import { AccountEditItemList } from './components/item_list';
|
||||
import { AccountEditTagSearch } from './components/tag_search';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
columnTitle: {
|
||||
id: 'account_edit_tags.column_title',
|
||||
defaultMessage: 'Edit featured hashtags',
|
||||
},
|
||||
});
|
||||
|
||||
const selectTags = createAppSelector(
|
||||
[(state) => state.profileEdit],
|
||||
(profileEdit) => ({
|
||||
tags: profileEdit.profile?.featuredTags ?? [],
|
||||
tagSuggestions: profileEdit.tagSuggestions ?? [],
|
||||
isLoading: !profileEdit.profile || !profileEdit.tagSuggestions,
|
||||
isPending: profileEdit.isPending,
|
||||
}),
|
||||
);
|
||||
|
||||
export const AccountEditFeaturedTags: FC = () => {
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
const intl = useIntl();
|
||||
|
||||
const { tags, tagSuggestions, isLoading, isPending } =
|
||||
useAppSelector(selectTags);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
useEffect(() => {
|
||||
void dispatch(fetchProfile());
|
||||
void dispatch(fetchSuggestedTags());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleDeleteTag = useCallback(
|
||||
({ id }: { id: string }) => {
|
||||
void dispatch(deleteFeaturedTag({ tagId: id }));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!accountId || !account) {
|
||||
return <AccountEditEmptyColumn notFound={!accountId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AccountEditColumn
|
||||
title={intl.formatMessage(messages.columnTitle)}
|
||||
to='/profile/edit'
|
||||
>
|
||||
<div className={classes.wrapper}>
|
||||
<FormattedMessage
|
||||
id='account_edit_tags.help_text'
|
||||
defaultMessage='Featured hashtags help users discover and interact with your profile. They appear as filters on your Profile page’s Activity view.'
|
||||
tagName='p'
|
||||
/>
|
||||
|
||||
<AccountEditTagSearch />
|
||||
|
||||
{tagSuggestions.length > 0 && (
|
||||
<div className={classes.tagSuggestions}>
|
||||
<FormattedMessage
|
||||
id='account_edit_tags.suggestions'
|
||||
defaultMessage='Suggestions:'
|
||||
/>
|
||||
{tagSuggestions.map((tag) => (
|
||||
<SuggestedTag name={tag.name} key={tag.id} disabled={isPending} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && <LoadingIndicator />}
|
||||
|
||||
<AccountEditItemList
|
||||
items={tags}
|
||||
disabled={isPending}
|
||||
renderItem={renderTag}
|
||||
onDelete={handleDeleteTag}
|
||||
/>
|
||||
</div>
|
||||
</AccountEditColumn>
|
||||
);
|
||||
};
|
||||
|
||||
function renderTag(tag: TagData) {
|
||||
return (
|
||||
<div className={classes.tagItem}>
|
||||
<h4>#{tag.name}</h4>
|
||||
{tag.statusesCount > 0 && (
|
||||
<FormattedMessage
|
||||
id='account_edit_tags.tag_status_count'
|
||||
defaultMessage='{count, plural, one {# post} other {# posts}}'
|
||||
values={{ count: tag.statusesCount }}
|
||||
tagName='p'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SuggestedTag: FC<{ name: string; disabled?: boolean }> = ({
|
||||
name,
|
||||
disabled,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const handleAddTag = useCallback(() => {
|
||||
void dispatch(addFeaturedTag({ name }));
|
||||
}, [dispatch, name]);
|
||||
return <Tag name={name} onClick={handleAddTag} disabled={disabled} />;
|
||||
};
|
||||
|
|
@ -1,28 +1,35 @@
|
|||
import { useCallback } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import type { ModalType } from '@/mastodon/actions/modal';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { AccountBio } from '@/mastodon/components/account_bio';
|
||||
import { Avatar } from '@/mastodon/components/avatar';
|
||||
import { Column } from '@/mastodon/components/column';
|
||||
import { ColumnHeader } from '@/mastodon/components/column_header';
|
||||
import { DisplayNameSimple } from '@/mastodon/components/display_name/simple';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import BundleColumnError from '@/mastodon/features/ui/components/bundle_column_error';
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { DismissibleCallout } from '@/mastodon/components/callout/dismissible';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import { useCurrentAccountId } from '@/mastodon/hooks/useAccountId';
|
||||
import { autoPlayGif } from '@/mastodon/initial_state';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
import { fetchProfile } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import { AccountEditColumn, AccountEditEmptyColumn } from './components/column';
|
||||
import { EditButton } from './components/edit_button';
|
||||
import { AccountFieldActions } from './components/field_actions';
|
||||
import { AccountEditSection } from './components/section';
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
export const messages = defineMessages({
|
||||
columnTitle: {
|
||||
id: 'account_edit.column_title',
|
||||
defaultMessage: 'Edit Profile',
|
||||
},
|
||||
displayNameTitle: {
|
||||
id: 'account_edit.display_name.title',
|
||||
defaultMessage: 'Display name',
|
||||
|
|
@ -49,6 +56,14 @@ const messages = defineMessages({
|
|||
defaultMessage:
|
||||
'Add your pronouns, external links, or anything else you’d like to share.',
|
||||
},
|
||||
customFieldsName: {
|
||||
id: 'account_edit.custom_fields.name',
|
||||
defaultMessage: 'field',
|
||||
},
|
||||
customFieldsTipTitle: {
|
||||
id: 'account_edit.custom_fields.tip_title',
|
||||
defaultMessage: 'Tip: Adding verified links',
|
||||
},
|
||||
featuredHashtagsTitle: {
|
||||
id: 'account_edit.featured_hashtags.title',
|
||||
defaultMessage: 'Featured hashtags',
|
||||
|
|
@ -58,6 +73,10 @@ const messages = defineMessages({
|
|||
defaultMessage:
|
||||
'Help others identify, and have quick access to, your favorite topics.',
|
||||
},
|
||||
featuredHashtagsItem: {
|
||||
id: 'account_edit.featured_hashtags.item',
|
||||
defaultMessage: 'hashtags',
|
||||
},
|
||||
profileTabTitle: {
|
||||
id: 'account_edit.profile_tab.title',
|
||||
defaultMessage: 'Profile tab settings',
|
||||
|
|
@ -68,12 +87,18 @@ const messages = defineMessages({
|
|||
},
|
||||
});
|
||||
|
||||
export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
||||
export const AccountEdit: FC = () => {
|
||||
const accountId = useCurrentAccountId();
|
||||
const account = useAccount(accountId);
|
||||
const intl = useIntl();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { profile } = useAppSelector((state) => state.profileEdit);
|
||||
useEffect(() => {
|
||||
void dispatch(fetchProfile());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleOpenModal = useCallback(
|
||||
(type: ModalType, props?: Record<string, unknown>) => {
|
||||
dispatch(openModal({ modalType: type, modalProps: props ?? {} }));
|
||||
|
|
@ -86,39 +111,39 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
const handleBioEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_BIO');
|
||||
}, [handleOpenModal]);
|
||||
const handleCustomFieldsVerifiedHelp = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_VERIFY_LINKS');
|
||||
}, [handleOpenModal]);
|
||||
const handleProfileDisplayEdit = useCallback(() => {
|
||||
handleOpenModal('ACCOUNT_EDIT_PROFILE_DISPLAY');
|
||||
}, [handleOpenModal]);
|
||||
|
||||
if (!accountId) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
const history = useHistory();
|
||||
const handleFeaturedTagsEdit = useCallback(() => {
|
||||
history.push('/profile/featured_tags');
|
||||
}, [history]);
|
||||
|
||||
// Normally we would use the account emoji, but we want all custom emojis to be available to render after editing.
|
||||
const emojis = useAppSelector((state) => state.custom_emojis);
|
||||
const htmlHandlers = useElementHandledLink({
|
||||
hashtagAccountId: profile?.id,
|
||||
});
|
||||
|
||||
if (!accountId || !account || !profile) {
|
||||
return <AccountEditEmptyColumn notFound={!accountId} />;
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||
<LoadingIndicator />
|
||||
</Column>
|
||||
);
|
||||
}
|
||||
|
||||
const headerSrc = autoPlayGif ? account.header : account.header_static;
|
||||
const headerSrc = autoPlayGif ? profile.header : profile.headerStatic;
|
||||
const hasName = !!profile.displayName;
|
||||
const hasBio = !!profile.bio;
|
||||
const hasFields = profile.fields.length > 0;
|
||||
const hasTags = profile.featuredTags.length > 0;
|
||||
|
||||
return (
|
||||
<Column bindToDocument={!multiColumn} className={classes.column}>
|
||||
<ColumnHeader
|
||||
title={intl.formatMessage({
|
||||
id: 'account_edit.column_title',
|
||||
defaultMessage: 'Edit Profile',
|
||||
})}
|
||||
className={classes.columnHeader}
|
||||
showBackButton
|
||||
extraButton={
|
||||
<Link to={`/@${account.acct}`} className='button'>
|
||||
<FormattedMessage
|
||||
id='account_edit.column_button'
|
||||
defaultMessage='Done'
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<AccountEditColumn
|
||||
title={intl.formatMessage(messages.columnTitle)}
|
||||
to={`/@${account.acct}`}
|
||||
>
|
||||
<header>
|
||||
<div className={classes.profileImage}>
|
||||
{headerSrc && <img src={headerSrc} alt='' />}
|
||||
|
|
@ -126,41 +151,115 @@ export const AccountEdit: FC<{ multiColumn: boolean }> = ({ multiColumn }) => {
|
|||
<Avatar account={account} size={80} className={classes.avatar} />
|
||||
</header>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.displayNameTitle}
|
||||
description={messages.displayNamePlaceholder}
|
||||
showDescription={account.display_name.length === 0}
|
||||
onEdit={handleNameEdit}
|
||||
>
|
||||
<DisplayNameSimple account={account} />
|
||||
</AccountEditSection>
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<AccountEditSection
|
||||
title={messages.displayNameTitle}
|
||||
description={messages.displayNamePlaceholder}
|
||||
showDescription={!hasName}
|
||||
buttons={
|
||||
<EditButton
|
||||
onClick={handleNameEdit}
|
||||
item={messages.displayNameTitle}
|
||||
edit={hasName}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EmojiHTML htmlString={profile.displayName} {...htmlHandlers} />
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.bioTitle}
|
||||
description={messages.bioPlaceholder}
|
||||
showDescription={!account.note_plain}
|
||||
onEdit={handleBioEdit}
|
||||
>
|
||||
<AccountBio accountId={accountId} />
|
||||
</AccountEditSection>
|
||||
<AccountEditSection
|
||||
title={messages.bioTitle}
|
||||
description={messages.bioPlaceholder}
|
||||
showDescription={!hasBio}
|
||||
buttons={
|
||||
<EditButton
|
||||
onClick={handleBioEdit}
|
||||
item={messages.bioTitle}
|
||||
edit={hasBio}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<EmojiHTML htmlString={profile.bio} {...htmlHandlers} />
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.customFieldsTitle}
|
||||
description={messages.customFieldsPlaceholder}
|
||||
showDescription
|
||||
/>
|
||||
<AccountEditSection
|
||||
title={messages.customFieldsTitle}
|
||||
description={messages.customFieldsPlaceholder}
|
||||
showDescription={!hasFields}
|
||||
>
|
||||
<ol>
|
||||
{profile.fields.map((field) => (
|
||||
<li key={field.id} className={classes.field}>
|
||||
<div>
|
||||
<EmojiHTML
|
||||
htmlString={field.name}
|
||||
className={classes.fieldName}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
<EmojiHTML htmlString={field.value} {...htmlHandlers} />
|
||||
</div>
|
||||
<AccountFieldActions
|
||||
item={intl.formatMessage(messages.customFieldsName)}
|
||||
id={field.id}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<Button
|
||||
onClick={handleCustomFieldsVerifiedHelp}
|
||||
className={classes.verifiedLinkHelpButton}
|
||||
plain
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.custom_fields.verified_hint'
|
||||
defaultMessage='How do I add a verified link?'
|
||||
/>
|
||||
</Button>
|
||||
{!hasFields && (
|
||||
<DismissibleCallout
|
||||
id='profile_edit_fields_tip'
|
||||
title={intl.formatMessage(messages.customFieldsTipTitle)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.custom_fields.tip_content'
|
||||
defaultMessage='You can easily add credibility to your Mastodon account by verifying links to any websites you own.'
|
||||
/>
|
||||
</DismissibleCallout>
|
||||
)}
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.featuredHashtagsTitle}
|
||||
description={messages.featuredHashtagsPlaceholder}
|
||||
showDescription
|
||||
/>
|
||||
<AccountEditSection
|
||||
title={messages.featuredHashtagsTitle}
|
||||
description={messages.featuredHashtagsPlaceholder}
|
||||
showDescription={!hasTags}
|
||||
buttons={
|
||||
<EditButton
|
||||
onClick={handleFeaturedTagsEdit}
|
||||
edit={hasTags}
|
||||
item={messages.featuredHashtagsItem}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{profile.featuredTags.map((tag) => `#${tag.name}`).join(', ')}
|
||||
</AccountEditSection>
|
||||
|
||||
<AccountEditSection
|
||||
title={messages.profileTabTitle}
|
||||
description={messages.profileTabSubtitle}
|
||||
showDescription
|
||||
/>
|
||||
</Column>
|
||||
<AccountEditSection
|
||||
title={messages.profileTabTitle}
|
||||
description={messages.profileTabSubtitle}
|
||||
showDescription
|
||||
buttons={
|
||||
<Button
|
||||
className={classes.editButton}
|
||||
onClick={handleProfileDisplayEdit}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.button_label'
|
||||
defaultMessage='Customize'
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</CustomEmojiProvider>
|
||||
</AccountEditColumn>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
import { useCallback, useId, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { EmojiTextAreaField } from '@/mastodon/components/form_fields';
|
||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.bio_modal.add_title',
|
||||
defaultMessage: 'Add bio',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account_edit.bio_modal.edit_title',
|
||||
defaultMessage: 'Edit bio',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
export const BioModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
|
||||
const { profile: { bio } = {}, isPending } = useAppSelector(
|
||||
(state) => state.profileEdit,
|
||||
);
|
||||
const [newBio, setNewBio] = useState(bio ?? '');
|
||||
const maxLength = useAppSelector(
|
||||
(state) =>
|
||||
state.server.getIn([
|
||||
'server',
|
||||
'configuration',
|
||||
'accounts',
|
||||
'max_note_length',
|
||||
]) as number | undefined,
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleSave = useCallback(() => {
|
||||
if (!isPending) {
|
||||
void dispatch(patchProfile({ note: newBio })).then(onClose);
|
||||
}
|
||||
}, [dispatch, isPending, newBio, onClose]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(bio ? messages.editTitle : messages.addTitle)}
|
||||
titleId={titleId}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={handleSave}
|
||||
onClose={onClose}
|
||||
updating={isPending}
|
||||
disabled={!!maxLength && newBio.length > maxLength}
|
||||
noFocusButton
|
||||
>
|
||||
<EmojiTextAreaField
|
||||
label=''
|
||||
value={newBio}
|
||||
onChange={setNewBio}
|
||||
aria-labelledby={titleId}
|
||||
maxLength={maxLength}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
autoSize
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { useCallback, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import type { Map as ImmutableMap } from 'immutable';
|
||||
|
||||
import { Button } from '@/mastodon/components/button';
|
||||
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
|
||||
import {
|
||||
removeField,
|
||||
selectFieldById,
|
||||
updateField,
|
||||
} from '@/mastodon/reducers/slices/profile_edit';
|
||||
import {
|
||||
createAppSelector,
|
||||
useAppDispatch,
|
||||
useAppSelector,
|
||||
} from '@/mastodon/store';
|
||||
|
||||
import { ConfirmationModal } from '../../ui/components/confirmation_modals';
|
||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const messages = defineMessages({
|
||||
editTitle: {
|
||||
id: 'account_edit.field_edit_modal.edit_title',
|
||||
defaultMessage: 'Edit custom field',
|
||||
},
|
||||
addTitle: {
|
||||
id: 'account_edit.field_edit_modal.add_title',
|
||||
defaultMessage: 'Add custom field',
|
||||
},
|
||||
editLabelField: {
|
||||
id: 'account_edit.field_edit_modal.name_label',
|
||||
defaultMessage: 'Label',
|
||||
},
|
||||
editLabelHint: {
|
||||
id: 'account_edit.field_edit_modal.name_hint',
|
||||
defaultMessage: 'E.g. “Personal website”',
|
||||
},
|
||||
editValueField: {
|
||||
id: 'account_edit.field_edit_modal.value_label',
|
||||
defaultMessage: 'Value',
|
||||
},
|
||||
editValueHint: {
|
||||
id: 'account_edit.field_edit_modal.value_hint',
|
||||
defaultMessage: 'E.g. “example.me”',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
const selectFieldLimits = createAppSelector(
|
||||
[
|
||||
(state) =>
|
||||
state.server.getIn(['server', 'configuration', 'accounts']) as
|
||||
| ImmutableMap<string, number>
|
||||
| undefined,
|
||||
],
|
||||
(accounts) => ({
|
||||
nameLimit: accounts?.get('profile_field_name_limit'),
|
||||
valueLimit: accounts?.get('profile_field_value_limit'),
|
||||
}),
|
||||
);
|
||||
|
||||
export const EditFieldModal: FC<DialogModalProps & { fieldKey?: string }> = ({
|
||||
onClose,
|
||||
fieldKey,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const field = useAppSelector((state) => selectFieldById(state, fieldKey));
|
||||
const [newLabel, setNewLabel] = useState(field?.name ?? '');
|
||||
const [newValue, setNewValue] = useState(field?.value ?? '');
|
||||
|
||||
const { nameLimit, valueLimit } = useAppSelector(selectFieldLimits);
|
||||
const isPending = useAppSelector((state) => state.profileEdit.isPending);
|
||||
|
||||
const disabled =
|
||||
!nameLimit ||
|
||||
!valueLimit ||
|
||||
newLabel.length > nameLimit ||
|
||||
newValue.length > valueLimit;
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleSave = useCallback(() => {
|
||||
if (disabled || isPending) {
|
||||
return;
|
||||
}
|
||||
void dispatch(
|
||||
updateField({ id: fieldKey, name: newLabel, value: newValue }),
|
||||
).then(onClose);
|
||||
}, [disabled, dispatch, fieldKey, isPending, newLabel, newValue, onClose]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
onClose={onClose}
|
||||
title={
|
||||
field
|
||||
? intl.formatMessage(messages.editTitle)
|
||||
: intl.formatMessage(messages.addTitle)
|
||||
}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={handleSave}
|
||||
updating={isPending}
|
||||
disabled={disabled}
|
||||
className={classes.wrapper}
|
||||
>
|
||||
<EmojiTextInputField
|
||||
value={newLabel}
|
||||
onChange={setNewLabel}
|
||||
label={intl.formatMessage(messages.editLabelField)}
|
||||
hint={intl.formatMessage(messages.editLabelHint)}
|
||||
maxLength={nameLimit}
|
||||
/>
|
||||
|
||||
<EmojiTextInputField
|
||||
value={newValue}
|
||||
onChange={setNewValue}
|
||||
label={intl.formatMessage(messages.editValueField)}
|
||||
hint={intl.formatMessage(messages.editValueHint)}
|
||||
maxLength={valueLimit}
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const DeleteFieldModal: FC<DialogModalProps & { fieldKey: string }> = ({
|
||||
onClose,
|
||||
fieldKey,
|
||||
}) => {
|
||||
const isPending = useAppSelector((state) => state.profileEdit.isPending);
|
||||
const dispatch = useAppDispatch();
|
||||
const handleDelete = useCallback(() => {
|
||||
void dispatch(removeField({ key: fieldKey })).then(onClose);
|
||||
}, [dispatch, fieldKey, onClose]);
|
||||
|
||||
return (
|
||||
<DialogModal
|
||||
onClose={onClose}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='account_edit.field_delete_modal.title'
|
||||
defaultMessage='Delete custom field?'
|
||||
/>
|
||||
}
|
||||
buttons={
|
||||
<Button dangerous onClick={handleDelete} disabled={isPending}>
|
||||
<FormattedMessage
|
||||
id='account_edit.field_delete_modal.delete_button'
|
||||
defaultMessage='Delete'
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.field_delete_modal.confirm'
|
||||
defaultMessage='Are you sure you want to delete this custom field? This action can’t be undone.'
|
||||
tagName='p'
|
||||
/>
|
||||
</DialogModal>
|
||||
);
|
||||
};
|
||||
|
||||
export const RearrangeFieldsModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||
return (
|
||||
<DialogModal onClose={onClose} title='Not implemented yet'>
|
||||
<p>Not implemented yet</p>
|
||||
</DialogModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from './bio_modal';
|
||||
export * from './fields_modals';
|
||||
export * from './name_modal';
|
||||
export * from './profile_display_modal';
|
||||
export * from './verified_modal';
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import { useCallback, useId, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessages, useIntl } from 'react-intl';
|
||||
|
||||
import { EmojiTextInputField } from '@/mastodon/components/form_fields';
|
||||
import type { BaseConfirmationModalProps } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { ConfirmationModal } from '@/mastodon/features/ui/components/confirmation_modals';
|
||||
import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
const messages = defineMessages({
|
||||
addTitle: {
|
||||
id: 'account_edit.name_modal.add_title',
|
||||
defaultMessage: 'Add display name',
|
||||
},
|
||||
editTitle: {
|
||||
id: 'account_edit.name_modal.edit_title',
|
||||
defaultMessage: 'Edit display name',
|
||||
},
|
||||
save: {
|
||||
id: 'account_edit.save',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
});
|
||||
|
||||
export const NameModal: FC<BaseConfirmationModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
const titleId = useId();
|
||||
|
||||
const { profile: { displayName } = {}, isPending } = useAppSelector(
|
||||
(state) => state.profileEdit,
|
||||
);
|
||||
const maxLength = useAppSelector(
|
||||
(state) =>
|
||||
state.server.getIn([
|
||||
'server',
|
||||
'configuration',
|
||||
'accounts',
|
||||
'max_display_name_length',
|
||||
]) as number | undefined,
|
||||
);
|
||||
|
||||
const [newName, setNewName] = useState(displayName ?? '');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleSave = useCallback(() => {
|
||||
if (!isPending) {
|
||||
void dispatch(patchProfile({ display_name: newName })).then(onClose);
|
||||
}
|
||||
}, [dispatch, isPending, newName, onClose]);
|
||||
|
||||
return (
|
||||
<ConfirmationModal
|
||||
title={intl.formatMessage(messages.editTitle)}
|
||||
titleId={titleId}
|
||||
confirm={intl.formatMessage(messages.save)}
|
||||
onConfirm={handleSave}
|
||||
onClose={onClose}
|
||||
updating={isPending}
|
||||
disabled={!!maxLength && newName.length > maxLength}
|
||||
noCloseOnConfirm
|
||||
noFocusButton
|
||||
>
|
||||
<EmojiTextInputField
|
||||
value={newName}
|
||||
onChange={setNewName}
|
||||
aria-labelledby={titleId}
|
||||
maxLength={maxLength}
|
||||
label=''
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is a modal, it's fine.
|
||||
autoFocus
|
||||
/>
|
||||
</ConfirmationModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import type { ChangeEventHandler, FC } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import { Callout } from '@/mastodon/components/callout';
|
||||
import { ToggleField } from '@/mastodon/components/form_fields';
|
||||
import { LoadingIndicator } from '@/mastodon/components/loading_indicator';
|
||||
import { patchProfile } from '@/mastodon/reducers/slices/profile_edit';
|
||||
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
|
||||
|
||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||
import { messages } from '../index';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
export const ProfileDisplayModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { profile, isPending } = useAppSelector((state) => state.profileEdit);
|
||||
const serverName = useAppSelector(
|
||||
(state) => state.meta.get('domain') as string,
|
||||
);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleToggleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
|
||||
(event) => {
|
||||
const { name, checked } = event.target;
|
||||
void dispatch(patchProfile({ [name]: checked }));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!profile) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogModal
|
||||
onClose={onClose}
|
||||
title={intl.formatMessage(messages.profileTabTitle)}
|
||||
noCancelButton
|
||||
>
|
||||
<div className={classes.toggleInputWrapper}>
|
||||
<ToggleField
|
||||
checked={profile.showMedia}
|
||||
onChange={handleToggleChange}
|
||||
disabled={isPending}
|
||||
name='show_media'
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_media.title'
|
||||
defaultMessage='Show ‘Media’ tab'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_media.description'
|
||||
defaultMessage='‘Media’ is an optional tab that shows your posts containing images or videos.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleField
|
||||
checked={profile.showMediaReplies}
|
||||
onChange={handleToggleChange}
|
||||
disabled={!profile.showMedia || isPending}
|
||||
name='show_media_replies'
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_media_replies.title'
|
||||
defaultMessage='Include replies on ‘Media’ tab'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_media_replies.description'
|
||||
defaultMessage='When enabled, Media tab shows both your posts and replies to other people’s posts.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ToggleField
|
||||
checked={profile.showFeatured}
|
||||
onChange={handleToggleChange}
|
||||
disabled={isPending}
|
||||
name='show_featured'
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_featured.title'
|
||||
defaultMessage='Show ‘Featured’ tab'
|
||||
/>
|
||||
}
|
||||
hint={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.show_featured.description'
|
||||
defaultMessage='‘Featured’ is an optional tab where you can showcase other accounts.'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Callout
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.hint.title'
|
||||
defaultMessage='Displays still vary'
|
||||
/>
|
||||
}
|
||||
icon={false}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.profile_tab.hint.description'
|
||||
defaultMessage='These settings customize what users see on {server} in the official apps, but they may not apply to users on other servers and 3rd party apps.'
|
||||
values={{
|
||||
server: serverName,
|
||||
}}
|
||||
/>
|
||||
</Callout>
|
||||
</DialogModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
.wrapper {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toggleInputWrapper {
|
||||
> div {
|
||||
padding: 12px 0;
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid var(--color-border-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.verifiedSteps {
|
||||
font-size: 15px;
|
||||
|
||||
li {
|
||||
counter-increment: steps;
|
||||
padding-left: 34px;
|
||||
margin-top: 24px;
|
||||
position: relative;
|
||||
|
||||
h2 {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: counter(steps);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
padding: 4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.details {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
list-style: none;
|
||||
margin-bottom: 8px;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
}
|
||||
|
||||
:global(.icon) {
|
||||
width: 1.4em;
|
||||
height: 1.4em;
|
||||
vertical-align: middle;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
&[open] :global(.icon) {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import { CopyLinkField } from '@/mastodon/components/form_fields/copy_link_field';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { createAppSelector, useAppSelector } from '@/mastodon/store';
|
||||
import ExpandArrowIcon from '@/material-icons/400-24px/expand_more.svg?react';
|
||||
|
||||
import type { DialogModalProps } from '../../ui/components/dialog_modal';
|
||||
import { DialogModal } from '../../ui/components/dialog_modal';
|
||||
|
||||
import classes from './styles.module.scss';
|
||||
|
||||
const selectAccountUrl = createAppSelector(
|
||||
[(state) => state.meta.get('me') as string, (state) => state.accounts],
|
||||
(accountId, accounts) => {
|
||||
const account = accounts.get(accountId);
|
||||
return account?.get('url') ?? '';
|
||||
},
|
||||
);
|
||||
|
||||
export const VerifiedModal: FC<DialogModalProps> = ({ onClose }) => {
|
||||
const accountUrl = useAppSelector(selectAccountUrl);
|
||||
|
||||
return (
|
||||
<DialogModal
|
||||
onClose={onClose}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id='account_edit.verified_modal.title'
|
||||
defaultMessage='How to add a verified link'
|
||||
/>
|
||||
}
|
||||
noCancelButton
|
||||
>
|
||||
<FormattedMessage
|
||||
id='account_edit.verified_modal.details'
|
||||
defaultMessage='Add credibility to your Mastodon profile by verifying links to personal websites. Here’s how it works:'
|
||||
tagName='p'
|
||||
/>
|
||||
|
||||
<ol className={classes.verifiedSteps}>
|
||||
<li>
|
||||
<CopyLinkField
|
||||
label={
|
||||
<FormattedMessage
|
||||
id='account_edit.verified_modal.step1.header'
|
||||
defaultMessage='Copy the HTML code below and paste into the header of your website'
|
||||
tagName='h2'
|
||||
/>
|
||||
}
|
||||
value={`<a rel="me" href="${accountUrl}">Mastodon</a>`}
|
||||
/>
|
||||
<details className={classes.details}>
|
||||
<summary>
|
||||
<FormattedMessage
|
||||
id='account_edit.verified_modal.invisible_link.summary'
|
||||
defaultMessage='How do I make the link invisible?'
|
||||
/>
|
||||
<Icon icon={ExpandArrowIcon} id='arrow' />
|
||||
</summary>
|
||||
<FormattedMessage
|
||||
id='account_edit.verified_modal.invisible_link.details'
|
||||
defaultMessage='Add the link to your header. The important part is rel="me" which prevents impersonation on websites with user-generated content. You can even use a link tag in the header of the page instead of {tag}, but the HTML must be accessible without executing JavaScript.'
|
||||
values={{ tag: <code><a></code> }}
|
||||
/>
|
||||
</details>
|
||||
</li>
|
||||
<li>
|
||||
<FormattedMessage
|
||||
id='account_edit.verified_modal.step2.header'
|
||||
defaultMessage='Add your website as a custom field'
|
||||
tagName='h2'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='account_edit.verified_modal.step2.details'
|
||||
defaultMessage='If you’ve already added your website as a custom field, you’ll need to delete and re-add it to trigger verification.'
|
||||
tagName='p'
|
||||
/>
|
||||
</li>
|
||||
</ol>
|
||||
</DialogModal>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,15 +1,4 @@
|
|||
.column {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
:global(.column-header__buttons) {
|
||||
align-items: center;
|
||||
padding-inline-end: 16px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
// Profile Edit Page
|
||||
|
||||
.profileImage {
|
||||
height: 120px;
|
||||
|
|
@ -35,6 +24,145 @@
|
|||
border: 1px solid var(--color-border-primary);
|
||||
}
|
||||
|
||||
.field {
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: start;
|
||||
|
||||
> div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldName {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.verifiedLinkHelpButton {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-decoration: underline;
|
||||
|
||||
&:global(.button) {
|
||||
color: var(--color-text-primary);
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
&:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Featured Tags Page
|
||||
|
||||
.wrapper {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.autoComplete,
|
||||
.tagSuggestions {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.tagSuggestions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
// Add more padding to the suggestions label
|
||||
> span {
|
||||
margin-right: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.tagItem {
|
||||
> h4 {
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
> p {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
// Column component
|
||||
|
||||
.column {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-top-width: 0;
|
||||
}
|
||||
|
||||
.columnHeader {
|
||||
:global(.column-header__buttons) {
|
||||
align-items: center;
|
||||
padding-inline-end: 16px;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
// Edit button component
|
||||
|
||||
.editButton {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
transition:
|
||||
color 0.2s ease-in-out,
|
||||
background-color 0.2s ease-in-out;
|
||||
|
||||
&:global(.button) {
|
||||
background-color: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
|
||||
&:active,
|
||||
&:focus,
|
||||
&:hover {
|
||||
background-color: var(--color-bg-brand-softer);
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
--default-icon-color: var(--color-text-error);
|
||||
--hover-bg-color: var(--color-bg-error-base-hover);
|
||||
--hover-icon-color: var(--color-text-on-error-base);
|
||||
}
|
||||
|
||||
// Item list component
|
||||
|
||||
.itemList {
|
||||
> li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
|
||||
> :first-child {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.itemListButtons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// Section component
|
||||
|
||||
.section {
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid var(--color-border-primary);
|
||||
|
|
@ -45,19 +173,7 @@
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
> button {
|
||||
border: 1px solid var(--color-border-primary);
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
padding: 4px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
|
|
@ -69,42 +185,3 @@
|
|||
.sectionSubtitle {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.inputWrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
// Override input styles
|
||||
.inputWrapper .inputText {
|
||||
font-size: 15px;
|
||||
padding-right: 32px;
|
||||
}
|
||||
|
||||
textarea.inputText {
|
||||
min-height: 82px;
|
||||
height: 100%;
|
||||
|
||||
// 160px is approx the height of the modal header and footer
|
||||
max-height: calc(80vh - 160px);
|
||||
}
|
||||
|
||||
.inputWrapper :global(.emoji-picker-dropdown) {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
right: 8px;
|
||||
height: 24px;
|
||||
z-index: 1;
|
||||
|
||||
:global(.icon-button) {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.counterError {
|
||||
color: var(--color-text-error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,13 +12,25 @@ import { Account } from 'mastodon/components/account';
|
|||
import { ColumnBackButton } from 'mastodon/components/column_back_button';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
import { RemoteHint } from 'mastodon/components/remote_hint';
|
||||
import {
|
||||
Article,
|
||||
ItemList,
|
||||
Scrollable,
|
||||
} from 'mastodon/components/scrollable_list/components';
|
||||
import { AccountHeader } from 'mastodon/features/account_timeline/components/account_header';
|
||||
import BundleColumnError from 'mastodon/features/ui/components/bundle_column_error';
|
||||
import Column from 'mastodon/features/ui/components/column';
|
||||
import { useAccountId } from 'mastodon/hooks/useAccountId';
|
||||
import { useAccountVisibility } from 'mastodon/hooks/useAccountVisibility';
|
||||
import {
|
||||
fetchAccountCollections,
|
||||
selectAccountCollections,
|
||||
} from 'mastodon/reducers/slices/collections';
|
||||
import { useAppDispatch, useAppSelector } from 'mastodon/store';
|
||||
|
||||
import { CollectionListItem } from '../collections/detail/collection_list_item';
|
||||
import { areCollectionsEnabled } from '../collections/utils';
|
||||
|
||||
import { EmptyMessage } from './components/empty_message';
|
||||
import { FeaturedTag } from './components/featured_tag';
|
||||
import type { TagMap } from './components/featured_tag';
|
||||
|
|
@ -42,6 +54,9 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||
if (accountId) {
|
||||
void dispatch(fetchFeaturedTags({ accountId }));
|
||||
void dispatch(fetchEndorsedAccounts({ accountId }));
|
||||
if (areCollectionsEnabled()) {
|
||||
void dispatch(fetchAccountCollections({ accountId }));
|
||||
}
|
||||
}
|
||||
}, [accountId, dispatch]);
|
||||
|
||||
|
|
@ -64,6 +79,15 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||
ImmutableList(),
|
||||
) as ImmutableList<string>,
|
||||
);
|
||||
const { collections, status } = useAppSelector((state) =>
|
||||
selectAccountCollections(state, accountId ?? null),
|
||||
);
|
||||
const listedCollections = collections.filter(
|
||||
// Hide unlisted and empty collections to avoid confusion
|
||||
// (Unlisted collections will only be part of the payload
|
||||
// when viewing your own profile.)
|
||||
(item) => item.discoverable && !!item.item_count,
|
||||
);
|
||||
|
||||
if (accountId === null) {
|
||||
return <BundleColumnError multiColumn={multiColumn} errorType='routing' />;
|
||||
|
|
@ -97,10 +121,31 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||
<Column>
|
||||
<ColumnBackButton />
|
||||
|
||||
<div className='scrollable scrollable--flex'>
|
||||
<Scrollable>
|
||||
{accountId && (
|
||||
<AccountHeader accountId={accountId} hideTabs={forceEmptyState} />
|
||||
)}
|
||||
{listedCollections.length > 0 && status === 'idle' && (
|
||||
<>
|
||||
<h4 className='column-subheading'>
|
||||
<FormattedMessage
|
||||
id='account.featured.collections'
|
||||
defaultMessage='Collections'
|
||||
/>
|
||||
</h4>
|
||||
<ItemList>
|
||||
{listedCollections.map((item, index) => (
|
||||
<CollectionListItem
|
||||
key={item.id}
|
||||
collection={item}
|
||||
withoutBorder={index === listedCollections.length - 1}
|
||||
positionInList={index + 1}
|
||||
listSize={listedCollections.length}
|
||||
/>
|
||||
))}
|
||||
</ItemList>
|
||||
</>
|
||||
)}
|
||||
{!featuredTags.isEmpty() && (
|
||||
<>
|
||||
<h4 className='column-subheading'>
|
||||
|
|
@ -109,9 +154,18 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||
defaultMessage='Hashtags'
|
||||
/>
|
||||
</h4>
|
||||
{featuredTags.map((tag) => (
|
||||
<FeaturedTag key={tag.get('id')} tag={tag} account={acct} />
|
||||
))}
|
||||
<ItemList>
|
||||
{featuredTags.map((tag, index) => (
|
||||
<Article
|
||||
focusable
|
||||
key={tag.get('id')}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={featuredTags.size}
|
||||
>
|
||||
<FeaturedTag tag={tag} account={acct} />
|
||||
</Article>
|
||||
))}
|
||||
</ItemList>
|
||||
</>
|
||||
)}
|
||||
{!featuredAccountIds.isEmpty() && (
|
||||
|
|
@ -122,13 +176,22 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
|
|||
defaultMessage='Profiles'
|
||||
/>
|
||||
</h4>
|
||||
{featuredAccountIds.map((featuredAccountId) => (
|
||||
<Account key={featuredAccountId} id={featuredAccountId} />
|
||||
))}
|
||||
<ItemList>
|
||||
{featuredAccountIds.map((featuredAccountId, index) => (
|
||||
<Article
|
||||
focusable
|
||||
key={featuredAccountId}
|
||||
aria-posinset={index + 1}
|
||||
aria-setsize={featuredAccountIds.size}
|
||||
>
|
||||
<Account id={featuredAccountId} />
|
||||
</Article>
|
||||
))}
|
||||
</ItemList>
|
||||
</>
|
||||
)}
|
||||
<RemoteHint accountId={accountId} />
|
||||
</div>
|
||||
</Scrollable>
|
||||
</Column>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import type { AccountFieldShape } from '@/mastodon/models/account';
|
||||
import { isServerFeatureEnabled } from '@/mastodon/utils/environment';
|
||||
|
||||
export function isRedesignEnabled() {
|
||||
return isServerFeatureEnabled('profile_redesign');
|
||||
}
|
||||
|
||||
export interface AccountField extends AccountFieldShape {
|
||||
nameHasEmojis: boolean;
|
||||
value_plain: string;
|
||||
valueHasEmojis: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -210,18 +210,14 @@ export const AccountHeader: React.FC<{
|
|||
<AccountNote accountId={accountId} />
|
||||
))}
|
||||
|
||||
{(!isRedesign || layout === 'single-column') && (
|
||||
<>
|
||||
<AccountBio
|
||||
accountId={accountId}
|
||||
className={classNames(
|
||||
'account__header__content',
|
||||
isRedesign && redesignClasses.bio,
|
||||
)}
|
||||
/>
|
||||
<AccountHeaderFields accountId={accountId} />
|
||||
</>
|
||||
)}
|
||||
<AccountBio
|
||||
accountId={accountId}
|
||||
className={classNames(
|
||||
'account__header__content',
|
||||
isRedesign && redesignClasses.bio,
|
||||
)}
|
||||
/>
|
||||
<AccountHeaderFields accountId={accountId} />
|
||||
</div>
|
||||
|
||||
<AccountNumberFields accountId={accountId} />
|
||||
|
|
|
|||
|
|
@ -1,25 +1,31 @@
|
|||
import { useCallback, useMemo, useState } from 'react';
|
||||
import type { FC, Key } from 'react';
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import { defineMessage, FormattedMessage, useIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import htmlConfig from '@/config/html-tags.json';
|
||||
import IconVerified from '@/images/icons/icon_verified.svg?react';
|
||||
import { openModal } from '@/mastodon/actions/modal';
|
||||
import { AccountFields } from '@/mastodon/components/account_fields';
|
||||
import { CustomEmojiProvider } from '@/mastodon/components/emoji/context';
|
||||
import type { EmojiHTMLProps } from '@/mastodon/components/emoji/html';
|
||||
import { EmojiHTML } from '@/mastodon/components/emoji/html';
|
||||
import { FormattedDateWrapper } from '@/mastodon/components/formatted_date';
|
||||
import { Icon } from '@/mastodon/components/icon';
|
||||
import { IconButton } from '@/mastodon/components/icon_button';
|
||||
import { MiniCard } from '@/mastodon/components/mini_card';
|
||||
import { useElementHandledLink } from '@/mastodon/components/status/handled_link';
|
||||
import { useAccount } from '@/mastodon/hooks/useAccount';
|
||||
import type { Account, AccountFieldShape } from '@/mastodon/models/account';
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
import { useResizeObserver } from '@/mastodon/hooks/useObserver';
|
||||
import type { Account } from '@/mastodon/models/account';
|
||||
import { useAppDispatch } from '@/mastodon/store';
|
||||
import MoreIcon from '@/material-icons/400-24px/more_horiz.svg?react';
|
||||
|
||||
import { cleanExtraEmojis } from '../../emoji/normalize';
|
||||
import type { AccountField } from '../common';
|
||||
import { isRedesignEnabled } from '../common';
|
||||
import { useFieldHtml } from '../hooks/useFieldHtml';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
|
|
@ -74,172 +80,309 @@ const RedesignAccountHeaderFields: FC<{ account: Account }> = ({ account }) => {
|
|||
() => cleanExtraEmojis(account.emojis),
|
||||
[account.emojis],
|
||||
);
|
||||
const textHasCustomEmoji = useCallback(
|
||||
(text?: string | null) => {
|
||||
if (!emojis || !text) {
|
||||
return false;
|
||||
}
|
||||
for (const emoji of Object.keys(emojis)) {
|
||||
if (text.includes(`:${emoji}:`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
[emojis],
|
||||
);
|
||||
const fields: AccountField[] = useMemo(() => {
|
||||
const fields = account.fields.toJS();
|
||||
if (!emojis) {
|
||||
return fields.map((field) => ({
|
||||
...field,
|
||||
nameHasEmojis: false,
|
||||
value_plain: field.value_plain ?? '',
|
||||
valueHasEmojis: false,
|
||||
}));
|
||||
}
|
||||
|
||||
const shortcodes = Object.keys(emojis);
|
||||
return fields.map((field) => ({
|
||||
...field,
|
||||
nameHasEmojis: shortcodes.some((code) =>
|
||||
field.name.includes(`:${code}:`),
|
||||
),
|
||||
value_plain: field.value_plain ?? '',
|
||||
valueHasEmojis: shortcodes.some((code) =>
|
||||
field.value_plain?.includes(`:${code}:`),
|
||||
),
|
||||
}));
|
||||
}, [account.fields, emojis]);
|
||||
|
||||
const htmlHandlers = useElementHandledLink({
|
||||
hashtagAccountId: account.id,
|
||||
});
|
||||
|
||||
if (account.fields.isEmpty()) {
|
||||
const { wrapperRef } = useColumnWrap();
|
||||
|
||||
if (fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomEmojiProvider emojis={emojis}>
|
||||
<dl className={classes.fieldList}>
|
||||
{account.fields.map((field, key) => (
|
||||
<FieldRow
|
||||
key={key}
|
||||
{...field.toJSON()}
|
||||
htmlHandlers={htmlHandlers}
|
||||
textHasCustomEmoji={textHasCustomEmoji}
|
||||
/>
|
||||
<dl className={classes.fieldList} ref={wrapperRef}>
|
||||
{fields.map((field, key) => (
|
||||
<FieldCard key={key} field={field} htmlHandlers={htmlHandlers} />
|
||||
))}
|
||||
</dl>
|
||||
</CustomEmojiProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const FieldRow: FC<
|
||||
{
|
||||
textHasCustomEmoji: (text?: string | null) => boolean;
|
||||
htmlHandlers: ReturnType<typeof useElementHandledLink>;
|
||||
} & AccountFieldShape
|
||||
> = ({
|
||||
textHasCustomEmoji,
|
||||
htmlHandlers,
|
||||
name,
|
||||
name_emojified,
|
||||
value_emojified,
|
||||
value_plain,
|
||||
verified_at,
|
||||
}) => {
|
||||
const FieldCard: FC<{
|
||||
htmlHandlers: ReturnType<typeof useElementHandledLink>;
|
||||
field: AccountField;
|
||||
}> = ({ htmlHandlers, field }) => {
|
||||
const intl = useIntl();
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const handleClick = useCallback(() => {
|
||||
setShowAll((prev) => !prev);
|
||||
}, []);
|
||||
const {
|
||||
name,
|
||||
name_emojified,
|
||||
nameHasEmojis,
|
||||
value_emojified,
|
||||
value_plain,
|
||||
valueHasEmojis,
|
||||
verified_at,
|
||||
} = field;
|
||||
|
||||
const { wrapperRef, isLabelOverflowing, isValueOverflowing } =
|
||||
useFieldOverflow();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const handleOverflowClick = useCallback(() => {
|
||||
dispatch(
|
||||
openModal({
|
||||
modalType: 'ACCOUNT_FIELD_OVERFLOW',
|
||||
modalProps: { field },
|
||||
}),
|
||||
);
|
||||
}, [dispatch, field]);
|
||||
|
||||
return (
|
||||
/* eslint-disable -- This method of showing field contents is not very accessible, but it's what we've got for now */
|
||||
<div
|
||||
<MiniCard
|
||||
className={classNames(
|
||||
classes.fieldRow,
|
||||
classes.fieldItem,
|
||||
verified_at && classes.fieldVerified,
|
||||
showAll && classes.fieldShowAll,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
/* eslint-enable */
|
||||
>
|
||||
<FieldHTML
|
||||
as='dt'
|
||||
text={name}
|
||||
textEmojified={name_emojified}
|
||||
textHasCustomEmoji={textHasCustomEmoji(name)}
|
||||
titleLength={50}
|
||||
className='translate'
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
<dd>
|
||||
label={
|
||||
<FieldHTML
|
||||
as='span'
|
||||
text={value_plain ?? ''}
|
||||
textEmojified={value_emojified}
|
||||
textHasCustomEmoji={textHasCustomEmoji(value_plain ?? '')}
|
||||
titleLength={120}
|
||||
text={name}
|
||||
textEmojified={name_emojified}
|
||||
textHasCustomEmoji={nameHasEmojis}
|
||||
className='translate'
|
||||
isOverflowing={isLabelOverflowing}
|
||||
onOverflowClick={handleOverflowClick}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
|
||||
{verified_at && (
|
||||
<Icon
|
||||
id='verified'
|
||||
icon={IconVerified}
|
||||
className={classes.fieldVerifiedIcon}
|
||||
aria-label={intl.formatMessage(verifyMessage, {
|
||||
date: intl.formatDate(verified_at, dateFormatOptions),
|
||||
})}
|
||||
noFill
|
||||
/>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
}
|
||||
value={
|
||||
<FieldHTML
|
||||
text={value_plain}
|
||||
textEmojified={value_emojified}
|
||||
textHasCustomEmoji={valueHasEmojis}
|
||||
isOverflowing={isValueOverflowing}
|
||||
onOverflowClick={handleOverflowClick}
|
||||
{...htmlHandlers}
|
||||
/>
|
||||
}
|
||||
ref={wrapperRef}
|
||||
>
|
||||
{verified_at && (
|
||||
<span
|
||||
className={classes.fieldVerifiedIcon}
|
||||
title={intl.formatMessage(verifyMessage, {
|
||||
date: intl.formatDate(verified_at, dateFormatOptions),
|
||||
})}
|
||||
>
|
||||
<Icon id='verified' icon={IconVerified} noFill />
|
||||
</span>
|
||||
)}
|
||||
</MiniCard>
|
||||
);
|
||||
};
|
||||
|
||||
const FieldHTML: FC<
|
||||
{
|
||||
as?: 'span' | 'dt';
|
||||
text: string;
|
||||
textEmojified: string;
|
||||
textHasCustomEmoji: boolean;
|
||||
titleLength: number;
|
||||
} & Omit<EmojiHTMLProps, 'htmlString'>
|
||||
> = ({
|
||||
as,
|
||||
type FieldHTMLProps = {
|
||||
text: string;
|
||||
textEmojified: string;
|
||||
textHasCustomEmoji: boolean;
|
||||
isOverflowing?: boolean;
|
||||
onOverflowClick?: () => void;
|
||||
} & Omit<EmojiHTMLProps, 'htmlString'>;
|
||||
|
||||
const FieldHTML: FC<FieldHTMLProps> = ({
|
||||
className,
|
||||
extraEmojis,
|
||||
text,
|
||||
textEmojified,
|
||||
textHasCustomEmoji,
|
||||
titleLength,
|
||||
isOverflowing,
|
||||
onOverflowClick,
|
||||
onElement,
|
||||
...props
|
||||
}) => {
|
||||
const handleElement: OnElementHandler = useCallback(
|
||||
(element, props, children, extra) => {
|
||||
if (element instanceof HTMLAnchorElement) {
|
||||
// Don't allow custom emoji and links in the same field to prevent verification spoofing.
|
||||
if (textHasCustomEmoji) {
|
||||
return (
|
||||
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return onElement?.(element, props, children, extra);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[onElement, textHasCustomEmoji],
|
||||
);
|
||||
const intl = useIntl();
|
||||
const handleElement = useFieldHtml(textHasCustomEmoji, onElement);
|
||||
|
||||
return (
|
||||
const html = (
|
||||
<EmojiHTML
|
||||
as={as}
|
||||
as='span'
|
||||
htmlString={textEmojified}
|
||||
title={showTitleOnLength(text, titleLength)}
|
||||
className={className}
|
||||
onElement={handleElement}
|
||||
data-contents
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
if (!isOverflowing) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{html}
|
||||
<IconButton
|
||||
icon='ellipsis'
|
||||
iconComponent={MoreIcon}
|
||||
title={intl.formatMessage({
|
||||
id: 'account.field_overflow',
|
||||
defaultMessage: 'Show full content',
|
||||
})}
|
||||
className={classes.fieldOverflowButton}
|
||||
onClick={onOverflowClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
function filterAttributesForSpan(props: Record<string, unknown>) {
|
||||
const validAttributes: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(props)) {
|
||||
if (key in htmlConfig.tags.span.attributes) {
|
||||
validAttributes[key] = props[key];
|
||||
function useColumnWrap() {
|
||||
const listRef = useRef<HTMLDListElement | null>(null);
|
||||
|
||||
const handleRecalculate = useCallback(() => {
|
||||
const listEle = listRef.current;
|
||||
if (!listEle) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
return validAttributes;
|
||||
|
||||
// Calculate dimensions from styles and element size to determine column spans.
|
||||
const styles = getComputedStyle(listEle);
|
||||
const gap = parseFloat(styles.columnGap || styles.gap || '0');
|
||||
const columnCount = parseInt(styles.getPropertyValue('--cols')) || 2;
|
||||
const listWidth = listEle.offsetWidth;
|
||||
const colWidth = (listWidth - gap * (columnCount - 1)) / columnCount;
|
||||
|
||||
// Matrix to hold the grid layout.
|
||||
const itemGrid: { ele: HTMLElement; span: number }[][] = [];
|
||||
|
||||
// First, determine the column span for each item and populate the grid matrix.
|
||||
let currentRow = 0;
|
||||
for (const child of listEle.children) {
|
||||
if (!(child instanceof HTMLElement)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// This uses a data attribute to detect which elements to measure that overflow.
|
||||
const contents = child.querySelectorAll('[data-contents]');
|
||||
|
||||
const childStyles = getComputedStyle(child);
|
||||
const padding =
|
||||
parseFloat(childStyles.paddingLeft) +
|
||||
parseFloat(childStyles.paddingRight);
|
||||
|
||||
const contentWidth =
|
||||
Math.max(
|
||||
...Array.from(contents).map((content) => content.scrollWidth),
|
||||
) + padding;
|
||||
|
||||
const contentSpan = Math.ceil(contentWidth / colWidth);
|
||||
const maxColSpan = Math.min(contentSpan, columnCount);
|
||||
|
||||
const curRow = itemGrid[currentRow] ?? [];
|
||||
const availableCols =
|
||||
columnCount - curRow.reduce((carry, curr) => carry + curr.span, 0);
|
||||
// Move to next row if current item doesn't fit.
|
||||
if (maxColSpan > availableCols) {
|
||||
currentRow++;
|
||||
}
|
||||
|
||||
itemGrid[currentRow] = (itemGrid[currentRow] ?? []).concat({
|
||||
ele: child,
|
||||
span: maxColSpan,
|
||||
});
|
||||
}
|
||||
|
||||
// Next, iterate through the grid matrix and set the column spans and row breaks.
|
||||
for (const row of itemGrid) {
|
||||
let remainingRowSpan = columnCount;
|
||||
for (let i = 0; i < row.length; i++) {
|
||||
const item = row[i];
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
const { ele, span } = item;
|
||||
if (i < row.length - 1) {
|
||||
ele.dataset.cols = span.toString();
|
||||
remainingRowSpan -= span;
|
||||
} else {
|
||||
// Last item in the row takes up remaining space to fill the row.
|
||||
ele.dataset.cols = remainingRowSpan.toString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const observer = useResizeObserver(handleRecalculate);
|
||||
|
||||
const wrapperRefCallback = useCallback(
|
||||
(element: HTMLDListElement | null) => {
|
||||
if (element) {
|
||||
listRef.current = element;
|
||||
observer.observe(element);
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
);
|
||||
|
||||
return { wrapperRef: wrapperRefCallback };
|
||||
}
|
||||
|
||||
function showTitleOnLength(value: string | null, maxLength: number) {
|
||||
if (value && value.length > maxLength) {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
function useFieldOverflow() {
|
||||
const [isLabelOverflowing, setIsLabelOverflowing] = useState(false);
|
||||
const [isValueOverflowing, setIsValueOverflowing] = useState(false);
|
||||
|
||||
const wrapperRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleRecalculate = useCallback(() => {
|
||||
const wrapperEle = wrapperRef.current;
|
||||
if (!wrapperEle) return;
|
||||
|
||||
const wrapperStyles = getComputedStyle(wrapperEle);
|
||||
const maxWidth =
|
||||
wrapperEle.offsetWidth -
|
||||
(parseFloat(wrapperStyles.paddingLeft) +
|
||||
parseFloat(wrapperStyles.paddingRight));
|
||||
|
||||
const label = wrapperEle.querySelector<HTMLSpanElement>(
|
||||
'dt > [data-contents]',
|
||||
);
|
||||
const value = wrapperEle.querySelector<HTMLSpanElement>(
|
||||
'dd > [data-contents]',
|
||||
);
|
||||
|
||||
setIsLabelOverflowing(label ? label.scrollWidth > maxWidth : false);
|
||||
setIsValueOverflowing(value ? value.scrollWidth > maxWidth : false);
|
||||
}, []);
|
||||
|
||||
const observer = useResizeObserver(handleRecalculate);
|
||||
|
||||
const wrapperRefCallback = useCallback(
|
||||
(element: HTMLElement | null) => {
|
||||
if (element) {
|
||||
wrapperRef.current = element;
|
||||
observer.observe(element);
|
||||
}
|
||||
},
|
||||
[observer],
|
||||
);
|
||||
|
||||
return {
|
||||
isLabelOverflowing,
|
||||
isValueOverflowing,
|
||||
wrapperRef: wrapperRefCallback,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,64 +214,101 @@ svg.badgeIcon {
|
|||
}
|
||||
|
||||
.fieldList {
|
||||
--cols: 4;
|
||||
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
column-gap: 12px;
|
||||
grid-template-columns: repeat(var(--cols), 1fr);
|
||||
gap: 4px;
|
||||
margin: 16px 0;
|
||||
border-top: 0.5px solid var(--color-border-primary);
|
||||
|
||||
@container (width < 420px) {
|
||||
grid-template-columns: 100px 1fr;
|
||||
--cols: 2;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
align-items: start;
|
||||
grid-template-columns: subgrid;
|
||||
padding: 8px;
|
||||
border-bottom: 0.5px solid var(--color-border-primary);
|
||||
.fieldItem {
|
||||
--col-span: 1;
|
||||
|
||||
> :is(dt, dd) {
|
||||
&:not(.fieldShowAll) {
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
grid-column: span var(--col-span);
|
||||
position: relative;
|
||||
|
||||
@for $col from 2 through 4 {
|
||||
&[data-cols='#{$col}'] {
|
||||
--col-span: #{$col};
|
||||
}
|
||||
}
|
||||
|
||||
> dt {
|
||||
color: var(--color-text-secondary);
|
||||
dt {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
> dd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
dd {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
:is(dt, dd) {
|
||||
text-overflow: initial;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
text-decoration: underline;
|
||||
// Override the MiniCard link styles
|
||||
a {
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// See: https://stackoverflow.com/questions/13226296/is-scrollwidth-property-of-a-span-not-working-on-chrome
|
||||
[data-contents] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldVerified {
|
||||
background-color: var(--color-bg-success-softer);
|
||||
|
||||
dt {
|
||||
padding-right: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldVerifiedIcon {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
|
||||
> svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldOverflowButton {
|
||||
--default-bg-color: var(--color-bg-secondary-solid);
|
||||
--hover-bg-color: color-mix(
|
||||
in oklab,
|
||||
var(--color-bg-brand-base),
|
||||
var(--default-bg-color) var(--overlay-strength-brand)
|
||||
);
|
||||
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
padding: 0 2px;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
border: 2px solid var(--color-bg-primary);
|
||||
|
||||
> svg {
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.fieldNumbersWrapper {
|
||||
|
|
|
|||
|
|
@ -5,23 +5,15 @@ import { FormattedMessage } from 'react-intl';
|
|||
import type { NavLinkProps } from 'react-router-dom';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
import { useLayout } from '@/mastodon/hooks/useLayout';
|
||||
|
||||
import { isRedesignEnabled } from '../common';
|
||||
|
||||
import classes from './redesign.module.scss';
|
||||
|
||||
export const AccountTabs: FC<{ acct: string }> = ({ acct }) => {
|
||||
const { layout } = useLayout();
|
||||
if (isRedesignEnabled()) {
|
||||
return (
|
||||
<div className={classes.tabs}>
|
||||
{layout !== 'single-column' && (
|
||||
<NavLink exact to={`/@${acct}/about`}>
|
||||
<FormattedMessage id='account.about' defaultMessage='About' />
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink isActive={isActive} to={`/@${acct}/posts`}>
|
||||
<NavLink isActive={isActive} to={`/@${acct}`}>
|
||||
<FormattedMessage id='account.activity' defaultMessage='Activity' />
|
||||
</NavLink>
|
||||
<NavLink exact to={`/@${acct}/media`}>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import type { Key } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import htmlConfig from '@/config/html-tags.json';
|
||||
import type { OnElementHandler } from '@/mastodon/utils/html';
|
||||
|
||||
export function useFieldHtml(
|
||||
hasCustomEmoji: boolean,
|
||||
onElement?: OnElementHandler,
|
||||
): OnElementHandler {
|
||||
return useCallback(
|
||||
(element, props, children, extra) => {
|
||||
if (element instanceof HTMLAnchorElement) {
|
||||
// Don't allow custom emoji and links in the same field to prevent verification spoofing.
|
||||
if (hasCustomEmoji) {
|
||||
return (
|
||||
<span {...filterAttributesForSpan(props)} key={props.key as Key}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return onElement?.(element, props, children, extra);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
[onElement, hasCustomEmoji],
|
||||
);
|
||||
}
|
||||
|
||||
function filterAttributesForSpan(props: Record<string, unknown>) {
|
||||
const validAttributes: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(props)) {
|
||||
if (key in htmlConfig.tags.span.attributes) {
|
||||
validAttributes[key] = props[key];
|
||||
}
|
||||
}
|
||||
return validAttributes;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user