Merge branch 'main' into improve-devcontainer-config

This commit is contained in:
Liberal Dev 2026-03-06 18:02:08 +09:00 committed by GitHub
commit f492b97739
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
364 changed files with 10598 additions and 3572 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
.nvmrc
View File

@ -1 +1 @@
24.13
24.14

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -78,8 +78,6 @@ module Admin
'report'
elsif params[:remove_from_report]
'remove_from_report'
elsif params[:delete]
'delete'
end
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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'>[];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,8 @@
.counter {
margin-top: 4px;
font-size: 13px;
}
.counterError {
color: var(--color-text-error);
}

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -3,7 +3,7 @@
}
.input {
padding-right: 45px;
padding-inline-end: 45px;
}
.menuButton {

View File

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

View File

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

View File

@ -0,0 +1,14 @@
.wrapper {
position: relative;
}
.input {
padding-inline-end: 45px;
}
.copyButton {
position: absolute;
inset-inline-end: 0;
top: 0;
padding: 9px;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +0,0 @@
.wrapper {
padding: 16px;
}
.bio {
color: var(--color-text-primary);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. Heres 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>&lt;a&gt;</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 youve already added your website as a custom field, youll need to delete and re-add it to trigger verification.'
tagName='p'
/>
</li>
</ol>
</DialogModal>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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