Merge remote-tracking branch 'parent/main' into kbtopic-remove-quote

This commit is contained in:
KMY 2025-05-27 10:51:58 +09:00
commit 7c65b6f9df
464 changed files with 8217 additions and 8135 deletions

View file

@ -9,6 +9,7 @@ services:
environment:
RAILS_ENV: development
NODE_ENV: development
VITE_RUBY_HOST: 0.0.0.0
BIND: 0.0.0.0
BOOTSNAP_CACHE_DIR: /tmp
REDIS_HOST: redis
@ -22,11 +23,12 @@ services:
ES_PORT: '9200'
LIBRE_TRANSLATE_ENDPOINT: http://libretranslate:5000
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-localhost:3000}
VITE_DEV_SERVER_PUBLIC: ${VITE_DEV_SERVER_PUBLIC:-localhost:3036}
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
ports:
- '3000:3000'
- '3035:3035'
- '3036:3036'
- '4000:4000'
networks:
- external_network

View file

@ -43,7 +43,6 @@ ES_PASS=password
# Make sure to use `bundle exec rails secret` to generate secrets
# -------
SECRET_KEY_BASE=
OTP_SECRET=
# Encryption secrets
# ------------------

View file

@ -25,26 +25,12 @@
'tesseract.js', // Requires code changes
'react-hotkeys', // Requires code changes
// Requires Webpacker upgrade or replacement
'@svgr/webpack',
'@types/webpack',
'babel-loader',
'compression-webpack-plugin',
'css-loader',
'imports-loader',
'mini-css-extract-plugin',
'postcss-loader',
'sass-loader',
'terser-webpack-plugin',
'webpack',
'webpack-assets-manifest',
'webpack-bundle-analyzer',
'webpack-dev-server',
'webpack-cli',
// react-router: Requires manual upgrade
'history',
'react-router-dom',
// react-spring: Requires manual upgrade when upgrading react
'@react-spring/web',
],
matchUpdateTypes: ['major'],
dependencyDashboardApproval: true,

View file

@ -52,7 +52,7 @@ jobs:
public/assets
public/packs
public/packs-test
tmp/cache/webpacker
tmp/cache/vite
key: ${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
restore-keys: |
${{ matrix.mode }}-assets-${{ github.head_ref || github.ref_name }}-${{ github.sha }}
@ -66,7 +66,7 @@ jobs:
- name: Archive asset artifacts
run: |
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs*
tar --exclude={"*.br","*.gz"} -zcf artifacts.tar.gz public/assets public/packs* tmp/cache/vite/last-build*.json
- uses: actions/upload-artifact@v4
if: matrix.mode == 'test'
@ -147,7 +147,7 @@ jobs:
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg imagemagick libpam-dev
additional-system-dependencies: ffmpeg libpam-dev
- name: Load database schema
run: |
@ -177,8 +177,8 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
test-libvips:
name: Libvips tests
test-imagemagick:
name: ImageMagick tests
runs-on: ubuntu-latest
needs:
@ -224,7 +224,7 @@ jobs:
CAS_ENABLED: true
BUNDLE_WITH: 'pam_authentication test'
GITHUB_RSPEC: ${{ matrix.ruby-version == '.ruby-version' && github.event.pull_request && 'true' }}
MASTODON_USE_LIBVIPS: true
MASTODON_USE_LIBVIPS: false
strategy:
fail-fast: false
@ -249,7 +249,7 @@ jobs:
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg libpam-dev
additional-system-dependencies: ffmpeg imagemagick libpam-dev
- name: Load database schema
run: './bin/rails db:create db:schema:load db:seed'
@ -329,7 +329,7 @@ jobs:
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg imagemagick
additional-system-dependencies: ffmpeg
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
@ -448,7 +448,7 @@ jobs:
uses: ./.github/actions/setup-ruby
with:
ruby-version: ${{ matrix.ruby-version}}
additional-system-dependencies: ffmpeg imagemagick
additional-system-dependencies: ffmpeg
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript

3
.gitignore vendored
View file

@ -21,10 +21,11 @@
/public/system
/public/assets
/public/packs
/public/packs-dev
/public/packs-test
.env
.env.production
/node_modules/
node_modules/
/build/
# Ignore elasticsearch config

2
.nvmrc
View file

@ -1 +1 @@
22.15
22.16

View file

@ -18,10 +18,6 @@
!/log/.keep
/tmp
/coverage
/public/system
/public/assets
/public/packs
/public/packs-test
.env
.env.production
.env.development
@ -60,6 +56,7 @@ docker-compose.override.yml
/public/packs
/public/packs-test
/public/system
/public/vite*
# Ignore emoji map file
/app/javascript/mastodon/features/emoji/emoji_map.json

View file

@ -74,13 +74,6 @@ Style/OptionalBooleanParameter:
- 'app/workers/domain_block_worker.rb'
- 'app/workers/unfollow_follow_worker.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle.
# SupportedStyles: short, verbose
Style/PreferredHashMethods:
Exclude:
- 'config/initializers/paperclip.rb'
# This cop supports safe autocorrection (--autocorrect).
Style/RedundantConstantBase:
Exclude:

View file

@ -1 +1 @@
3.4.3
3.4.4

View file

@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io"
# Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"]
# renovate: datasource=docker depName=docker.io/ruby
ARG RUBY_VERSION="3.4.3"
ARG RUBY_VERSION="3.4.4"
# # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"]
# renovate: datasource=node-version depName=node
ARG NODE_MAJOR_VERSION="22"

11
Gemfile
View file

@ -52,6 +52,7 @@ gem 'faraday-httpclient'
gem 'fast_blank', '~> 1.0'
gem 'fastimage'
gem 'hiredis', '~> 0.6'
gem 'hiredis-client'
gem 'htmlentities', '~> 4.3'
gem 'http', '~> 5.2.0'
gem 'http_accept_language', '~> 2.1'
@ -62,7 +63,7 @@ gem 'inline_svg'
gem 'irb', '~> 1.8'
gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0'
gem 'linzer', '~> 0.6.1'
gem 'linzer', '~> 0.7.2'
gem 'mario-redis-lock', '~> 1.2', require: 'redis_lock'
gem 'mime-types', '~> 3.7.0', require: 'mime/types/columnar'
gem 'mutex_m'
@ -78,15 +79,14 @@ gem 'rack-cors', '~> 2.0', require: 'rack/cors'
gem 'rails-i18n', '~> 8.0'
gem 'redcarpet', '~> 3.6'
gem 'redis', '~> 4.5', require: ['redis', 'redis/connection/hiredis']
gem 'redis-namespace', '~> 1.10'
gem 'rqrcode', '~> 3.0'
gem 'ruby-progressbar', '~> 1.13'
gem 'sanitize', '~> 7.0'
gem 'scenic', '~> 1.7'
gem 'sidekiq', '~> 6.5'
gem 'sidekiq', '< 8'
gem 'sidekiq-bulk', '~> 0.2.0'
gem 'sidekiq-scheduler', '~> 5.0'
gem 'sidekiq-unique-jobs', '~> 7.1'
gem 'sidekiq-unique-jobs', '> 8'
gem 'simple_form', '~> 5.2'
gem 'simple-navigation', '~> 4.4'
gem 'stoplight', '~> 4.1'
@ -95,7 +95,6 @@ gem 'tty-prompt', '~> 0.23', require: false
gem 'twitter-text', '~> 3.1.0'
gem 'tzinfo-data', '~> 1.2023'
gem 'webauthn', '~> 3.0'
gem 'webpacker', '~> 5.4'
gem 'webpush', github: 'mastodon/webpush', ref: '9631ac63045cfabddacc69fc06e919b4c13eb913'
gem 'json-ld'
@ -230,3 +229,5 @@ gem 'rubyzip', '~> 2.3'
gem 'hcaptcha', '~> 7.1'
gem 'mail', '~> 2.8'
gem 'vite_rails', '~> 3.0.19'

View file

@ -94,7 +94,7 @@ GEM
ast (2.4.3)
attr_required (1.0.2)
aws-eventstream (1.3.2)
aws-partitions (1.1087.0)
aws-partitions (1.1103.0)
aws-sdk-core (3.215.1)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
@ -109,7 +109,7 @@ GEM
aws-sigv4 (~> 1.5)
aws-sigv4 (1.11.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-blob (0.5.7)
azure-blob (0.5.8)
rexml
base64 (0.2.0)
bcp47_spec (0.2.1)
@ -124,14 +124,11 @@ GEM
binding_of_caller (1.0.1)
debug_inspector (>= 1.2.0)
blurhash (0.1.8)
bootsnap (1.18.4)
bootsnap (1.18.6)
msgpack (~> 1.2)
brakeman (7.0.2)
racc
browser (6.2.0)
brpoplpush-redis_script (0.1.3)
concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, < 6)
builder (3.3.0)
bundler-audit (0.9.2)
bundler (>= 1.2.0, < 3)
@ -148,6 +145,7 @@ GEM
case_transform (0.2)
activesupport
cbor (0.5.9.8)
cgi (0.4.2)
charlock_holmes (0.7.9)
chewy (7.6.0)
activesupport (>= 5.2)
@ -171,7 +169,7 @@ GEM
css_parser (1.21.1)
addressable
csv (3.3.4)
database_cleaner-active_record (2.2.0)
database_cleaner-active_record (2.2.1)
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
@ -194,7 +192,7 @@ GEM
devise_pam_authenticatable2 (9.2.0)
devise (>= 4.0.0)
rpam2 (~> 4.0)
diff-lcs (1.6.1)
diff-lcs (1.6.2)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
docile (1.4.1)
@ -203,6 +201,7 @@ GEM
railties (>= 5)
dotenv (3.1.8)
drb (2.2.1)
dry-cli (1.2.0)
elasticsearch (7.17.11)
elasticsearch-api (= 7.17.11)
elasticsearch-transport (= 7.17.11)
@ -227,7 +226,7 @@ GEM
fabrication (2.31.0)
faker (3.5.1)
i18n (>= 1.8.11, < 2)
faraday (2.13.0)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@ -261,15 +260,16 @@ GEM
fog-core (~> 2.1)
fog-json (>= 1.0)
formatador (1.1.0)
forwardable (1.3.3)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
globalid (1.2.1)
activesupport (>= 6.1)
google-protobuf (4.30.2)
google-protobuf (4.31.0)
bigdecimal
rake (>= 13)
googleapis-common-protos-types (1.19.0)
googleapis-common-protos-types (1.20.0)
google-protobuf (>= 3.18, < 5.a)
haml (6.3.0)
temple (>= 0.8.2)
@ -293,6 +293,8 @@ GEM
highline (3.1.2)
reline
hiredis (0.6.3)
hiredis-client (0.24.0)
redis-client (= 0.24.0)
hkdf (0.3.0)
htmlentities (4.3.4)
http (5.2.0)
@ -337,7 +339,7 @@ GEM
azure-blob (~> 0.5.2)
hashie (~> 5.0)
jmespath (1.6.2)
json (2.10.2)
json (2.12.0)
json-canonicalization (1.0.0)
json-jwt (1.16.7)
activesupport (>= 4.2)
@ -381,7 +383,7 @@ GEM
marcel (~> 1.0.1)
mime-types
terrapin (>= 0.6.0, < 2.0)
language_server-protocol (3.17.0.4)
language_server-protocol (3.17.0.5)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
@ -395,7 +397,11 @@ GEM
rexml
link_header (0.0.8)
lint_roller (1.1.0)
linzer (0.6.5)
linzer (0.7.2)
cgi (~> 0.4.2)
forwardable (~> 1.3, >= 1.3.3)
logger (~> 1.7, >= 1.7.0)
net-http (~> 0.6.0)
openssl (~> 3.0, >= 3.0.0)
rack (>= 2.2, < 4.0)
starry (~> 0.2)
@ -410,7 +416,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.24.0)
loofah (2.24.1)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@ -426,9 +432,9 @@ GEM
mime-types (3.7.0)
logger
mime-types-data (~> 3.2025, >= 3.2025.0507)
mime-types-data (3.2025.0507)
mime-types-data (3.2025.0514)
mini_mime (1.1.5)
mini_portile2 (2.8.8)
mini_portile2 (2.8.9)
minitest (5.25.5)
msgpack (1.8.0)
multi_json (1.15.0)
@ -503,7 +509,7 @@ GEM
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.7)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-action_pack (0.12.0)
opentelemetry-instrumentation-action_pack (0.12.1)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.23.0)
opentelemetry-instrumentation-rack (~> 0.21)
@ -617,7 +623,7 @@ GEM
activesupport (>= 7.0.0)
rack
railties (>= 7.0.0)
psych (5.2.3)
psych (5.2.6)
date
stringio
public_suffix (6.0.2)
@ -627,7 +633,7 @@ GEM
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.1)
rack (2.2.14)
rack (2.2.16)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-cors (2.0.2)
@ -695,8 +701,8 @@ GEM
psych (>= 4.0.0)
redcarpet (3.6.1)
redis (4.8.1)
redis-namespace (1.11.0)
redis (>= 4)
redis-client (0.24.0)
connection_pool
redlock (1.3.2)
redis (>= 3.0.0, < 6.0)
regexp_parser (2.10.0)
@ -709,7 +715,7 @@ GEM
railties (>= 5.2)
rexml (3.4.1)
rotp (6.3.0)
rouge (4.5.1)
rouge (4.5.2)
rpam2 (4.0.2)
rqrcode (3.1.0)
chunky_png (~> 1.0)
@ -726,7 +732,7 @@ GEM
rspec-support (~> 3.13.0)
rspec-github (3.0.0)
rspec-core (~> 3.0)
rspec-mocks (3.13.3)
rspec-mocks (3.13.4)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (8.0.0)
@ -743,7 +749,7 @@ GEM
rspec-mocks (~> 3.0)
sidekiq (>= 5, < 9)
rspec-support (3.13.3)
rubocop (1.75.5)
rubocop (1.75.7)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
@ -767,12 +773,12 @@ GEM
lint_roller (~> 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-rails (2.31.0)
rubocop-rails (2.32.0)
activesupport (>= 4.2.0)
lint_roller (~> 1.1)
rack (>= 1.1)
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.38.0, < 2.0)
rubocop-ast (>= 1.44.0, < 2.0)
rubocop-rspec (3.6.0)
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
@ -780,7 +786,8 @@ GEM
lint_roller (~> 1.1)
rubocop (~> 1.72, >= 1.72.1)
rubocop-rspec (~> 3.5)
ruby-prof (1.7.1)
ruby-prof (1.7.2)
base64
ruby-progressbar (1.13.0)
ruby-saml (1.18.0)
nokogiri (>= 1.13.10)
@ -800,31 +807,30 @@ GEM
activerecord (>= 4.0.0)
railties (>= 4.0.0)
securerandom (0.4.1)
selenium-webdriver (4.32.0)
selenium-webdriver (4.33.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
semantic_range (3.1.0)
shoulda-matchers (6.5.0)
activesupport (>= 5.2.0)
sidekiq (6.5.12)
connection_pool (>= 2.2.5, < 3)
rack (~> 2.0)
redis (>= 4.5.0, < 5)
sidekiq (7.3.9)
base64
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
sidekiq-bulk (0.2.0)
sidekiq
sidekiq-scheduler (5.0.6)
rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8)
tilt (>= 1.4.0, < 3)
sidekiq-unique-jobs (7.1.33)
brpoplpush-redis_script (> 0.1.1, <= 2.0.0)
sidekiq-unique-jobs (8.0.10)
concurrent-ruby (~> 1.0, >= 1.0.5)
redis (< 5.0)
sidekiq (>= 5.0, < 7.0)
thor (>= 0.20, < 3.0)
sidekiq (>= 7.0.0, < 8.0.0)
thor (>= 1.0, < 3.0)
simple-navigation (4.4.0)
activesupport (>= 2.3.2)
simple_form (5.3.1)
@ -892,6 +898,15 @@ GEM
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
vite_rails (3.0.19)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
vite_ruby (3.9.2)
dry-cli (>= 0.7, < 2)
logger (~> 1.6)
mutex_m
rack-proxy (~> 0.6, >= 0.6.1)
zeitwerk (~> 2.2)
warden (1.2.9)
rack (>= 2.0.9)
webauthn (3.4.0)
@ -910,11 +925,6 @@ GEM
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webpacker (5.4.4)
activesupport (>= 5.2)
rack-proxy (>= 0.6.1)
railties (>= 5.2)
semantic_range (>= 2.3.0)
webrick (1.9.1)
websocket (1.2.11)
websocket-driver (0.7.7)
@ -973,6 +983,7 @@ DEPENDENCIES
haml_lint
hcaptcha (~> 7.1)
hiredis (~> 0.6)
hiredis-client
htmlentities (~> 4.3)
http (~> 5.2.0)
http_accept_language (~> 2.1)
@ -991,7 +1002,7 @@ DEPENDENCIES
letter_opener (~> 1.8)
letter_opener_web (~> 3.0)
link_header (~> 0.0)
linzer (~> 0.6.1)
linzer (~> 0.7.2)
lograge (~> 0.12)
mail (~> 2.8)
mario-redis-lock (~> 1.2)
@ -1042,7 +1053,6 @@ DEPENDENCIES
rdf-normalize (~> 0.5)
redcarpet (~> 3.6)
redis (~> 4.5)
redis-namespace (~> 1.10)
rqrcode (~> 3.0)
rspec-github (~> 3.0)
rspec-rails (~> 8.0)
@ -1062,10 +1072,10 @@ DEPENDENCIES
scenic (~> 1.7)
selenium-webdriver
shoulda-matchers
sidekiq (~> 6.5)
sidekiq (< 8)
sidekiq-bulk (~> 0.2.0)
sidekiq-scheduler (~> 5.0)
sidekiq-unique-jobs (~> 7.1)
sidekiq-unique-jobs (> 8)
simple-navigation (~> 4.4)
simple_form (~> 5.2)
simplecov (~> 0.22)
@ -1078,9 +1088,9 @@ DEPENDENCIES
tty-prompt (~> 0.23)
twitter-text (~> 3.1.0)
tzinfo-data (~> 1.2023)
vite_rails (~> 3.0.19)
webauthn (~> 3.0)
webmock (~> 3.18)
webpacker (~> 5.4)
webpush!
xorcist (~> 1.1)
@ -1088,4 +1098,4 @@ RUBY VERSION
ruby 3.4.1p0
BUNDLED WITH
2.6.8
2.6.9

View file

@ -1,4 +1,4 @@
web: env PORT=3000 RAILS_ENV=development bundle exec puma -C config/puma.rb
sidekiq: env PORT=3000 RAILS_ENV=development bundle exec sidekiq
stream: env PORT=4000 yarn workspace @mastodon/streaming start
webpack: bin/webpack-dev-server
vite: yarn dev

View file

@ -17,10 +17,6 @@
"description": "The secret key base",
"generator": "secret"
},
"OTP_SECRET": {
"description": "One-time password secret",
"generator": "secret"
},
"SINGLE_USER_MODE": {
"description": "Should the instance run in single user mode? (Disable registrations, redirect to front page)",
"value": "false",

View file

@ -2,13 +2,17 @@
module Admin
class RulesController < BaseController
before_action :set_rule, except: [:index, :create]
before_action :set_rule, except: [:index, :new, :create]
def index
authorize :rule, :index?
@rules = Rule.ordered
@rule = Rule.new
@rules = Rule.ordered.includes(:translations)
end
def new
authorize :rule, :create?
@rule = Rule.new
end
def edit
@ -23,8 +27,7 @@ module Admin
if @rule.save
redirect_to admin_rules_path
else
@rules = Rule.ordered
render :index
render :new
end
end
@ -46,6 +49,22 @@ module Admin
redirect_to admin_rules_path
end
def move_up
authorize @rule, :update?
@rule.move!(-1)
redirect_to admin_rules_path
end
def move_down
authorize @rule, :update?
@rule.move!(+1)
redirect_to admin_rules_path
end
private
def set_rule
@ -54,7 +73,7 @@ module Admin
def resource_params
params
.expect(rule: [:text, :hint, :priority])
.expect(rule: [:text, :hint, :priority, translations_attributes: [[:id, :language, :text, :hint, :_destroy]]])
end
end
end

View file

@ -42,37 +42,22 @@ class Api::Fasp::BaseController < ApplicationController
end
def validate_signature!
signature_input = request.headers['signature-input']&.encode('UTF-8')
raise Error, 'signature-input is missing' if signature_input.blank?
raise Error, 'signature-input is missing' if request.headers['signature-input'].blank?
provider = nil
Linzer.verify!(request.rack_request, no_older_than: 5.minutes) do |keyid|
provider = Fasp::Provider.find(keyid)
Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid)
end
keyid = signature_input.match(KEYID_PATTERN)[1]
provider = Fasp::Provider.find(keyid)
linzer_request = Linzer.new_request(
request.method,
request.original_url,
{},
{
'content-digest' => request.headers['content-digest'],
'signature-input' => signature_input,
'signature' => request.headers['signature'],
}
)
message = Linzer::Message.new(linzer_request)
key = Linzer.new_ed25519_public_key(provider.provider_public_key_pem, keyid)
signature = Linzer::Signature.build(message.headers)
Linzer.verify(key, message, signature)
@current_provider = provider
end
def sign_response
response.headers['content-digest'] = "sha-256=:#{OpenSSL::Digest.base64digest('sha256', response.body || '')}:"
linzer_response = Linzer.new_response(response.body, response.status, { 'content-digest' => response.headers['content-digest'] })
message = Linzer::Message.new(linzer_response)
key = Linzer.new_ed25519_key(current_provider.server_private_key_pem)
signature = Linzer.sign(key, message, %w(@status content-digest))
response.headers.merge!(signature.to_h)
Linzer.sign!(response, key:, components: %w(@status content-digest))
end
def check_fasp_enabled

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class Api::Fasp::DataSharing::V0::BackfillRequestsController < Api::Fasp::BaseController
def create
backfill_request = current_provider.fasp_backfill_requests.new(backfill_request_params)
respond_to do |format|
format.json do
if backfill_request.save
render json: { backfillRequest: { id: backfill_request.id } }, status: 201
else
head 422
end
end
end
end
private
def backfill_request_params
params
.permit(:category, :maxCount)
.to_unsafe_h
.transform_keys { |k| k.to_s.underscore }
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
class Api::Fasp::DataSharing::V0::ContinuationsController < Api::Fasp::BaseController
def create
backfill_request = current_provider.fasp_backfill_requests.find(params[:backfill_request_id])
Fasp::BackfillWorker.perform_async(backfill_request.id)
head 204
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
class Api::Fasp::DataSharing::V0::EventSubscriptionsController < Api::Fasp::BaseController
def create
subscription = current_provider.fasp_subscriptions.create!(subscription_params)
render json: { subscription: { id: subscription.id } }, status: 201
end
def destroy
subscription = current_provider.fasp_subscriptions.find(params[:id])
subscription.destroy
head 204
end
private
def subscription_params
params
.permit(:category, :subscriptionType, :maxBatchSize, threshold: {})
.to_unsafe_h
.transform_keys { |k| k.to_s.underscore }
end
end

View file

@ -18,6 +18,6 @@ class Api::V1::Instances::RulesController < Api::V1::Instances::BaseController
private
def set_rules
@rules = Rule.ordered
@rules = Rule.ordered.includes(:translations)
end
end

View file

@ -126,7 +126,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
end
def set_rules
@rules = Rule.ordered
@rules = Rule.ordered.includes(:translations)
end
def require_rules_acceptance!

View file

@ -197,9 +197,7 @@ class Auth::SessionsController < Devise::SessionsController
)
# Only send a notification email every hour at most
return if redis.get("2fa_failure_notification:#{user.id}").present?
redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour)
return if redis.set("2fa_failure_notification:#{user.id}", '1', ex: 1.hour, get: true).present?
UserMailer.failed_2fa(user, request.remote_ip, request.user_agent, Time.now.utc).deliver_later!
end

View file

@ -8,6 +8,7 @@ module WebAppControllerConcern
before_action :redirect_unauthenticated_to_permalinks!
before_action :set_referer_header
before_action :redirect_to_tos_interstitial!
content_security_policy do |p|
policy = ContentSecurityPolicy.new
@ -45,6 +46,13 @@ module WebAppControllerConcern
protected
def redirect_to_tos_interstitial!
return unless current_user&.require_tos_interstitial?
@terms_of_service = TermsOfService.published.first
render 'terms_of_service_interstitial/show', layout: 'auth'
end
def set_referer_header
response.set_header('Referrer-Policy', Setting.allow_referrer_origin ? 'strict-origin-when-cross-origin' : 'same-origin')
end

View file

@ -4,8 +4,19 @@ class TermsOfServiceController < ApplicationController
include WebAppControllerConcern
skip_before_action :require_functional!
skip_before_action :redirect_to_tos_interstitial!
before_action :clear_redirect_interstitial!
def show
expires_in(15.seconds, public: true, stale_while_revalidate: 30.seconds, stale_if_error: 1.day) unless user_signed_in?
end
private
def clear_redirect_interstitial!
return unless user_signed_in?
current_user.update(require_tos_interstitial: false)
end
end

View file

@ -4,7 +4,7 @@ module RoutingHelper
extend ActiveSupport::Concern
include ActionView::Helpers::AssetTagHelper
include Webpacker::Helper
include ViteRails::TagHelpers
included do
include Rails.application.routes.url_helpers
@ -25,7 +25,7 @@ module RoutingHelper
end
def frontend_asset_path(source, **)
asset_pack_path("media/#{source}", **)
vite_asset_path(source, **)
end
def frontend_asset_url(source, **)

View file

@ -4,11 +4,13 @@ module ThemeHelper
def theme_style_tags(theme)
if theme == 'system'
''.html_safe.tap do |tags|
tags << stylesheet_pack_tag('mastodon-light', media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
tags << stylesheet_pack_tag('default', media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
tags << vite_stylesheet_tag('styles/mastodon-light.scss', media: 'not all and (prefers-color-scheme: dark)', crossorigin: 'anonymous')
tags << vite_stylesheet_tag('styles/application.scss', media: '(prefers-color-scheme: dark)', crossorigin: 'anonymous')
end
elsif theme == 'default'
vite_stylesheet_tag 'styles/application.scss', media: 'all', crossorigin: 'anonymous'
else
stylesheet_pack_tag theme, media: 'all', crossorigin: 'anonymous'
vite_stylesheet_tag "styles/#{theme}.scss", media: 'all', crossorigin: 'anonymous'
end
end

View file

@ -1,4 +1,3 @@
import './public-path';
import { createRoot } from 'react-dom/client';
import Rails from '@rails/ujs';
@ -345,7 +344,7 @@ async function mountReactComponent(element: Element) {
);
const { default: Component } = (await import(
`@/mastodon/components/admin/${componentName}`
`@/mastodon/components/admin/${componentName}.jsx`
)) as { default: React.ComponentType };
const root = createRoot(element);

View file

@ -1,11 +1,6 @@
import './public-path';
import { loadLocale } from 'mastodon/locales';
import main from 'mastodon/main';
import { start } from '../mastodon/common';
import { loadLocale } from '../mastodon/locales';
import { loadPolyfills } from '../mastodon/polyfills';
start();
import { loadPolyfills } from 'mastodon/polyfills';
loadPolyfills()
.then(loadLocale)

View file

@ -0,0 +1,3 @@
import { start } from 'mastodon/common';
start();

View file

@ -1,15 +1,11 @@
import './public-path';
import { createRoot } from 'react-dom/client';
import { afterInitialRender } from 'mastodon/hooks/useRenderSignal';
import { start } from '../mastodon/common';
import { Status } from '../mastodon/features/standalone/status';
import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready';
start();
function loaded() {
const mountNode = document.getElementById('mastodon-status');

View file

@ -1,4 +1,3 @@
import './public-path';
import ready from '../mastodon/ready';
ready(() => {

View file

@ -1,4 +0,0 @@
/* Placeholder file to have `inert.scss` compiled by Webpack
This is used by the `wicg-inert` polyfill */
import '../styles/inert.scss';

View file

@ -1,3 +0,0 @@
import '../styles/mailer.scss';
require.context('../icons');

View file

@ -1,23 +0,0 @@
// Dynamically set webpack's loading path depending on a meta header, in order
// to share the same assets regardless of instance configuration.
// See https://webpack.js.org/guides/public-path/#on-the-fly
function removeOuterSlashes(string: string) {
return string.replace(/^\/*/, '').replace(/\/*$/, '');
}
function formatPublicPath(host = '', path = '') {
let formattedHost = removeOuterSlashes(host);
if (formattedHost && !/^http/i.test(formattedHost)) {
formattedHost = `//${formattedHost}`;
}
const formattedPath = removeOuterSlashes(path);
return `${formattedHost}/${formattedPath}/`;
}
const cdnHost = document.querySelector<HTMLMetaElement>('meta[name=cdn-host]');
__webpack_public_path__ = formatPublicPath(
cdnHost ? cdnHost.content : '',
process.env.PUBLIC_OUTPUT_PATH,
);

View file

@ -1,7 +1,5 @@
import { createRoot } from 'react-dom/client';
import './public-path';
import { IntlMessageFormat } from 'intl-messageformat';
import type { MessageDescriptor, PrimitiveType } from 'react-intl';
import { defineMessages } from 'react-intl';
@ -10,7 +8,6 @@ import Rails from '@rails/ujs';
import axios from 'axios';
import { throttle } from 'lodash';
import { start } from '../mastodon/common';
import { timeAgoString } from '../mastodon/components/relative_timestamp';
import emojify from '../mastodon/features/emoji/emoji';
import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions';
@ -20,8 +17,6 @@ import ready from '../mastodon/ready';
import 'cocoon-js-vanilla';
start();
const messages = defineMessages({
usernameTaken: {
id: 'username.taken',
@ -153,9 +148,7 @@ function loaded() {
const reactComponents = document.querySelectorAll('[data-component]');
if (reactComponents.length > 0) {
import(
/* webpackChunkName: "containers/media_container" */ '../mastodon/containers/media_container'
)
import('../mastodon/containers/media_container')
.then(({ default: MediaContainer }) => {
reactComponents.forEach((component) => {
Array.from(component.children).forEach((child) => {

View file

@ -8,8 +8,6 @@ and performs no other task.
*/
import './public-path';
import axios from 'axios';
interface JRDLink {

View file

@ -1,13 +1,9 @@
import './public-path';
import { createRoot } from 'react-dom/client';
import { start } from '../mastodon/common';
import ComposeContainer from '../mastodon/containers/compose_container';
import { loadPolyfills } from '../mastodon/polyfills';
import ready from '../mastodon/ready';
start();
function loaded() {
const mountNode = document.getElementById('mastodon-compose');

View file

@ -1,4 +1,3 @@
import './public-path';
import axios from 'axios';
import ready from '../mastodon/ready';

View file

@ -0,0 +1,22 @@
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { apiGetFamiliarFollowers } from '../api/accounts';
import { importFetchedAccounts } from './importer';
export const fetchAccountsFamiliarFollowers = createDataLoadingThunk(
'accounts_familiar_followers/fetch',
({ id }: { id: string }) => apiGetFamiliarFollowers(id),
([data], { dispatch }) => {
if (!data) {
return null;
}
dispatch(importFetchedAccounts(data.accounts));
return {
id: data.id,
accountIds: data.accounts.map((account) => account.id),
};
},
);

View file

@ -69,6 +69,10 @@ export function importFetchedStatuses(statuses) {
processStatus(status.reblog);
}
if (status.quote?.quoted_status) {
processStatus(status.quote.quoted_status);
}
if (status.poll?.id) {
pushUnique(polls, createPollFromServerJSON(status.poll, getState().polls[status.poll.id]));
}

View file

@ -23,12 +23,20 @@ export function normalizeFilterResult(result) {
export function normalizeStatus(status, normalOldStatus) {
const normalStatus = { ...status };
normalStatus.account = status.account.id;
if (status.reblog && status.reblog.id) {
normalStatus.reblog = status.reblog.id;
}
if (status.quote?.quoted_status ?? status.quote?.quoted_status_id) {
normalStatus.quote = {
...status.quote,
quoted_status: status.quote.quoted_status?.id ?? status.quote?.quoted_status_id,
};
}
if (status.poll && status.poll.id) {
normalStatus.poll = status.poll.id;
}

View file

@ -83,6 +83,7 @@ export default function api(withAuthorization = true) {
return instance;
}
type ApiUrl = `v${1 | 2}/${string}`;
type RequestParamsOrData = Record<string, unknown>;
export async function apiRequest<ApiResponse = unknown>(
@ -105,28 +106,28 @@ export async function apiRequest<ApiResponse = unknown>(
}
export async function apiRequestGet<ApiResponse = unknown>(
url: string,
url: ApiUrl,
params?: RequestParamsOrData,
) {
return apiRequest<ApiResponse>('GET', url, { params });
}
export async function apiRequestPost<ApiResponse = unknown>(
url: string,
url: ApiUrl,
data?: RequestParamsOrData,
) {
return apiRequest<ApiResponse>('POST', url, { data });
}
export async function apiRequestPut<ApiResponse = unknown>(
url: string,
url: ApiUrl,
data?: RequestParamsOrData,
) {
return apiRequest<ApiResponse>('PUT', url, { data });
}
export async function apiRequestDelete<ApiResponse = unknown>(
url: string,
url: ApiUrl,
params?: RequestParamsOrData,
) {
return apiRequest<ApiResponse>('DELETE', url, { params });

View file

@ -1,5 +1,8 @@
import { apiRequestPost, apiRequestGet } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
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';
@ -31,3 +34,8 @@ export const apiGetFeaturedTags = (id: string) =>
export const apiGetEndorsedAccounts = (id: string) =>
apiRequestGet<ApiAccountJSON>(`v1/accounts/${id}/endorsements`);
export const apiGetFamiliarFollowers = (id: string) =>
apiRequestGet<ApiFamiliarFollowersJSON>('v1/accounts/familiar_followers', {
id,
});

View file

@ -2,9 +2,9 @@ import { apiRequestGet, apiRequestPost } from 'mastodon/api';
import type { ApiPollJSON } from 'mastodon/api_types/polls';
export const apiGetPoll = (pollId: string) =>
apiRequestGet<ApiPollJSON>(`/v1/polls/${pollId}`);
apiRequestGet<ApiPollJSON>(`v1/polls/${pollId}`);
export const apiPollVote = (pollId: string, choices: string[]) =>
apiRequestPost<ApiPollJSON>(`/v1/polls/${pollId}/votes`, {
apiRequestPost<ApiPollJSON>(`v1/polls/${pollId}/votes`, {
choices,
});

View file

@ -81,3 +81,9 @@ export interface ApiMutedAccountJSON extends BaseApiAccountJSON {
// For now, we have the same type representing both `Account` and `MutedAccount`
// objects, but we should refactor this in the future.
export type ApiAccountJSON = ApiMutedAccountJSON;
// See app/serializers/rest/familiar_followers_serializer.rb
export type ApiFamiliarFollowersJSON = {
id: string;
accounts: ApiAccountJSON[];
}[];

View file

@ -1,8 +1,6 @@
import Rails from '@rails/ujs';
export function start() {
require.context('../images/', true, /\.(jpg|png|svg)$/);
try {
Rails.start();
} catch {

View file

@ -1,11 +1,13 @@
import { fromJS } from 'immutable';
import renderer from 'react-test-renderer';
import { accountDefaultValues, createAccountFromServerJSON } from '@/mastodon/models/account';
import { Avatar } from '../avatar';
describe('<Avatar />', () => {
const account = fromJS({
const account = createAccountFromServerJSON({
...accountDefaultValues,
username: 'alice',
acct: 'alice',
display_name: 'Alice',

View file

@ -13,9 +13,12 @@ import {
unblockAccount,
muteAccount,
unmuteAccount,
followAccountSuccess,
} from 'mastodon/actions/accounts';
import { showAlertForError } from 'mastodon/actions/alerts';
import { openModal } from 'mastodon/actions/modal';
import { initMuteModal } from 'mastodon/actions/mutes';
import { apiFollowAccount } from 'mastodon/api/accounts';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { FollowersCounter } from 'mastodon/components/counters';
@ -26,6 +29,8 @@ import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import { ShortNumber } from 'mastodon/components/short_number';
import { Skeleton } from 'mastodon/components/skeleton';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { useIdentity } from 'mastodon/identity_context';
import { me } from 'mastodon/initial_state';
import type { MenuItem } from 'mastodon/models/dropdown_menu';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -79,10 +84,12 @@ export const Account: React.FC<{
children,
}) => {
const intl = useIntl();
const { signedIn } = useIdentity();
const account = useAppSelector((state) => state.accounts.get(id));
const relationship = useAppSelector((state) => state.relationships.get(id));
const dispatch = useAppDispatch();
const accountUrl = account?.url;
const isRemote = account?.acct !== account?.username;
const handleBlock = useCallback(() => {
if (relationship?.blocking) {
@ -125,37 +132,74 @@ export const Account: React.FC<{
},
];
} else if (defaultAction !== 'block') {
const handleAddToLists = () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: id,
},
}),
);
};
arr = [];
arr = [
{
if (isRemote && accountUrl) {
arr.push({
text: intl.formatMessage(messages.openOriginalPage),
href: accountUrl,
});
}
if (signedIn) {
const handleAddToLists = () => {
const openAddToListModal = () => {
dispatch(
openModal({
modalType: 'LIST_ADDER',
modalProps: {
accountId: id,
},
}),
);
};
if (relationship?.following || relationship?.requested || id === me) {
openAddToListModal();
} else {
dispatch(
openModal({
modalType: 'CONFIRM_FOLLOW_TO_LIST',
modalProps: {
accountId: id,
onConfirm: () => {
apiFollowAccount(id)
.then((relationship) => {
dispatch(
followAccountSuccess({
relationship,
alreadyFollowing: false,
}),
);
openAddToListModal();
})
.catch((err: unknown) => {
dispatch(showAlertForError(err));
});
},
},
}),
);
}
};
arr.push({
text: intl.formatMessage(messages.addToLists),
action: handleAddToLists,
},
];
if (accountUrl) {
arr.unshift(
{
text: intl.formatMessage(messages.openOriginalPage),
href: accountUrl,
},
null,
);
});
}
}
return arr;
}, [dispatch, intl, id, accountUrl, relationship, defaultAction]);
}, [
dispatch,
intl,
id,
accountUrl,
relationship,
defaultAction,
isRemote,
signedIn,
]);
if (hidden) {
return (

View file

@ -1,17 +1,21 @@
import { useState, useCallback } from 'react';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { useHovering } from 'mastodon/hooks/useHovering';
import { autoPlayGif } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
interface Props {
account: Account | undefined; // FIXME: remove `undefined` once we know for sure its always there
size: number;
account:
| Pick<Account, 'id' | 'acct' | 'avatar' | 'avatar_static'>
| undefined; // FIXME: remove `undefined` once we know for sure its always there
size?: number;
style?: React.CSSProperties;
inline?: boolean;
animate?: boolean;
withLink?: boolean;
counter?: number | string;
counterBorderColor?: string;
}
@ -21,6 +25,7 @@ export const Avatar: React.FC<Props> = ({
animate = autoPlayGif,
size = 20,
inline = false,
withLink = false,
style: styleFromParent,
counter,
counterBorderColor,
@ -35,10 +40,7 @@ export const Avatar: React.FC<Props> = ({
height: `${size}px`,
};
const src =
hovering || animate
? account?.get('avatar')
: account?.get('avatar_static');
const src = hovering || animate ? account?.avatar : account?.avatar_static;
const handleLoad = useCallback(() => {
setLoading(false);
@ -48,7 +50,7 @@ export const Avatar: React.FC<Props> = ({
setError(true);
}, [setError]);
return (
const avatar = (
<div
className={classNames('account__avatar', {
'account__avatar--inline': inline,
@ -72,4 +74,18 @@ export const Avatar: React.FC<Props> = ({
)}
</div>
);
if (withLink) {
return (
<Link
to={`/@${account?.acct}`}
title={`@${account?.acct}`}
data-hover-card-account={account?.id}
>
{avatar}
</Link>
);
}
return avatar;
};

View file

@ -0,0 +1,23 @@
import classNames from 'classnames';
/**
* Wrapper for displaying a number of Avatar components horizontally,
* either spaced out (default) or overlapping (using the `compact` prop).
*/
export const AvatarGroup: React.FC<{
compact?: boolean;
avatarHeight?: number;
children: React.ReactNode;
}> = ({ children, compact = false, avatarHeight }) => (
<div
className={classNames('avatar-group', { 'avatar-group--compact': compact })}
style={
avatarHeight
? ({ '--avatar-height': `${avatarHeight}px` } as React.CSSProperties)
: undefined
}
>
{children}
</div>
);

View file

@ -43,3 +43,17 @@ export const FollowersCounter = (
}}
/>
);
export const FollowersYouKnowCounter = (
displayNumber: React.ReactNode,
pluralReady: number,
) => (
<FormattedMessage
id='account.followers_you_know_counter'
defaultMessage='{counter} you know'
values={{
count: pluralReady,
counter: <strong>{displayNumber}</strong>,
}}
/>
);

View file

@ -0,0 +1,211 @@
import type { ComponentPropsWithRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, FormattedMessage, useIntl } from 'react-intl';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import type { AnimatedProps } from '@react-spring/web';
import { animated, useSpring } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import { expandAccountFeaturedTimeline } from '@/mastodon/actions/timelines';
import { IconButton } from '@/mastodon/components/icon_button';
import StatusContainer from '@/mastodon/containers/status_container';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
const messages = defineMessages({
previous: { id: 'featured_carousel.previous', defaultMessage: 'Previous' },
next: { id: 'featured_carousel.next', defaultMessage: 'Next' },
slide: {
id: 'featured_carousel.slide',
defaultMessage: '{index} of {total}',
},
});
export const FeaturedCarousel: React.FC<{
accountId: string;
tagged?: string;
}> = ({ accountId, tagged }) => {
const intl = useIntl();
// Load pinned statuses
const dispatch = useAppDispatch();
useEffect(() => {
if (accountId) {
void dispatch(expandAccountFeaturedTimeline(accountId, { tagged }));
}
}, [accountId, dispatch, tagged]);
const pinnedStatuses = useAppSelector(
(state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
);
// Handle slide change
const [slideIndex, setSlideIndex] = useState(0);
const wrapperRef = useRef<HTMLDivElement>(null);
const handleSlideChange = useCallback(
(direction: number) => {
setSlideIndex((prev) => {
const max = pinnedStatuses.size - 1;
let newIndex = prev + direction;
if (newIndex < 0) {
newIndex = max;
} else if (newIndex > max) {
newIndex = 0;
}
const slide = wrapperRef.current?.children[newIndex];
if (slide) {
setCurrentSlideHeight(slide.scrollHeight);
}
return newIndex;
});
},
[pinnedStatuses.size],
);
// Handle slide heights
const [currentSlideHeight, setCurrentSlideHeight] = useState(
wrapperRef.current?.scrollHeight ?? 0,
);
const observerRef = useRef<ResizeObserver>(
new ResizeObserver(() => {
handleSlideChange(0);
}),
);
const wrapperStyles = useSpring({
x: `-${slideIndex * 100}%`,
height: currentSlideHeight,
});
useEffect(() => {
// Update slide height when the component mounts
if (currentSlideHeight === 0) {
handleSlideChange(0);
}
}, [currentSlideHeight, handleSlideChange]);
// Handle swiping animations
const bind = useDrag(({ swipe: [swipeX] }) => {
handleSlideChange(swipeX * -1); // Invert swipe as swiping left loads the next slide.
});
const handlePrev = useCallback(() => {
handleSlideChange(-1);
}, [handleSlideChange]);
const handleNext = useCallback(() => {
handleSlideChange(1);
}, [handleSlideChange]);
if (!accountId || pinnedStatuses.isEmpty()) {
return null;
}
return (
<div
className='featured-carousel'
{...bind()}
aria-roledescription='carousel'
aria-labelledby='featured-carousel-title'
role='region'
>
<div className='featured-carousel__header'>
<h4 className='featured-carousel__title' id='featured-carousel-title'>
<FormattedMessage
id='featured_carousel.header'
defaultMessage='{count, plural, one {Pinned Post} other {Pinned Posts}}'
values={{ count: pinnedStatuses.size }}
/>
</h4>
{pinnedStatuses.size > 1 && (
<>
<IconButton
title={intl.formatMessage(messages.previous)}
icon='chevron-left'
iconComponent={ChevronLeftIcon}
onClick={handlePrev}
/>
<span aria-live='polite'>
<FormattedMessage
id='featured_carousel.post'
defaultMessage='Post'
>
{(text) => <span className='sr-only'>{text}</span>}
</FormattedMessage>
{slideIndex + 1} / {pinnedStatuses.size}
</span>
<IconButton
title={intl.formatMessage(messages.next)}
icon='chevron-right'
iconComponent={ChevronRightIcon}
onClick={handleNext}
/>
</>
)}
</div>
<animated.div
className='featured-carousel__slides'
ref={wrapperRef}
style={wrapperStyles}
aria-atomic='false'
aria-live='polite'
>
{pinnedStatuses.map((statusId, index) => (
<FeaturedCarouselItem
key={`f-${statusId}`}
data-index={index}
aria-label={intl.formatMessage(messages.slide, {
index: index + 1,
total: pinnedStatuses.size,
})}
statusId={statusId}
observer={observerRef.current}
active={index === slideIndex}
/>
))}
</animated.div>
</div>
);
};
interface FeaturedCarouselItemProps {
statusId: string;
active: boolean;
observer: ResizeObserver;
}
const FeaturedCarouselItem: React.FC<
FeaturedCarouselItemProps & AnimatedProps<ComponentPropsWithRef<'div'>>
> = ({ statusId, active, observer, ...props }) => {
const handleRef = useCallback(
(instance: HTMLDivElement | null) => {
if (instance) {
observer.observe(instance);
}
},
[observer],
);
return (
<animated.div
className='featured-carousel__slide'
// @ts-expect-error inert in not in this version of React
inert={!active ? 'true' : undefined}
aria-roledescription='slide'
role='group'
ref={handleRef}
{...props}
>
<StatusContainer
// @ts-expect-error inferred props are wrong
id={statusId}
contextType='account'
withCounters
/>
</animated.div>
);
};

View file

@ -9,11 +9,16 @@ import { fetchAccount } from 'mastodon/actions/accounts';
import { AccountBio } from 'mastodon/components/account_bio';
import { AccountFields } from 'mastodon/components/account_fields';
import { Avatar } from 'mastodon/components/avatar';
import { FollowersCounter } from 'mastodon/components/counters';
import { AvatarGroup } from 'mastodon/components/avatar_group';
import {
FollowersCounter,
FollowersYouKnowCounter,
} from 'mastodon/components/counters';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { ShortNumber } from 'mastodon/components/short_number';
import { useFetchFamiliarFollowers } from 'mastodon/features/account_timeline/hooks/familiar_followers';
import { domain } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -38,6 +43,8 @@ export const HoverCardAccount = forwardRef<
}
}, [dispatch, accountId, account]);
const { familiarFollowers } = useFetchFamiliarFollowers({ accountId });
return (
<div
ref={ref}
@ -73,12 +80,28 @@ export const HoverCardAccount = forwardRef<
)}
</div>
<div className='hover-card__number'>
<div className='hover-card__numbers'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
isHide={account.other_settings.hide_followers_count}
/>
{familiarFollowers.length > 0 && (
<>
&middot;
<div className='hover-card__familiar-followers'>
<ShortNumber
value={familiarFollowers.length}
renderer={FollowersYouKnowCounter}
/>
<AvatarGroup compact>
{familiarFollowers.slice(0, 3).map((account) => (
<Avatar key={account.id} account={account} size={22} />
))}
</AvatarGroup>
</div>
</>
)}
</div>
<FollowButton accountId={accountId} />

View file

@ -1,17 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage } from 'react-intl';
import { IconLogo } from 'mastodon/components/logo';
import { AuthorLink } from 'mastodon/features/explore/components/author_link';
export const MoreFromAuthor = ({ accountId }) => (
<div className='more-from-author'>
<IconLogo />
<FormattedMessage id='link_preview.more_from_author' defaultMessage='More from {name}' values={{ name: <AuthorLink accountId={accountId} /> }} />
</div>
);
MoreFromAuthor.propTypes = {
accountId: PropTypes.string.isRequired,
};

View file

@ -0,0 +1,21 @@
import { FormattedMessage } from 'react-intl';
import { IconLogo } from 'mastodon/components/logo';
import { AuthorLink } from 'mastodon/features/explore/components/author_link';
export const MoreFromAuthor: React.FC<{ accountId: string }> = ({
accountId,
}) => (
<FormattedMessage
id='link_preview.more_from_author'
defaultMessage='More from {name}'
values={{ name: <AuthorLink accountId={accountId} /> }}
>
{(chunks) => (
<div className='more-from-author'>
<IconLogo />
{chunks}
</div>
)}
</FormattedMessage>
);

View file

@ -5,7 +5,6 @@ import { injectIntl, defineMessages, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
@ -13,7 +12,6 @@ import { HotKeys } from 'react-hotkeys';
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import ReferenceIcon from '@/material-icons/400-24px/link.svg?react';
import PushPinIcon from '@/material-icons/400-24px/push_pin.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
import LimitedIcon from '@/material-icons/400-24px/shield.svg?react';
import TimerIcon from '@/material-icons/400-24px/timer.svg?react';
@ -91,6 +89,7 @@ class Status extends ImmutablePureComponent {
status: ImmutablePropTypes.map,
account: ImmutablePropTypes.record,
contextType: PropTypes.string,
children: PropTypes.node,
previousId: PropTypes.string,
rootId: PropTypes.string,
onClick: PropTypes.func,
@ -120,6 +119,7 @@ class Status extends ImmutablePureComponent {
onMoveUp: PropTypes.func,
onMoveDown: PropTypes.func,
showThread: PropTypes.bool,
isQuotedPost: PropTypes.bool,
getScrollPosition: PropTypes.func,
updateScrollBottom: PropTypes.func,
cacheMediaWidth: PropTypes.func,
@ -181,9 +181,8 @@ class Status extends ImmutablePureComponent {
}
};
handleMouseUp = e => {
handleHeaderClick = e => {
// Only handle clicks on the empty space above the content
if (e.target !== e.currentTarget && e.detail >= 1) {
return;
}
@ -379,7 +378,7 @@ class Status extends ImmutablePureComponent {
};
render () {
const { intl, hidden, featured, unfocusable, unread, showThread, scrollKey, pictureInPicture, previousId, rootId, skipPrepend, avatarSize = 46 } = this.props;
const { intl, hidden, featured, unfocusable, unread, showThread, isQuotedPost = false, scrollKey, pictureInPicture, previousId, nextInReplyToId, rootId, skipPrepend, avatarSize = 46, children } = this.props;
let { status, account, ...other } = this.props;
@ -543,7 +542,7 @@ class Status extends ImmutablePureComponent {
</Bundle>
);
}
} else if (status.get('card') && !this.props.muted) {
} else if (status.get('card') && !status.get('quote') && !this.props.muted) {
media = (
<Card
onOpenMedia={this.handleOpenMedia}
@ -577,17 +576,29 @@ class Status extends ImmutablePureComponent {
<div className={classNames('status__wrapper', `status__wrapper-${status.get('visibility_ex')}`, { 'status__wrapper-reply': !!status.get('in_reply_to_id'), unread, focusable: !this.props.muted })} tabIndex={this.props.muted || unfocusable ? null : 0} data-featured={featured ? 'true' : null} aria-label={textForScreenReader(intl, status, rebloggedByText)} ref={this.handleRef} data-nosnippet={status.getIn(['account', 'noindex'], true) || undefined}>
{!skipPrepend && prepend}
<div className={classNames('status', `status-${status.get('visibility_ex')}`, { 'status-reply': !!status.get('in_reply_to_id'), 'status--in-thread': !!rootId, 'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted })} data-id={status.get('id')}>
<div
className={
classNames('status', `status-${status.get('visibility_ex')}`,
{
'status-reply': !!status.get('in_reply_to_id'),
'status--in-thread': !!rootId,
'status--first-in-thread': previousId && (!connectUp || connectToRoot), muted: this.props.muted,
'status--is-quote': isQuotedPost,
'status--has-quote': !!status.get('quote'),
})
}
data-id={status.get('id')}
>
{(connectReply || connectUp || connectToRoot) && <div className={classNames('status__line', { 'status__line--full': connectReply, 'status__line--first': !status.get('in_reply_to_id') && !connectToRoot })} />}
{(!matchedFilters || expanded || isShowItem('avatar_on_filter')) && (
<div onMouseUp={this.handleMouseUp} className='status__info'>
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time'>
{withReference}
{withExpiration}
{withLimited}
<span className='status__visibility-icon'><VisibilityIcon visibility={visibilityName} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</Link>
<div onClick={this.handleHeaderClick} onAuxClick={this.handleHeaderClick} className='status__info'>
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} className='status__relative-time'>
{withReference}
{withExpiration}
{withLimited}
<span className='status__visibility-icon'><VisibilityIcon visibility={status.get('visibility_ex')} /></span>
<RelativeTimestamp timestamp={status.get('created_at')} />{status.get('edited_at') && <abbr title={intl.formatMessage(messages.edited, { date: intl.formatDate(status.get('edited_at'), { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) })}> *</abbr>}
</Link>
<Link to={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name'>
<div className='status__avatar'>
@ -614,13 +625,17 @@ class Status extends ImmutablePureComponent {
{...statusContentProps}
/>
{children}
{media}
{hashtagBar}
{emojiReactionsBar}
</>
)}
{(!matchedFilters || this.state.showDespiteFilter) && <StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />}
{!isQuotedPost && (!matchedFilters || this.state.showDespiteFilter) &&
<StatusActionBar scrollKey={scrollKey} status={status} account={account} {...other} />
}
</div>
</div>
</HotKeys>

View file

@ -9,7 +9,7 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg';
import BookmarkIcon from '@/material-icons/400-24px/bookmark-fill.svg?react';
import BookmarkBorderIcon from '@/material-icons/400-24px/bookmark.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';

View file

@ -9,7 +9,7 @@ import { TIMELINE_GAP, TIMELINE_SUGGESTIONS } from 'mastodon/actions/timelines';
import { RegenerationIndicator } from 'mastodon/components/regeneration_indicator';
import { InlineFollowSuggestions } from 'mastodon/features/home_timeline/components/inline_follow_suggestions';
import StatusContainer from '../containers/status_container';
import { StatusQuoteManager } from '../components/status_quoted';
import { LoadGap } from './load_gap';
import ScrollableList from './scrollable_list';
@ -113,7 +113,7 @@ export default class StatusList extends ImmutablePureComponent {
);
default:
return (
<StatusContainer
<StatusQuoteManager
key={statusId}
id={statusId}
onMoveUp={this.handleMoveUp}
@ -130,7 +130,7 @@ export default class StatusList extends ImmutablePureComponent {
if (scrollableContent && featuredStatusIds) {
scrollableContent = featuredStatusIds.map(statusId => (
<StatusContainer
<StatusQuoteManager
key={`f-${statusId}`}
id={statusId}
featured

View file

@ -0,0 +1,208 @@
import { useMemo } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import type { Map as ImmutableMap } from 'immutable';
import ArticleIcon from '@/material-icons/400-24px/article.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import { Icon } from 'mastodon/components/icon';
import StatusContainer from 'mastodon/containers/status_container';
import type { Status } from 'mastodon/models/status';
import type { RootState } from 'mastodon/store';
import { useAppSelector } from 'mastodon/store';
import QuoteIcon from '../../images/quote.svg?react';
import { makeGetStatus } from '../selectors';
const MAX_QUOTE_POSTS_NESTING_LEVEL = 1;
const QuoteWrapper: React.FC<{
isError?: boolean;
children: React.ReactElement;
}> = ({ isError, children }) => {
return (
<div
className={classNames('status__quote', {
'status__quote--error': isError,
})}
>
<Icon id='quote' icon={QuoteIcon} className='status__quote-icon' />
{children}
</div>
);
};
const QuoteLink: React.FC<{
status: Status;
}> = ({ status }) => {
const accountId = status.get('account') as string;
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const quoteAuthorName = account?.display_name_html;
if (!quoteAuthorName) {
return null;
}
const quoteAuthorElement = (
<span dangerouslySetInnerHTML={{ __html: quoteAuthorName }} />
);
const quoteUrl = `/@${account.get('acct')}/${status.get('id') as string}`;
return (
<Link to={quoteUrl} className='status__quote-author-button'>
<FormattedMessage
id='status.quote_post_author'
defaultMessage='Post by {name}'
values={{ name: quoteAuthorElement }}
/>
<Icon id='chevron_right' icon={ChevronRightIcon} />
<Icon id='article' icon={ArticleIcon} />
</Link>
);
};
type QuoteMap = ImmutableMap<'state' | 'quoted_status', string | null>;
type GetStatusSelector = (
state: RootState,
props: { id?: string | null; contextType?: string },
) => Status | null;
export const QuotedStatus: React.FC<{
quote: QuoteMap;
contextType?: string;
variant?: 'full' | 'link';
nestingLevel?: number;
}> = ({ quote, contextType, nestingLevel = 1, variant = 'full' }) => {
const quotedStatusId = quote.get('quoted_status');
const quoteState = quote.get('state');
const status = useAppSelector((state) =>
quotedStatusId ? state.statuses.get(quotedStatusId) : undefined,
);
let quoteError: React.ReactNode = null;
// In order to find out whether the quoted post should be completely hidden
// due to a matching filter, we run it through the selector used by `status_container`.
// If this returns null even though `status` exists, it's because it's filtered.
const getStatus = useMemo(() => makeGetStatus(), []) as GetStatusSelector;
const statusWithExtraData = useAppSelector((state) =>
getStatus(state, { id: quotedStatusId, contextType }),
);
const isFilteredAndHidden = status && statusWithExtraData === null;
if (isFilteredAndHidden) {
quoteError = (
<FormattedMessage
id='status.quote_error.filtered'
defaultMessage='Hidden due to one of your filters'
/>
);
} else if (quoteState === 'deleted') {
quoteError = (
<FormattedMessage
id='status.quote_error.removed'
defaultMessage='This post was removed by its author.'
/>
);
} else if (quoteState === 'unauthorized') {
quoteError = (
<FormattedMessage
id='status.quote_error.unauthorized'
defaultMessage='This post cannot be displayed as you are not authorized to view it.'
/>
);
} else if (quoteState === 'pending') {
quoteError = (
<FormattedMessage
id='status.quote_error.pending_approval'
defaultMessage='This post is pending approval from the original author.'
/>
);
} else if (quoteState === 'rejected' || quoteState === 'revoked') {
quoteError = (
<FormattedMessage
id='status.quote_error.rejected'
defaultMessage='This post cannot be displayed as the original author does not allow it to be quoted.'
/>
);
} else if (!status || !quotedStatusId) {
quoteError = (
<FormattedMessage
id='status.quote_error.not_found'
defaultMessage='This post cannot be displayed.'
/>
);
}
if (quoteError) {
return <QuoteWrapper isError>{quoteError}</QuoteWrapper>;
}
if (variant === 'link' && status) {
return <QuoteLink status={status} />;
}
const childQuote = status?.get('quote') as QuoteMap | undefined;
const canRenderChildQuote =
childQuote && nestingLevel <= MAX_QUOTE_POSTS_NESTING_LEVEL;
return (
<QuoteWrapper>
{/* @ts-expect-error Status is not yet typed */}
<StatusContainer
isQuotedPost
id={quotedStatusId}
contextType={contextType}
avatarSize={40}
>
{canRenderChildQuote && (
<QuotedStatus
quote={childQuote}
contextType={contextType}
variant={
nestingLevel === MAX_QUOTE_POSTS_NESTING_LEVEL ? 'link' : 'full'
}
nestingLevel={nestingLevel + 1}
/>
)}
</StatusContainer>
</QuoteWrapper>
);
};
interface StatusQuoteManagerProps {
id: string;
contextType?: string;
[key: string]: unknown;
}
/**
* This wrapper component takes a status ID and, if the associated status
* is a quote post, it renders the quote into `StatusContainer` as a child.
* It passes all other props through to `StatusContainer`.
*/
export const StatusQuoteManager = (props: StatusQuoteManagerProps) => {
const status = useAppSelector((state) => {
const status = state.statuses.get(props.id);
const reblogId = status?.get('reblog') as string | undefined;
return reblogId ? state.statuses.get(reblogId) : status;
});
const quote = status?.get('quote') as QuoteMap | undefined;
if (quote) {
return (
<StatusContainer {...props}>
<QuotedStatus quote={quote} contextType={props.contextType} />
</StatusContainer>
);
}
return <StatusContainer {...props} />;
};

View file

@ -61,6 +61,7 @@ const severityMessages = {
const mapStateToProps = state => ({
server: state.getIn(['server', 'server']),
locale: state.getIn(['meta', 'locale']),
extendedDescription: state.getIn(['server', 'extendedDescription']),
domainBlocks: state.getIn(['server', 'domainBlocks']),
});
@ -130,6 +131,7 @@ class About extends PureComponent {
static propTypes = {
server: ImmutablePropTypes.map,
locale: ImmutablePropTypes.string,
extendedDescription: ImmutablePropTypes.map,
domainBlocks: ImmutablePropTypes.contains({
isLoading: PropTypes.bool,
@ -153,7 +155,7 @@ class About extends PureComponent {
};
render () {
const { multiColumn, intl, server, extendedDescription, domainBlocks } = this.props;
const { multiColumn, intl, server, extendedDescription, domainBlocks, locale } = this.props;
const isLoading = server.get('isLoading');
const fedibirdCapabilities = server.get('fedibird_capabilities') || []; // thinking about isLoading is true
@ -218,12 +220,15 @@ class About extends PureComponent {
<p><FormattedMessage id='about.not_available' defaultMessage='This information has not been made available on this server.' /></p>
) : (
<ol className='rules-list'>
{server.get('rules').map(rule => (
<li key={rule.get('id')}>
<div className='rules-list__text'>{rule.get('text')}</div>
{rule.get('hint').length > 0 && (<div className='rules-list__hint'>{rule.get('hint')}</div>)}
</li>
))}
{server.get('rules').map(rule => {
const text = rule.getIn(['translations', locale, 'text']) || rule.getIn(['translations', locale.split('-')[0], 'text']) || rule.get('text');
const hint = rule.getIn(['translations', locale, 'hint']) || rule.getIn(['translations', locale.split('-')[0], 'hint']) || rule.get('hint');
return (
<li key={rule.get('id')}>
<div className='rules-list__text'>{text}</div>
{hint.length > 0 && (<div className='rules-list__hint'>{hint}</div>)}
</li>
)})}
</ol>
))}
</Section>

View file

@ -29,7 +29,7 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
message = (
<FormattedMessage
id='empty_column.account_featured.me'
defaultMessage='You have not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friends accounts on your profile?'
defaultMessage='You have not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?'
/>
);
} else if (suspended) {
@ -52,7 +52,7 @@ export const EmptyMessage: React.FC<EmptyMessageProps> = ({
message = (
<FormattedMessage
id='empty_column.account_featured.other'
defaultMessage='{acct} has not featured anything yet. Did you know that you can feature your posts, hashtags you use the most, and even your friends accounts on your profile?'
defaultMessage='{acct} has not featured anything yet. Did you know that you can feature your hashtags you use the most, and even your friends accounts on your profile?'
values={{ acct }}
/>
);

View file

@ -4,17 +4,14 @@ import { FormattedMessage } from 'react-intl';
import { useParams } from 'react-router';
import type { Map as ImmutableMap } from 'immutable';
import { List as ImmutableList } from 'immutable';
import { fetchEndorsedAccounts } from 'mastodon/actions/accounts';
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
import { expandAccountFeaturedTimeline } from 'mastodon/actions/timelines';
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 StatusContainer from 'mastodon/containers/status_container';
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';
@ -43,7 +40,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
useEffect(() => {
if (accountId) {
void dispatch(expandAccountFeaturedTimeline(accountId));
void dispatch(fetchFeaturedTags({ accountId }));
void dispatch(fetchEndorsedAccounts({ accountId }));
}
@ -52,10 +48,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
const isLoading = useAppSelector(
(state) =>
!accountId ||
!!(state.timelines as ImmutableMap<string, unknown>).getIn([
`account:${accountId}:pinned`,
'isLoading',
]) ||
!!state.user_lists.getIn(['featured_tags', accountId, 'isLoading']),
);
const featuredTags = useAppSelector(
@ -65,13 +57,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
ImmutableList(),
) as ImmutableList<TagMap>,
);
const featuredStatusIds = useAppSelector(
(state) =>
(state.timelines as ImmutableMap<string, unknown>).getIn(
[`account:${accountId}:pinned`, 'items'],
ImmutableList(),
) as ImmutableList<string>,
);
const featuredAccountIds = useAppSelector(
(state) =>
state.user_lists.getIn(
@ -94,11 +79,7 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
);
}
if (
featuredStatusIds.isEmpty() &&
featuredTags.isEmpty() &&
featuredAccountIds.isEmpty()
) {
if (featuredTags.isEmpty() && featuredAccountIds.isEmpty()) {
return (
<AccountFeaturedWrapper accountId={accountId}>
<EmptyMessage
@ -133,24 +114,6 @@ const AccountFeatured: React.FC<{ multiColumn: boolean }> = ({
))}
</>
)}
{!featuredStatusIds.isEmpty() && (
<>
<h4 className='column-subheading'>
<FormattedMessage
id='account.featured.posts'
defaultMessage='Posts'
/>
</h4>
{featuredStatusIds.map((statusId) => (
<StatusContainer
key={`f-${statusId}`}
// @ts-expect-error inferred props are wrong
id={statusId}
contextType='account'
/>
))}
</>
)}
{!featuredAccountIds.isEmpty() && (
<>
<h4 className='column-subheading'>

View file

@ -67,6 +67,7 @@ import {
import { getAccountHidden } from 'mastodon/selectors/accounts';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { FamiliarFollowers } from './familiar_followers';
import { MemorialNote } from './memorial_note';
import { MovedNote } from './moved_note';
@ -1007,6 +1008,10 @@ export const AccountHeader: React.FC<{
<div className='account__header__badges'>{badges}</div>
)}
{account.id !== me && signedIn && (
<FamiliarFollowers accountId={accountId} />
)}
{!(suspended || hidden) && (
<div className='account__header__extra'>
<div

View file

@ -0,0 +1,82 @@
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import { Avatar } from '@/mastodon/components/avatar';
import { AvatarGroup } from '@/mastodon/components/avatar_group';
import type { Account } from '@/mastodon/models/account';
import { useFetchFamiliarFollowers } from '../hooks/familiar_followers';
const AccountLink: React.FC<{ account?: Account }> = ({ account }) => {
if (!account) {
return null;
}
return (
<Link
to={`/@${account.acct}`}
data-hover-card-account={account.id}
dangerouslySetInnerHTML={{ __html: account.display_name_html }}
/>
);
};
const FamiliarFollowersReadout: React.FC<{ familiarFollowers: Account[] }> = ({
familiarFollowers,
}) => {
const messageData = {
name1: <AccountLink account={familiarFollowers.at(0)} />,
name2: <AccountLink account={familiarFollowers.at(1)} />,
othersCount: familiarFollowers.length - 2,
};
if (familiarFollowers.length === 1) {
return (
<FormattedMessage
id='account.familiar_followers_one'
defaultMessage='Followed by {name1}'
values={messageData}
/>
);
} else if (familiarFollowers.length === 2) {
return (
<FormattedMessage
id='account.familiar_followers_two'
defaultMessage='Followed by {name1} and {name2}'
values={messageData}
/>
);
} else {
return (
<FormattedMessage
id='account.familiar_followers_many'
defaultMessage='Followed by {name1}, {name2}, and {othersCount, plural, one {one other you know} other {# others you know}}'
values={messageData}
/>
);
}
};
export const FamiliarFollowers: React.FC<{ accountId: string }> = ({
accountId,
}) => {
const { familiarFollowers, isLoading } = useFetchFamiliarFollowers({
accountId,
});
if (isLoading || familiarFollowers.length === 0) {
return null;
}
return (
<div className='account__header__familiar-followers'>
<AvatarGroup compact>
{familiarFollowers.slice(0, 3).map((account) => (
<Avatar withLink key={account.id} account={account} size={28} />
))}
</AvatarGroup>
<FamiliarFollowersReadout familiarFollowers={familiarFollowers} />
</div>
);
};

View file

@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { fetchAccountsFamiliarFollowers } from '@/mastodon/actions/accounts_familiar_followers';
import { getAccountFamiliarFollowers } from '@/mastodon/selectors/accounts';
import { useAppDispatch, useAppSelector } from '@/mastodon/store';
import { me } from 'mastodon/initial_state';
export const useFetchFamiliarFollowers = ({
accountId,
}: {
accountId?: string;
}) => {
const dispatch = useAppDispatch();
const familiarFollowers = useAppSelector((state) =>
accountId ? getAccountFamiliarFollowers(state, accountId) : null,
);
const hasNoData = familiarFollowers === null;
useEffect(() => {
if (hasNoData && accountId && accountId !== me) {
void dispatch(fetchAccountsFamiliarFollowers({ id: accountId }));
}
}, [dispatch, accountId, hasNoData]);
return {
familiarFollowers: hasNoData ? [] : familiarFollowers,
isLoading: hasNoData,
};
};

View file

@ -23,6 +23,7 @@ import { RemoteHint } from 'mastodon/components/remote_hint';
import { AccountHeader } from './components/account_header';
import { LimitedAccountHint } from './components/limited_account_hint';
import { fetchFeaturedTags } from 'mastodon/actions/featured_tags';
import { FeaturedCarousel } from '@/mastodon/components/featured_carousel';
const emptyList = ImmutableList();
@ -171,7 +172,12 @@ class AccountTimeline extends ImmutablePureComponent {
<ColumnBackButton />
<StatusList
prepend={<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />}
prepend={
<>
<AccountHeader accountId={this.props.accountId} hideTabs={forceEmptyState} tagged={this.props.params.tagged} />
<FeaturedCarousel accountId={this.props.accountId} />
</>
}
alwaysPrepend
append={<RemoteHint accountId={accountId} />}
scrollKey='account_timeline'

View file

@ -15,10 +15,6 @@ import type { List as ImmutableList, Map as ImmutableMap } from 'immutable';
import { useSpring, animated } from '@react-spring/web';
import Textarea from 'react-textarea-autosize';
import { length } from 'stringz';
// eslint-disable-next-line import/extensions
import tesseractWorkerPath from 'tesseract.js/dist/worker.min.js';
// eslint-disable-next-line import/no-extraneous-dependencies
import tesseractCorePath from 'tesseract.js-core/tesseract-core.wasm.js';
import { showAlertForError } from 'mastodon/actions/alerts';
import { uploadThumbnail } from 'mastodon/actions/compose';
@ -350,9 +346,15 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
fetchTesseract()
.then(async ({ createWorker }) => {
const [tesseractWorkerPath, tesseractCorePath] = await Promise.all([
// eslint-disable-next-line import/extensions
import('tesseract.js/dist/worker.min.js?url'),
// eslint-disable-next-line import/no-extraneous-dependencies
import('tesseract.js-core/tesseract-core.wasm.js?url'),
]);
const worker = await createWorker('eng', 1, {
workerPath: tesseractWorkerPath as string,
corePath: tesseractCorePath as string,
workerPath: tesseractWorkerPath.default,
corePath: tesseractCorePath.default,
langPath: `${assetHost}/ocr/lang-data`,
cacheMethod: 'write',
});
@ -501,5 +503,4 @@ export const AltTextModal = forwardRef<ModalRef, Props & Partial<RestoreProps>>(
);
},
);
AltTextModal.displayName = 'AltTextModal';

View file

@ -12,14 +12,10 @@ import Overlay from 'react-overlays/Overlay';
import MoodIcon from '@/material-icons/400-20px/mood.svg?react';
import { IconButton } from 'mastodon/components/icon_button';
import emojiCompressed from 'mastodon/features/emoji/emoji_compressed';
import { assetHost } from 'mastodon/utils/config';
import { buildCustomEmojis, categoriesFromEmojis } from '../../emoji/emoji';
import { EmojiPicker as EmojiPickerAsync } from '../../ui/util/async-components';
const nimblePickerData = emojiCompressed[5];
const messages = defineMessages({
emoji: { id: 'emoji_button.label', defaultMessage: 'Insert emoji' },
emoji_search: { id: 'emoji_button.search', defaultMessage: 'Search...' },
@ -40,19 +36,11 @@ let EmojiPicker, Emoji; // load asynchronously
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
const backgroundImageFn = () => `${assetHost}/emoji/sheet_15_1.png`;
const notFoundFn = () => (
<div className='emoji-mart-no-results'>
<Emoji
data={nimblePickerData}
emoji='sleuth_or_spy'
set='twitter'
size={32}
sheetSize={32}
sheetColumns={62}
sheetRows={62}
backgroundImageFn={backgroundImageFn}
/>
<div className='emoji-mart-no-results-label'>
@ -110,12 +98,12 @@ class ModifierPickerMenu extends PureComponent {
return (
<div className='emoji-picker-dropdown__modifiers__menu' style={{ display: active ? 'block' : 'none' }} ref={this.setRef}>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={1} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={2} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={3} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={4} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={5} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={6} backgroundImageFn={backgroundImageFn} /></button>
<button type='button' onClick={this.handleClick} data-index={1}><Emoji emoji='fist' size={22} skin={1} /></button>
<button type='button' onClick={this.handleClick} data-index={2}><Emoji emoji='fist' size={22} skin={2} /></button>
<button type='button' onClick={this.handleClick} data-index={3}><Emoji emoji='fist' size={22} skin={3} /></button>
<button type='button' onClick={this.handleClick} data-index={4}><Emoji emoji='fist' size={22} skin={4} /></button>
<button type='button' onClick={this.handleClick} data-index={5}><Emoji emoji='fist' size={22} skin={5} /></button>
<button type='button' onClick={this.handleClick} data-index={6}><Emoji emoji='fist' size={22} skin={6} /></button>
</div>
);
}
@ -150,7 +138,7 @@ class ModifierPicker extends PureComponent {
return (
<div className='emoji-picker-dropdown__modifiers'>
<Emoji data={nimblePickerData} sheetColumns={62} sheetRows={62} emoji='fist' set='twitter' size={22} sheetSize={32} skin={modifier} onClick={this.handleClick} backgroundImageFn={backgroundImageFn} />
<Emoji emoji='fist' size={22} skin={modifier} onClick={this.handleClick} />
<ModifierPickerMenu active={active} onSelect={this.handleSelect} onClose={this.props.onClose} />
</div>
);
@ -286,16 +274,11 @@ class EmojiPickerMenuImpl extends PureComponent {
return (
<div className={classNames('emoji-picker-dropdown__menu', { selecting: modifierOpen })} style={style} ref={this.setRef}>
<EmojiPicker
data={nimblePickerData}
sheetColumns={62}
sheetRows={62}
perLine={8}
emojiSize={22}
sheetSize={32}
custom={buildCustomEmojis(custom_emojis)}
color=''
emoji=''
set='twitter'
title={title}
i18n={this.getI18n()}
onClick={this.handleClick}
@ -304,7 +287,6 @@ class EmojiPickerMenuImpl extends PureComponent {
skin={skinTone}
showPreview={false}
showSkinTones={false}
backgroundImageFn={backgroundImageFn}
notFound={notFoundFn}
autoFocus={this.state.readyToFocus}
emojiTooltip

View file

@ -45,7 +45,6 @@ type EmojiCompressed = [
Category[],
Data['aliases'],
EmojisWithoutShortCodes,
Data,
];
/*

View file

@ -9,28 +9,27 @@
// to ensure that the prevaled file is regenerated by Babel
// version: 4
const { NimbleEmojiIndex } = require('emoji-mart');
const { uncompress: emojiMartUncompress } = require('emoji-mart/dist/utils/data');
import { NimbleEmojiIndex } from 'emoji-mart';
import { uncompress as emojiMartUncompress } from 'emoji-mart/dist/utils/data';
let data = require('./emoji_data.json');
const emojiMap = require('./emoji_map.json');
const { unicodeToFilename } = require('./unicode_to_filename_s');
const { unicodeToUnifiedName } = require('./unicode_to_unified_name_s');
import data from './emoji_data.json';
import emojiMap from './emoji_map.json';
import { unicodeToFilename } from './unicode_to_filename_s';
import { unicodeToUnifiedName } from './unicode_to_unified_name_s';
emojiMartUncompress(data);
const emojiMartData = data;
const emojiIndex = new NimbleEmojiIndex(emojiMartData);
const excluded = ['®', '©', '™'];
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {};
const excluded = ['®', '©', '™'];
const skinTones = ['🏻', '🏼', '🏽', '🏾', '🏿'];
const shortcodeMap = {};
const shortCodesToEmojiData = {};
const emojisWithoutShortCodes = [];
Object.keys(emojiIndex.emojis).forEach(key => {
Object.keys(emojiIndex.emojis).forEach((key) => {
let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this
@ -41,22 +40,22 @@ Object.keys(emojiIndex.emojis).forEach(key => {
shortcodeMap[emoji.native] = emoji.id;
});
const stripModifiers = unicode => {
skinTones.forEach(tone => {
const stripModifiers = (unicode) => {
skinTones.forEach((tone) => {
unicode = unicode.replace(tone, '');
});
return unicode;
};
Object.keys(emojiMap).forEach(key => {
Object.keys(emojiMap).forEach((key) => {
if (excluded.includes(key)) {
delete emojiMap[key];
return;
}
const normalizedKey = stripModifiers(key);
let shortcode = shortcodeMap[normalizedKey];
let shortcode = shortcodeMap[normalizedKey];
if (!shortcode) {
shortcode = shortcodeMap[normalizedKey + '\uFE0F'];
@ -82,7 +81,7 @@ Object.keys(emojiMap).forEach(key => {
}
});
Object.keys(emojiIndex.emojis).forEach(key => {
Object.keys(emojiIndex.emojis).forEach((key) => {
let emoji = emojiIndex.emojis[key];
// Emojis with skin tone modifiers are stored like this
@ -94,9 +93,11 @@ Object.keys(emojiIndex.emojis).forEach(key => {
let { short_names, search, unified } = emojiMartData.emojis[key];
if (short_names[0] !== key) {
throw new Error('The compressor expects the first short_code to be the ' +
'key. It may need to be rewritten if the emoji change such that this ' +
'is no longer the case.');
throw new Error(
'The compressor expects the first short_code to be the ' +
'key. It may need to be rewritten if the emoji change such that this ' +
'is no longer the case.',
);
}
short_names = short_names.slice(1); // first short name can be inferred from the key
@ -117,21 +118,22 @@ Object.keys(emojiIndex.emojis).forEach(key => {
// JSON.parse/stringify is to emulate what @preval is doing and avoid any
// inconsistent behavior in dev mode
module.exports = JSON.parse(JSON.stringify([
shortCodesToEmojiData,
/*
* The property `skins` is not found in the current context.
* This could potentially lead to issues when interacting with modules or data structures
* that expect the presence of `skins` property.
* Currently, no definitions or references to `skins` property can be found in:
* - {@link node_modules/emoji-mart/dist/utils/data.js}
* - {@link node_modules/emoji-mart/data/all.json}
* - {@link app/javascript/mastodon/features/emoji/emoji_compressed.d.ts#Skins}
* Future refactorings or updates should consider adding definitions or handling for `skins` property.
*/
emojiMartData.skins,
emojiMartData.categories,
emojiMartData.aliases,
emojisWithoutShortCodes,
emojiMartData
]));
export default JSON.parse(
JSON.stringify([
shortCodesToEmojiData,
/*
* The property `skins` is not found in the current context.
* This could potentially lead to issues when interacting with modules or data structures
* that expect the presence of `skins` property.
* Currently, no definitions or references to `skins` property can be found in:
* - {@link node_modules/emoji-mart/dist/utils/data.js}
* - {@link node_modules/emoji-mart/data/all.json}
* - {@link app/javascript/mastodon/features/emoji/emoji_compressed.d.ts#Skins}
* Future refactorings or updates should consider adding definitions or handling for `skins` property.
*/
emojiMartData.skins,
emojiMartData.categories,
emojiMartData.aliases,
emojisWithoutShortCodes,
]),
);

View file

@ -3,9 +3,13 @@
// emojiIndex.search functionality.
import type { BaseEmoji } from 'emoji-mart';
import type { Emoji } from 'emoji-mart/dist-es/utils/data';
// eslint-disable-next-line import/no-unresolved
import emojiCompressed from 'virtual:mastodon-emoji-compressed';
import type {
Search,
ShortCodesToEmojiData,
} from 'virtual:mastodon-emoji-compressed';
import type { Search, ShortCodesToEmojiData } from './emoji_compressed';
import emojiCompressed from './emoji_compressed';
import { unicodeToUnifiedName } from './unicode_to_unified_name';
type Emojis = Record<

View file

@ -1,7 +0,0 @@
import Emoji from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import Picker from 'emoji-mart/dist-es/components/picker/nimble-picker';
export {
Picker,
Emoji,
};

View file

@ -0,0 +1,53 @@
import type { EmojiProps, PickerProps } from 'emoji-mart';
import EmojiRaw from 'emoji-mart/dist-es/components/emoji/nimble-emoji';
import PickerRaw from 'emoji-mart/dist-es/components/picker/nimble-picker';
import { assetHost } from 'mastodon/utils/config';
import EmojiData from './emoji_data.json';
const backgroundImageFnDefault = () => `${assetHost}/emoji/sheet_15_1.png`;
const Emoji = ({
set = 'twitter',
sheetSize = 32,
sheetColumns = 62,
sheetRows = 62,
backgroundImageFn = backgroundImageFnDefault,
...props
}: EmojiProps) => {
return (
<EmojiRaw
data={EmojiData}
set={set}
sheetSize={sheetSize}
sheetColumns={sheetColumns}
sheetRows={sheetRows}
backgroundImageFn={backgroundImageFn}
{...props}
/>
);
};
const Picker = ({
set = 'twitter',
sheetSize = 32,
sheetColumns = 62,
sheetRows = 62,
backgroundImageFn = backgroundImageFnDefault,
...props
}: PickerProps) => {
return (
<PickerRaw
data={EmojiData}
set={set}
sheetSize={sheetSize}
sheetColumns={sheetColumns}
sheetRows={sheetRows}
backgroundImageFn={backgroundImageFn}
{...props}
/>
);
};
export { Picker, Emoji };

View file

@ -2,11 +2,13 @@
// (i.e. the svg filename) and a shortCode intended to be shown
// as a "title" attribute in an HTML element (aka tooltip).
// eslint-disable-next-line import/no-unresolved
import emojiCompressed from 'virtual:mastodon-emoji-compressed';
import type {
FilenameData,
ShortCodesToEmojiDataKey,
} from './emoji_compressed';
import emojiCompressed from './emoji_compressed';
} from 'virtual:mastodon-emoji-compressed';
import { unicodeToFilename } from './unicode_to_filename';
type UnicodeMapping = Record<

View file

@ -1,7 +1,6 @@
// taken from:
// https://github.com/twitter/twemoji/blob/47732c7/twemoji-generator.js#L848-L866
// eslint-disable-next-line import/no-commonjs, no-undef
exports.unicodeToFilename = (str) => {
export const unicodeToFilename = (str) => {
let result = '';
let charCode = 0;
let p = 0;

View file

@ -6,8 +6,7 @@ function padLeft(str, num) {
return str;
}
// eslint-disable-next-line import/no-commonjs, no-undef
exports.unicodeToUnifiedName = (str) => {
export const unicodeToUnifiedName = (str) => {
let output = '';
for (let i = 0; i < str.length; i += 2) {

View file

@ -35,8 +35,11 @@ import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
heading: { id: 'column.list_members', defaultMessage: 'Manage list members' },
export const messages = defineMessages({
manageMembers: {
id: 'column.list_members',
defaultMessage: 'Manage list members',
},
placeholder: {
id: 'lists.search',
defaultMessage: 'Search',
@ -256,10 +259,10 @@ const ListMembers: React.FC<{
return (
<Column
bindToDocument={!multiColumn}
label={intl.formatMessage(messages.heading)}
label={intl.formatMessage(messages.manageMembers)}
>
<ColumnHeader
title={intl.formatMessage(messages.heading)}
title={intl.formatMessage(messages.manageMembers)}
icon='list-ul'
iconComponent={ListAltIcon}
multiColumn={multiColumn}
@ -332,7 +335,7 @@ const ListMembers: React.FC<{
</ScrollableList>
<Helmet>
<title>{intl.formatMessage(messages.heading)}</title>
<title>{intl.formatMessage(messages.manageMembers)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>

View file

@ -9,16 +9,23 @@ import { isFulfilled } from '@reduxjs/toolkit';
import Toggle from 'react-toggle';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
import { fetchList } from 'mastodon/actions/lists';
import { createList, updateList } from 'mastodon/actions/lists_typed';
import { apiGetAccounts } from 'mastodon/api/lists';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
import type { RepliesPolicyType } from 'mastodon/api_types/lists';
import { Avatar } from 'mastodon/components/avatar';
import { AvatarGroup } from 'mastodon/components/avatar_group';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { messages as membersMessages } from './members';
const messages = defineMessages({
edit: { id: 'column.edit_list', defaultMessage: 'Edit list' },
create: { id: 'column.create_list', defaultMessage: 'Create list' },
@ -27,42 +34,40 @@ const messages = defineMessages({
const MembersLink: React.FC<{
id: string;
}> = ({ id }) => {
const [count, setCount] = useState(0);
const [avatars, setAvatars] = useState<string[]>([]);
const intl = useIntl();
const [avatarCount, setAvatarCount] = useState(0);
const [avatarAccounts, setAvatarAccounts] = useState<ApiAccountJSON[]>([]);
useEffect(() => {
void apiGetAccounts(id)
.then((data) => {
setCount(data.length);
setAvatars(data.slice(0, 3).map((a) => a.avatar));
return '';
setAvatarCount(data.length);
setAvatarAccounts(data.slice(0, 3));
})
.catch(() => {
// Nothing
});
}, [id, setCount, setAvatars]);
}, [id]);
return (
<Link to={`/lists/${id}/members`} className='app-form__link'>
<div className='app-form__link__text'>
<strong>
<FormattedMessage
id='lists.list_members'
defaultMessage='List members'
/>
{intl.formatMessage(membersMessages.manageMembers)}
<Icon id='chevron_right' icon={ChevronRightIcon} />
</strong>
<FormattedMessage
id='lists.list_members_count'
defaultMessage='{count, plural, one {# member} other {# members}}'
values={{ count }}
values={{ count: avatarCount }}
/>
</div>
<div className='avatar-pile'>
{avatars.map((url) => (
<img key={url} src={url} alt='' />
<AvatarGroup compact>
{avatarAccounts.map((a) => (
<Avatar key={a.id} account={a} size={30} />
))}
</div>
</AvatarGroup>
</Link>
);
};

View file

@ -23,7 +23,7 @@ import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
import { Account } from 'mastodon/components/account';
import EmojiView from 'mastodon/components/emoji_view';
import { Icon } from 'mastodon/components/icon';
import StatusContainer from 'mastodon/containers/status_container';
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
import { me } from 'mastodon/initial_state';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
@ -181,7 +181,7 @@ class Notification extends ImmutablePureComponent {
renderMention (notification) {
return (
<StatusContainer
<StatusQuoteManager
id={notification.get('status')}
withDismiss
hidden={this.props.hidden}
@ -211,7 +211,7 @@ class Notification extends ImmutablePureComponent {
</span>
</div>
<StatusContainer
<StatusQuoteManager
id={notification.get('status')}
account={notification.get('account')}
muted
@ -276,7 +276,7 @@ class Notification extends ImmutablePureComponent {
</span>
</div>
<StatusContainer
<StatusQuoteManager
id={notification.get('status')}
account={notification.get('account')}
muted
@ -345,7 +345,7 @@ class Notification extends ImmutablePureComponent {
</span>
</div>
<StatusContainer
<StatusQuoteManager
id={notification.get('status')}
contextType='notifications'
muted
@ -415,7 +415,7 @@ class Notification extends ImmutablePureComponent {
</span>
</div>
<StatusContainer
<StatusQuoteManager
id={notification.get('status')}
account={notification.get('account')}
contextType='notifications'
@ -457,7 +457,7 @@ class Notification extends ImmutablePureComponent {
</span>
</div>
<StatusContainer
<StatusQuoteManager
id={notification.get('status')}
account={account}
contextType='notifications'

View file

@ -1,31 +0,0 @@
import { Link } from 'react-router-dom';
import { Avatar } from 'mastodon/components/avatar';
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
import { useAppSelector } from 'mastodon/store';
const AvatarWrapper: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
if (!account) return null;
return (
<Link
to={`/@${account.acct}`}
title={`@${account.acct}`}
data-hover-card-account={account.id}
>
<Avatar account={account} size={28} />
</Link>
);
};
export const AvatarGroup: React.FC<{ accountIds: string[] }> = ({
accountIds,
}) => (
<div className='notification-group__avatar-group'>
{accountIds.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS).map((accountId) => (
<AvatarWrapper key={accountId} accountId={accountId} />
))}
</div>
);

View file

@ -7,17 +7,29 @@ import { HotKeys } from 'react-hotkeys';
import { replyComposeById } from 'mastodon/actions/compose';
import { navigateToStatus } from 'mastodon/actions/statuses';
import { Avatar } from 'mastodon/components/avatar';
import { AvatarGroup } from 'mastodon/components/avatar_group';
import EmojiView from 'mastodon/components/emoji_view';
import type { EmojiReactionGroup } from 'mastodon/models/notification_group';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import { RelativeTimestamp } from 'mastodon/components/relative_timestamp';
import type { EmojiReactionGroup } from 'mastodon/models/notification_group';
import { NOTIFICATIONS_GROUP_MAX_AVATARS } from 'mastodon/models/notification_group';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
import { AvatarGroup } from './avatar_group';
import { DisplayedName } from './displayed_name';
import { EmbeddedStatus } from './embedded_status';
const AVATAR_SIZE = 28;
export const AvatarById: React.FC<{ accountId: string }> = ({ accountId }) => {
const account = useAppSelector((state) => state.accounts.get(accountId));
if (!account) return null;
return <Avatar withLink account={account} size={AVATAR_SIZE} />;
};
export type LabelRenderer = (
displayedName: JSX.Element,
total: number,
@ -109,7 +121,13 @@ export const NotificationGroupWithStatus: React.FC<{
url={group.emoji.url}
staticUrl={group.emoji.static_url}
/>
<AvatarGroup accountIds={group.sampleAccountIds} />
<AvatarGroup avatarHeight={AVATAR_SIZE}>
{group.sampleAccountIds
.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS)
.map((id) => (
<AvatarById key={id} accountId={id} />
))}
</AvatarGroup>
{actions && (
<div className='notification-group__actions'>{actions}</div>
@ -120,7 +138,13 @@ export const NotificationGroupWithStatus: React.FC<{
{!emojiReactionGroups && (
<div className='notification-group__main__header__wrapper'>
<AvatarGroup accountIds={accountIds} />
<AvatarGroup avatarHeight={AVATAR_SIZE}>
{accountIds
.slice(0, NOTIFICATIONS_GROUP_MAX_AVATARS)
.map((id) => (
<AvatarById key={id} accountId={id} />
))}
</AvatarGroup>
{actions && (
<div className='notification-group__actions'>{actions}</div>
@ -130,7 +154,14 @@ export const NotificationGroupWithStatus: React.FC<{
<div className='notification-group__main__header__label'>
{label}
{timestamp && <RelativeTimestamp timestamp={timestamp} />}
{timestamp && (
<>
<span className='notification-group__main__header__label-separator'>
&middot;
</span>
<RelativeTimestamp timestamp={timestamp} />
</>
)}
</div>
</div>

View file

@ -12,7 +12,7 @@ import {
} from 'mastodon/actions/statuses';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import Status from 'mastodon/containers/status_container';
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
import { getStatusHidden } from 'mastodon/selectors/filters';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
@ -104,8 +104,7 @@ export const NotificationWithStatus: React.FC<{
{label}
</div>
<Status
// @ts-expect-error -- <Status> is not yet typed
<StatusQuoteManager
id={statusId}
contextType='notifications'
withDismiss

View file

@ -0,0 +1,192 @@
/* eslint-disable @typescript-eslint/no-unsafe-return,
@typescript-eslint/no-explicit-any,
@typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-assignment */
import type { ReactNode } from 'react';
import { useCallback } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import MenuIcon from '@/material-icons/400-24px/menu.svg?react';
import EmojiReactionIcon from '@/material-icons/400-24px/mood.svg?react';
import { updateReactionDeck } from 'mastodon/actions/reaction_deck';
import { Button } from 'mastodon/components/button';
import { Column } from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import EmojiPickerDropdown from 'mastodon/features/compose/containers/emoji_picker_dropdown_container';
import { autoPlayGif } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import emojify from '../emoji/emoji';
const messages = defineMessages({
reaction_deck_add: { id: 'reaction_deck.add', defaultMessage: 'Add' },
heading: { id: 'column.reaction_deck', defaultMessage: 'Reaction deck' },
});
const ReactionEmoji: React.FC<{
index: number;
emoji: string;
emojiMap: any;
onChange: (index: number, emoji: any) => void;
onRemove: (index: number) => void;
}> = ({ index, emoji, emojiMap, onChange, onRemove }) => {
const handleChange = useCallback((emoji: any) => {
onChange(index, emoji);
}, [index, onChange]);
const handleRemove = useCallback(() => {
onRemove(index);
}, [index, onRemove]);
let content: ReactNode;
const mapEmoji = emojiMap.find((e: any) => e.get('shortcode') === emoji);
if (mapEmoji) {
const filename = autoPlayGif
? mapEmoji.get('url')
: mapEmoji.get('static_url');
const shortCode = `:${emoji}:`;
content = (
<img
draggable='false'
className='emojione custom-emoji'
alt={shortCode}
title={shortCode}
src={filename}
/>
);
} else {
const html = { __html: emojify(emoji) };
content = <span dangerouslySetInnerHTML={html} />;
}
return (
<div className='reaction_deck__emoji'>
<div className='reaction_deck__emoji__wrapper'>
<div className='reaction_deck__emoji__wrapper__content'>
<EmojiPickerDropdown onPickEmoji={handleChange} />
<div>
{content}
</div>
</div>
<div className='reaction_deck__emoji__wrapper__options'>
<Button secondary text={'Remove'} onClick={handleRemove} />
</div>
</div>
</div>
);
};
export const ReactionDeck: React.FC<{
multiColumn?: boolean;
}> = ({ multiColumn }) => {
const dispatch = useAppDispatch();
const intl = useIntl();
const emojiMap = useAppSelector((state) => state.custom_emojis);
const deck = useAppSelector((state) => state.reaction_deck);
const onChange = useCallback(
(emojis: any) => {
dispatch(updateReactionDeck(emojis));
},
[dispatch],
);
const deckToArray = (deckData: any) =>
deckData.map((item: any) => item.get('name')).toArray();
/*
const handleReorder = useCallback((result: any) => {
const newDeck = deckToArray(deck);
const deleted = newDeck.splice(result.source.index, 1);
newDeck.splice(result.destination.index, 0, deleted[0]);
onChange(newDeck);
}, [onChange, deck]);
*/
const handleChange = useCallback(
(index: number, emoji: any) => {
const newDeck = deckToArray(deck);
newDeck[index] = emoji.native || emoji.id.replace(':', '');
onChange(newDeck);
},
[onChange, deck]
);
const handleRemove = useCallback(
(index: number) => {
const newDeck = deckToArray(deck);
newDeck.splice(index, 1);
onChange(newDeck);
},
[onChange, deck],
);
const handleAdd = useCallback(
(emoji: any) => {
const newDeck = deckToArray(deck);
newDeck.push('👍');
onChange(newDeck);
},
[onChange, deck],
);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!deck) {
return (
<Column>
<LoadingIndicator />
</Column>
);
}
return (
<Column bindToDocument={!multiColumn}>
<ColumnHeader
icon='smile-o'
iconComponent={EmojiReactionIcon}
title={intl.formatMessage(messages.heading)}
multiColumn={multiColumn}
showBackButton
/>
{deck.map((emoji: any, index) => (
<ReactionEmoji
emojiMap={emojiMap}
key={index}
emoji={emoji.get('name')}
index={index}
onChange={handleChange}
onRemove={handleRemove}
/>
))}
<div>
<EmojiPickerDropdown
onPickEmoji={handleAdd}
button={
<Button secondary>
<FormattedMessage id='reaction_deck.add' defaultMessage='Add' />
</Button>
}
/>
</div>
<Helmet>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default ReactionDeck;

View file

@ -12,6 +12,7 @@ import Option from './components/option';
const mapStateToProps = state => ({
rules: state.getIn(['server', 'server', 'rules']),
locale: state.getIn(['meta', 'locale']),
});
class Rules extends PureComponent {
@ -19,6 +20,7 @@ class Rules extends PureComponent {
static propTypes = {
onNextStep: PropTypes.func.isRequired,
rules: ImmutablePropTypes.list,
locale: PropTypes.string,
selectedRuleIds: ImmutablePropTypes.set.isRequired,
onToggle: PropTypes.func.isRequired,
};
@ -34,7 +36,7 @@ class Rules extends PureComponent {
};
render () {
const { rules, selectedRuleIds } = this.props;
const { rules, locale, selectedRuleIds } = this.props;
return (
<>
@ -49,7 +51,7 @@ class Rules extends PureComponent {
value={item.get('id')}
checked={selectedRuleIds.includes(item.get('id'))}
onToggle={this.handleRulesToggle}
label={item.get('text')}
label={item.getIn(['translations', locale, 'text']) || item.getIn(['translations', locale.split('-')[0], 'text']) || item.get('text')}
multiple
/>
))}

View file

@ -17,7 +17,7 @@ import { ColumnHeader } from 'mastodon/components/column_header';
import { CompatibilityHashtag as Hashtag } from 'mastodon/components/hashtag';
import { Icon } from 'mastodon/components/icon';
import ScrollableList from 'mastodon/components/scrollable_list';
import Status from 'mastodon/containers/status_container';
import { StatusQuoteManager } from 'mastodon/components/status_quoted';
import { Search } from 'mastodon/features/compose/components/search';
import { useSearchParam } from 'mastodon/hooks/useSearchParam';
import type { Hashtag as HashtagType } from 'mastodon/models/tags';
@ -53,8 +53,7 @@ const renderHashtags = (hashtags: HashtagType[]) =>
const renderStatuses = (statusIds: string[]) =>
hidePeek<string>(statusIds).map((id) => (
// @ts-expect-error inferred props are wrong
<Status key={id} id={id} contextType='explore' />
<StatusQuoteManager key={id} id={id} contextType='explore' />
));
type SearchType = 'all' | ApiSearchType;
@ -190,8 +189,7 @@ export const SearchResults: React.FC<{ multiColumn: boolean }> = ({
onClickMore={handleSelectStatuses}
>
{results.statuses.slice(0, INITIAL_DISPLAY).map((id) => (
// @ts-expect-error inferred props are wrong
<Status key={id} id={id} contextType='explore' />
<StatusQuoteManager key={id} id={id} contextType='explore' />
))}
</SearchSection>
)}

View file

@ -28,6 +28,7 @@ import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_pict
import { SearchabilityIcon } from 'mastodon/components/searchability_icon';
import StatusContent from 'mastodon/components/status_content';
import StatusEmojiReactionsBar from 'mastodon/components/status_emoji_reactions_bar';
import { QuotedStatus } from 'mastodon/components/status_quoted';
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
import { Audio } from 'mastodon/features/audio';
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
@ -235,7 +236,7 @@ export const DetailedStatus: React.FC<{
/>
);
}
} else if (status.get('card')) {
} else if (status.get('card') && !status.get('quote')) {
media = (
<Card
sensitive={status.get('sensitive') && !status.get('spoiler_text')}
@ -381,7 +382,12 @@ export const DetailedStatus: React.FC<{
return (
<div style={outerStyle}>
<div ref={handleRef} className={classNames('detailed-status')}>
<div
ref={handleRef}
className={classNames('detailed-status', {
'status--has-quote': !!status.get('quote'),
})}
>
{status.get('visibility_ex') === 'direct' && (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'>
@ -446,6 +452,10 @@ export const DetailedStatus: React.FC<{
{...(statusContentProps as any)}
/>
{status.get('quote') && (
<QuotedStatus quote={status.get('quote')} />
)}
{media}
{hashtagBar}
{emojiReactionsBar}

View file

@ -6,8 +6,6 @@ import classNames from 'classnames';
import { Helmet } from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { createSelector } from '@reduxjs/toolkit';
import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux';
@ -65,7 +63,7 @@ import {
} from '../../actions/statuses';
import ColumnHeader from '../../components/column_header';
import { textForScreenReader, defaultMediaVisibility } from '../../components/status';
import StatusContainer from '../../containers/status_container';
import { StatusQuoteManager } from '../../components/status_quoted';
import { bookmarkCategoryNeeded, deleteModal } from '../../initial_state';
import { makeGetStatus, makeGetPictureInPicture } from '../../selectors';
import { getAncestorsIds, getDescendantsIds, getReferencesIds } from 'mastodon/selectors/contexts';
@ -539,7 +537,7 @@ class Status extends ImmutablePureComponent {
const { params: { statusId } } = this.props;
return list.map((id, i) => (
<StatusContainer
<StatusQuoteManager
key={id}
id={id}
onMoveUp={this.handleMoveUp}

View file

@ -1,315 +1,315 @@
export function EmojiPicker () {
return import(/* webpackChunkName: "emoji_picker" */'../../emoji/emoji_picker');
return import('../../emoji/emoji_picker');
}
export function Compose () {
return import(/* webpackChunkName: "features/compose" */'../../compose');
return import('../../compose');
}
export function Notifications () {
return import(/* webpackChunkName: "features/notifications" */'../../notifications_v2');
return import('../../notifications_v2');
}
export function HomeTimeline () {
return import(/* webpackChunkName: "features/home_timeline" */'../../home_timeline');
return import('../../home_timeline');
}
export function PublicTimeline () {
return import(/* webpackChunkName: "features/public_timeline" */'../../public_timeline');
return import('../../public_timeline');
}
export function CommunityTimeline () {
return import(/* webpackChunkName: "features/community_timeline" */'../../community_timeline');
return import('../../community_timeline');
}
export function Firehose () {
return import(/* webpackChunkName: "features/firehose" */'../../firehose');
return import('../../firehose');
}
export function HashtagTimeline () {
return import(/* webpackChunkName: "features/hashtag_timeline" */'../../hashtag_timeline');
return import('../../hashtag_timeline');
}
export function DirectTimeline() {
return import(/* webpackChunkName: "features/direct_timeline" */'../../direct_timeline');
return import('../../direct_timeline');
}
export function AntennaTimeline () {
return import(/* webpackChunkName: "features/antenna_timeline" */'../../antenna_timeline');
return import('../../antenna_timeline');
}
export function ListTimeline () {
return import(/* webpackChunkName: "features/list_timeline" */'../../list_timeline');
return import('../../list_timeline');
}
export function Lists () {
return import(/* webpackChunkName: "features/lists" */'../../lists');
return import('../../lists');
}
export function Antennas () {
return import(/* webpackChunkName: "features/antennas" */'../../antennas');
return import('../../antennas');
}
export function Circles () {
return import(/* webpackChunkName: "features/circles" */'../../circles');
return import('../../circles');
}
export function CircleStatuses () {
return import(/* webpackChunkName: "features/circle_statuses" */'../../circle_statuses');
return import('../../circle_statuses');
}
export function Status () {
return import(/* webpackChunkName: "features/status" */'../../status');
return import('../../status');
}
export function GettingStarted () {
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
return import('../../getting_started');
}
export function KeyboardShortcuts () {
return import(/* webpackChunkName: "features/keyboard_shortcuts" */'../../keyboard_shortcuts');
return import('../../keyboard_shortcuts');
}
export function PinnedStatuses () {
return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
return import('../../pinned_statuses');
}
export function AccountTimeline () {
return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
return import('../../account_timeline');
}
export function AccountGallery () {
return import(/* webpackChunkName: "features/account_gallery" */'../../account_gallery');
return import('../../account_gallery');
}
export function AccountFeatured() {
return import(/* webpackChunkName: "features/account_featured" */'../../account_featured');
return import('../../account_featured');
}
export function Followers () {
return import(/* webpackChunkName: "features/followers" */'../../followers');
return import('../../followers');
}
export function Following () {
return import(/* webpackChunkName: "features/following" */'../../following');
return import('../../following');
}
export function Reblogs () {
return import(/* webpackChunkName: "features/reblogs" */'../../reblogs');
return import('../../reblogs');
}
export function Favourites () {
return import(/* webpackChunkName: "features/favourites" */'../../favourites');
return import('../../favourites');
}
export function EmojiReactions () {
return import(/* webpackChunkName: "features/emoji_reactions" */'../../emoji_reactions');
return import('../../emoji_reactions');
}
export function StatusReferences () {
return import(/* webpackChunkName: "features/status_references" */'../../status_references');
return import('../../status_references');
}
export function MentionedUsers () {
return import(/* webpackChunkName: "features/mentioned_users" */'../../mentioned_users');
return import('../../mentioned_users');
}
export function FollowRequests () {
return import(/* webpackChunkName: "features/follow_requests" */'../../follow_requests');
return import('../../follow_requests');
}
export function FavouritedStatuses () {
return import(/* webpackChunkName: "features/favourited_statuses" */'../../favourited_statuses');
return import('../../favourited_statuses');
}
export function EmojiReactedStatuses () {
return import(/* webpackChunkName: "features/emoji_reacted_statuses" */'../../emoji_reacted_statuses');
return import('../../emoji_reacted_statuses');
}
export function FollowedTags () {
return import(/* webpackChunkName: "features/followed_tags" */'../../followed_tags');
return import('../../followed_tags');
}
export function BookmarkedStatuses () {
return import(/* webpackChunkName: "features/bookmarked_statuses" */'../../bookmarked_statuses');
return import('../../bookmarked_statuses');
}
export function BookmarkCategories () {
return import(/* webpackChunkName: "features/bookmark_categories" */'../../bookmark_categories');
return import('../../bookmark_categories');
}
export function BookmarkCategoryStatuses () {
return import(/* webpackChunkName: "features/bookmark_category_statuses" */'../../bookmark_category_statuses');
return import('../../bookmark_category_statuses');
}
export function BookmarkCategoryAdder () {
return import(/* webpackChunkName: "features/bookmark_category_adder" */'../../bookmark_category_adder');
return import('../../bookmark_category_adder');
}
export function Blocks () {
return import(/* webpackChunkName: "features/blocks" */'../../blocks');
return import('../../blocks');
}
export function DomainBlocks () {
return import(/* webpackChunkName: "features/domain_blocks" */'../../domain_blocks');
return import('../../domain_blocks');
}
export function Mutes () {
return import(/* webpackChunkName: "features/mutes" */'../../mutes');
return import('../../mutes');
}
export function MuteModal () {
return import(/* webpackChunkName: "modals/mute_modal" */'../components/mute_modal');
return import('../components/mute_modal');
}
export function BlockModal () {
return import(/* webpackChunkName: "modals/block_modal" */'../components/block_modal');
return import('../components/block_modal');
}
export function DomainBlockModal () {
return import(/* webpackChunkName: "modals/domain_block_modal" */'../components/domain_block_modal');
return import('../components/domain_block_modal');
}
export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
return import('../components/report_modal');
}
export function IgnoreNotificationsModal () {
return import(/* webpackChunkName: "modals/domain_block_modal" */'../components/ignore_notifications_modal');
return import('../components/ignore_notifications_modal');
}
export function MediaGallery () {
return import(/* webpackChunkName: "status/media_gallery" */'../../../components/media_gallery');
return import('../../../components/media_gallery');
}
export function Video () {
return import(/* webpackChunkName: "features/video" */'../../video');
return import('../../video');
}
export function EmbedModal () {
return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
return import('../components/embed_modal');
}
export function ListAdder () {
return import(/*webpackChunkName: "features/list_adder" */'../../list_adder');
return import('../../list_adder');
}
export function AntennaAdder () {
return import(/*webpackChunkName: "features/antenna_adder" */'../../antenna_adder');
return import('../../antenna_adder');
}
export function CircleAdder () {
return import(/*webpackChunkName: "features/circle_adder" */'../../circle_adder');
return import('../../circle_adder');
}
export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
return import('tesseract.js');
}
export function Audio () {
return import(/* webpackChunkName: "features/audio" */'../../audio');
return import('../../audio');
}
export function Directory () {
return import(/* webpackChunkName: "features/directory" */'../../directory');
return import('../../directory');
}
export function OnboardingProfile () {
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/profile');
return import('../../onboarding/profile');
}
export function OnboardingFollows () {
return import(/* webpackChunkName: "features/onboarding" */'../../onboarding/follows');
return import('../../onboarding/follows');
}
export function ReactionDeck () {
return import(/* webpackChunkName: "features/reaction_deck" */'../../reaction_deck');
return import('../../reaction_deck');
}
export function CompareHistoryModal () {
return import(/*webpackChunkName: "modals/compare_history_modal" */'../components/compare_history_modal');
return import('../components/compare_history_modal');
}
export function Explore () {
return import(/* webpackChunkName: "features/explore" */'../../explore');
return import('../../explore');
}
export function Search () {
return import(/* webpackChunkName: "features/explore" */'../../search');
return import('../../search');
}
export function FilterModal () {
return import(/*webpackChunkName: "modals/filter_modal" */'../components/filter_modal');
return import('../components/filter_modal');
}
export function InteractionModal () {
return import(/*webpackChunkName: "modals/interaction_modal" */'../../interaction_modal');
return import('../../interaction_modal');
}
export function SubscribedLanguagesModal () {
return import(/*webpackChunkName: "modals/subscribed_languages_modal" */'../../subscribed_languages_modal');
return import('../../subscribed_languages_modal');
}
export function ClosedRegistrationsModal () {
return import(/*webpackChunkName: "modals/closed_registrations_modal" */'../../closed_registrations_modal');
return import('../../closed_registrations_modal');
}
export function About () {
return import(/*webpackChunkName: "features/about" */'../../about');
return import('../../about');
}
export function PrivacyPolicy () {
return import(/*webpackChunkName: "features/privacy_policy" */'../../privacy_policy');
return import('../../privacy_policy');
}
export function TermsOfService () {
return import(/*webpackChunkName: "features/terms_of_service" */'../../terms_of_service');
return import('../../terms_of_service');
}
export function NotificationRequests () {
return import(/*webpackChunkName: "features/notifications/requests" */'../../notifications/requests');
return import('../../notifications/requests');
}
export function NotificationRequest () {
return import(/*webpackChunkName: "features/notifications/request" */'../../notifications/request');
return import('../../notifications/request');
}
export function LinkTimeline () {
return import(/*webpackChunkName: "features/link_timeline" */'../../link_timeline');
return import('../../link_timeline');
}
export function AnnualReportModal () {
return import(/*webpackChunkName: "modals/annual_report_modal" */'../components/annual_report_modal');
return import('../components/annual_report_modal');
}
export function ListEdit () {
return import(/*webpackChunkName: "features/lists" */'../../lists/new');
return import('../../lists/new');
}
export function ListMembers () {
return import(/* webpackChunkName: "features/lists" */'../../lists/members');
return import('../../lists/members');
}
export function AntennaEdit () {
return import(/*webpackChunkName: "features/antennas" */'../../antennas/new');
return import('../../antennas/new');
}
export function AntennaMembers () {
return import(/* webpackChunkName: "features/antennas" */'../../antennas/members');
return import('../../antennas/members');
}
export function AntennaSetting () {
return import(/*webpackChunkName: "features/antennas/filtering" */'../../antennas/filtering');
return import('../../antennas/filtering');
}
export function CircleEdit () {
return import(/*webpackChunkName: "features/circles" */'../../circles/new');
return import('../../circles/new');
}
export function CircleMembers () {
return import(/* webpackChunkName: "features/circles" */'../../circles/members');
return import('../../circles/members');
}
export function BookmarkCategoryEdit () {
return import(/*webpackChunkName: "features/bookmark_categories" */'../../bookmark_categories/new');
return import('../../bookmark_categories/new');
}

View file

@ -346,8 +346,10 @@ export const Video: React.FC<{
const updateProgress = () => {
nextFrame = requestAnimationFrame(() => {
if (videoRef.current) {
const progress =
videoRef.current.currentTime / videoRef.current.duration;
void api.start({
progress: `${(videoRef.current.currentTime / videoRef.current.duration) * 100}%`,
progress: isNaN(progress) ? '0%' : `${progress * 100}%`,
immediate: reduceMotion,
config: config.stiff,
});

View file

@ -3,7 +3,7 @@
// can at least log in using KaiOS devices).
function importArrowKeyNavigation() {
return import(/* webpackChunkName: "arrow-key-navigation" */ 'arrow-key-navigation');
return import('arrow-key-navigation');
}
export default function loadKeyboardExtensions() {

View file

@ -23,9 +23,11 @@
"account.copy": "Profil linkini kopyala",
"account.direct": "@{name} istifadəçisini fərdi olaraq etiketlə",
"account.disable_notifications": "@{name} paylaşım edəndə mənə bildiriş göndərməyi dayandır",
"account.domain_blocking": "Domenin bloklanması",
"account.edit_profile": "Profili redaktə et",
"account.enable_notifications": "@{name} paylaşım edəndə mənə bildiriş göndər",
"account.endorse": "Profildə seçilmişlərə əlavə et",
"account.featured.hashtags": "Etiketler",
"account.featured_tags.last_status_at": "Son paylaşım {date} tarixində olub",
"account.featured_tags.last_status_never": "Paylaşım yoxdur",
"account.follow": "İzlə",

View file

@ -28,6 +28,8 @@
"account.edit_profile": "Редактиране на профила",
"account.enable_notifications": "Известяване при публикуване от @{name}",
"account.endorse": "Представи в профила",
"account.familiar_followers_one": "Последвано от {name1}",
"account.familiar_followers_two": "Последвано от {name1} и {name2}",
"account.featured": "Препоръчано",
"account.featured.accounts": "Профили",
"account.featured.hashtags": "Хаштагове",
@ -304,6 +306,7 @@
"emoji_button.search_results": "Резултати от търсене",
"emoji_button.symbols": "Символи",
"emoji_button.travel": "Пътуване и места",
"empty_column.account_featured_other.unknown": "Този акаунт още не е отличил нищо.",
"empty_column.account_hides_collections": "Този потребител е избрал да не дава тази информация",
"empty_column.account_suspended": "Спрян акаунт",
"empty_column.account_timeline": "Тук няма публикации!",
@ -508,7 +511,6 @@
"lists.exclusive": "Скриване на членуващи в Начало",
"lists.exclusive_hint": "Ако някой е в този списък, то скрийте го в инфоканала си на Начало, за да избегнете виждането на публикациите му два пъти.",
"lists.find_users_to_add": "Намерете потребители за добавяне",
"lists.list_members": "Списък членуващи",
"lists.list_members_count": "{count, plural, one {# членуващ} other {# членуващи}}",
"lists.list_name": "Име на списък",
"lists.new_list_name": "Ново име на списък",

View file

@ -25,6 +25,8 @@
"account.edit_profile": "Kemmañ ar profil",
"account.enable_notifications": "Ma c'hemenn pa vez embannet traoù gant @{name}",
"account.endorse": "Lakaat war-wel war ar profil",
"account.familiar_followers_one": "Heuilhet gant {name1}",
"account.familiar_followers_two": "Heuilhet gant {name1} ha {name2}",
"account.featured_tags.last_status_at": "Toud diwezhañ : {date}",
"account.featured_tags.last_status_never": "Embannadur ebet",
"account.follow": "Heuliañ",
@ -32,6 +34,7 @@
"account.followers": "Tud koumanantet",
"account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.",
"account.followers_counter": "{count, plural, one {{counter} heulier} two {{counter} heulier} few {{counter} heulier} many {{counter} heulier} other {{counter} heulier}}",
"account.followers_you_know_counter": "{counter} a anavezit",
"account.following": "Koumanantoù",
"account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
"account.go_to_profile": "Gwelet ar profil",
@ -575,6 +578,7 @@
"status.mute": "Kuzhat @{name}",
"status.mute_conversation": "Kuzhat ar gaozeadenn",
"status.open": "Digeriñ ar c'hannad-mañ",
"status.quote_post_author": "Embannadenn gant {name}",
"status.read_more": "Lenn muioc'h",
"status.reblog": "Skignañ",
"status.reblog_private": "Skignañ gant ar weledenn gentañ",

View file

@ -28,6 +28,9 @@
"account.edit_profile": "Edita el perfil",
"account.enable_notifications": "Notifica'm els tuts de @{name}",
"account.endorse": "Recomana en el perfil",
"account.familiar_followers_many": "Seguit per {name1}, {name2} i {othersCount, plural, one {# altre compte} other {# altres comptes}} que coneixeu",
"account.familiar_followers_one": "Seguit per {name1}",
"account.familiar_followers_two": "Seguit per {name1} i {name2}",
"account.featured": "Destacat",
"account.featured.accounts": "Perfils",
"account.featured.hashtags": "Etiquetes",
@ -39,6 +42,7 @@
"account.followers": "Seguidors",
"account.followers.empty": "A aquest usuari encara no el segueix ningú.",
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidors}}",
"account.followers_you_know_counter": "{counter} que coneixeu",
"account.following": "Seguint",
"account.following_counter": "{count, plural, other {Seguint-ne {counter}}}",
"account.follows.empty": "Aquest usuari encara no segueix ningú.",
@ -49,7 +53,7 @@
"account.joined_short": "S'hi va unir",
"account.languages": "Canvia les llengües subscrites",
"account.link_verified_on": "La propietat d'aquest enllaç es va verificar el dia {date}",
"account.locked_info": "L'estat de privadesa del compte està definit com a blocat. El propietari revisa manualment qui pot seguir-lo.",
"account.locked_info": "L'estat de privacitat del compte està definit com a blocat. El propietari revisa manualment qui pot seguir-lo.",
"account.media": "Contingut",
"account.mention": "Menciona @{name}",
"account.moved_to": "{name} ha indicat que el seu nou compte és:",
@ -514,7 +518,6 @@
"lists.exclusive": "Amaga membres a Inici",
"lists.exclusive_hint": "Si algú és a la llista, amagueu-los de la pantalla d'inici, per a no veure'n les publicacions duplicades.",
"lists.find_users_to_add": "Troba usuaris per a afegir",
"lists.list_members": "Membres de la llista",
"lists.list_members_count": "{count, plural, one {# membre} other {# membres}}",
"lists.list_name": "Nom de la llista",
"lists.new_list_name": "Nom de la nova llista",
@ -725,7 +728,7 @@
"privacy.unlisted.long": "Menys fanfàrries algorísmiques",
"privacy.unlisted.short": "Públic silenciós",
"privacy_policy.last_updated": "Darrera actualització {date}",
"privacy_policy.title": "Política de privadesa",
"privacy_policy.title": "Política de Privacitat",
"recommended": "Recomanat",
"refresh": "Actualitza",
"regeneration_indicator.please_stand_by": "Espereu.",
@ -860,6 +863,13 @@
"status.mute_conversation": "Silencia la conversa",
"status.open": "Amplia el tut",
"status.pin": "Destaca al perfil",
"status.quote_error.filtered": "No es mostra a causa d'un dels vostres filtres",
"status.quote_error.not_found": "No es pot mostrar aquesta publicació.",
"status.quote_error.pending_approval": "Aquesta publicació està pendent d'aprovació per l'autor original.",
"status.quote_error.rejected": "No es pot mostrar aquesta publicació perquè l'autor original no en permet la citació.",
"status.quote_error.removed": "Aquesta publicació ha estat eliminada per l'autor.",
"status.quote_error.unauthorized": "No es pot mostrar aquesta publicació perquè no teniu autorització per a veure-la.",
"status.quote_post_author": "Publicació de {name}",
"status.read_more": "Més informació",
"status.reblog": "Impulsa",
"status.reblog_private": "Impulsa amb la visibilitat original",

View file

@ -28,6 +28,9 @@
"account.edit_profile": "Upravit profil",
"account.enable_notifications": "Oznamovat mi příspěvky @{name}",
"account.endorse": "Zvýraznit na profilu",
"account.familiar_followers_many": "Sleduje je {name1}, {name2} a {othersCount, plural, one {jeden další, které znáte} few {# další, které znáte} many {# dalších, které znáte} other {# dalších, které znáte}}",
"account.familiar_followers_one": "Sleduje je {name1}",
"account.familiar_followers_two": "Sleduje je {name1} a {name2}",
"account.featured": "Zvýrazněné",
"account.featured.accounts": "Profily",
"account.featured.hashtags": "Hashtagy",
@ -39,6 +42,7 @@
"account.followers": "Sledující",
"account.followers.empty": "Tohoto uživatele zatím nikdo nesleduje.",
"account.followers_counter": "{count, plural, one {{counter} sledující} few {{counter} sledující} many {{counter} sledujících} other {{counter} sledujících}}",
"account.followers_you_know_counter": "{count, one {{counter}, kterého znáte}, few {{counter}, které znáte}, many {{counter}, kterých znáte} other {{counter}, kterých znáte}}",
"account.following": "Sledujete",
"account.following_counter": "{count, plural, one {{counter} sledovaný} few {{counter} sledovaní} many {{counter} sledovaných} other {{counter} sledovaných}}",
"account.follows.empty": "Tento uživatel zatím nikoho nesleduje.",
@ -515,7 +519,6 @@
"lists.exclusive": "Skrýt členy na domovském kanálu",
"lists.exclusive_hint": "Pokud je někdo na tomto seznamu, skryjte jej ve vašem domovském kanálu, abyste se vyhnuli dvojímu vidění jejich příspěvků.",
"lists.find_users_to_add": "Najít uživatele, které chcete přidat",
"lists.list_members": "Členové seznamu",
"lists.list_members_count": "{count, plural, one {# člen} few {# členové} many {# členů} other {# členů}}",
"lists.list_name": "Název seznamu",
"lists.new_list_name": "Název nového seznamu",
@ -681,7 +684,7 @@
"notifications.policy.filter_not_followers_title": "Lidé, kteří vás nesledují",
"notifications.policy.filter_not_following_hint": "Dokud je ručně neschválíte",
"notifications.policy.filter_not_following_title": "Lidé, které nesledujete",
"notifications.policy.filter_private_mentions_hint": "Vyfiltrováno, pokud to není odpověď na vaši zmínku nebo pokud sledujete odesílatele",
"notifications.policy.filter_private_mentions_hint": "Filtrováno, pokud to není v odpovědi na vaši vlastní zmínku nebo pokud nesledujete odesílatele",
"notifications.policy.filter_private_mentions_title": "Nevyžádané soukromé zmínky",
"notifications.policy.title": "Spravovat oznámení od…",
"notifications_permission_banner.enable": "Povolit oznámení na ploše",
@ -742,7 +745,7 @@
"relative_time.minutes": "{number} m",
"relative_time.seconds": "{number} s",
"relative_time.today": "dnes",
"reply_indicator.attachments": "{count, plural, one {{counter} příloha} few {{counter} přílohy} other {{counter} přilohů}}",
"reply_indicator.attachments": "{count, plural, one {{counter} příloha} few {{counter} přílohy} other {{counter} příloh}}",
"reply_indicator.cancel": "Zrušit",
"reply_indicator.poll": "Anketa",
"report.block": "Blokovat",
@ -861,6 +864,13 @@
"status.mute_conversation": "Skrýt konverzaci",
"status.open": "Rozbalit tento příspěvek",
"status.pin": "Zvýraznit na profilu",
"status.quote_error.filtered": "Skryté kvůli jednomu z vašich filtrů",
"status.quote_error.not_found": "Tento příspěvek nelze zobrazit.",
"status.quote_error.pending_approval": "Tento příspěvek čeká na schválení od původního autora.",
"status.quote_error.rejected": "Tento příspěvek nemůže být zobrazen, protože původní autor neumožňuje, aby byl citován.",
"status.quote_error.removed": "Tento příspěvek byl odstraněn jeho autorem.",
"status.quote_error.unauthorized": "Tento příspěvek nelze zobrazit, protože nemáte oprávnění k jeho zobrazení.",
"status.quote_post_author": "Příspěvek od {name}",
"status.read_more": "Číst více",
"status.reblog": "Boostnout",
"status.reblog_private": "Boostnout s původní viditelností",

View file

@ -28,6 +28,9 @@
"account.edit_profile": "Golygu'r proffil",
"account.enable_notifications": "Rhowch wybod i fi pan fydd @{name} yn postio",
"account.endorse": "Dangos ar fy mhroffil",
"account.familiar_followers_many": "Yn cael ei ddilyn gan {name1},{name2}, a {othersCount, plural, one {one other you know} other{# others you know}}",
"account.familiar_followers_one": "Wedi'i ddilyn gan {name1}",
"account.familiar_followers_two": "Wedi'i ddilyn gan {name1} a {name2}",
"account.featured": "Nodwedd",
"account.featured.accounts": "Proffilau",
"account.featured.hashtags": "Hashnodau",
@ -39,6 +42,7 @@
"account.followers": "Dilynwyr",
"account.followers.empty": "Does neb yn dilyn y defnyddiwr hwn eto.",
"account.followers_counter": "{count, plural, one {{counter} dilynwr} two {{counter} ddilynwr} other {{counter} dilynwyr}}",
"account.followers_you_know_counter": "{counter} rydych chi'n adnabod",
"account.following": "Yn dilyn",
"account.following_counter": "{count, plural, one {Yn dilyn {counter}} other {Yn dilyn {counter} arall}}",
"account.follows.empty": "Dyw'r defnyddiwr hwn ddim yn dilyn unrhyw un eto.",
@ -515,7 +519,6 @@
"lists.exclusive": "Cuddio aelodau yn y Cartref",
"lists.exclusive_hint": "Os oes rhywun ar y rhestr hon, cuddiwch nhw yn eich llif Cartref i osgoi gweld eu postiadau ddwywaith.",
"lists.find_users_to_add": "Canfod defnyddwyr i'w hychwanegu",
"lists.list_members": "Aelodau rhestr",
"lists.list_members_count": "{count, plural, one {# aelod} other {# aelod}}",
"lists.list_name": "Enw rhestr",
"lists.new_list_name": "Enw rhestr newydd",
@ -861,6 +864,12 @@
"status.mute_conversation": "Anwybyddu sgwrs",
"status.open": "Ehangu'r post hwn",
"status.pin": "Dangos ar y proffil",
"status.quote_error.not_found": "Does dim modd dangos y postiad hwn.",
"status.quote_error.pending_approval": "Mae'r postiad hwn yn aros am gymeradwyaeth yr awdur gwreiddiol.",
"status.quote_error.rejected": "Does dim modd dangos y postiad hwn gan nad yw'r awdur gwreiddiol yn caniatáu iddo gael ei ddyfynnu.",
"status.quote_error.removed": "Cafodd y postiad hwn ei ddileu gan ei awdur.",
"status.quote_error.unauthorized": "Does dim modd dangos y postiad hwn gan nad oes gennych awdurdod i'w weld.",
"status.quote_post_author": "Postiad gan {name}",
"status.read_more": "Darllen rhagor",
"status.reblog": "Hybu",
"status.reblog_private": "Hybu i'r gynulleidfa wreiddiol",

View file

@ -28,6 +28,9 @@
"account.edit_profile": "Redigér profil",
"account.enable_notifications": "Advisér mig, når @{name} poster",
"account.endorse": "Fremhæv på profil",
"account.familiar_followers_many": "Følges af {name1}, {name2} og {othersCount, plural, one {# mere, man kender} other {# mere, man kender}}",
"account.familiar_followers_one": "Følges af {name1}",
"account.familiar_followers_two": "Følges af {name1} og {name2}",
"account.featured": "Fremhævet",
"account.featured.accounts": "Profiler",
"account.featured.hashtags": "Hashtags",
@ -39,6 +42,7 @@
"account.followers": "Følgere",
"account.followers.empty": "Ingen følger denne bruger endnu.",
"account.followers_counter": "{count, plural, one {{counter} følger} other {{counter} følgere}}",
"account.followers_you_know_counter": "{counter} man kender",
"account.following": "Følger",
"account.following_counter": "{count, plural, one {{counter} følger} other {{counter} følger}}",
"account.follows.empty": "Denne bruger følger ikke nogen endnu.",
@ -515,7 +519,6 @@
"lists.exclusive": "Skjul medlemmer i Hjem",
"lists.exclusive_hint": "Er nogen er på denne liste, skjul personen i hjemme-feeds for at undgå at se vedkommendes indlæg to gange.",
"lists.find_users_to_add": "Find brugere at tilføje",
"lists.list_members": "Liste over medlemmer",
"lists.list_members_count": "{count, plural, one {# medlem} other {# medlemmer}}",
"lists.list_name": "Listetitel",
"lists.new_list_name": "Ny listetitel",
@ -861,6 +864,13 @@
"status.mute_conversation": "Skjul samtale",
"status.open": "Udvid dette indlæg",
"status.pin": "Fremhæv på profil",
"status.quote_error.filtered": "Skjult grundet et af filterne",
"status.quote_error.not_found": "Dette indlæg kan ikke vises.",
"status.quote_error.pending_approval": "Dette indlæg afventer godkendelse fra den oprindelige forfatter.",
"status.quote_error.rejected": "Dette indlæg kan ikke vises, da den oprindelige forfatter ikke tillader citering heraf.",
"status.quote_error.removed": "Dette indlæg er fjernet af forfatteren.",
"status.quote_error.unauthorized": "Dette indlæg kan ikke vises, da man ikke har tilladelse til at se det.",
"status.quote_post_author": "Indlæg fra {name}",
"status.read_more": "Læs mere",
"status.reblog": "Fremhæv",
"status.reblog_private": "Fremhæv med oprindelig synlighed",

View file

@ -28,6 +28,9 @@
"account.edit_profile": "Profil bearbeiten",
"account.enable_notifications": "Benachrichtige mich wenn @{name} etwas postet",
"account.endorse": "Im Profil vorstellen",
"account.familiar_followers_many": "Gefolgt von {name1}, {name2} und {othersCount, plural, one {einem weiteren Profil, das dir bekannt ist} other {# weiteren Profilen, die dir bekannt sind}}",
"account.familiar_followers_one": "Gefolgt von {name1}",
"account.familiar_followers_two": "Gefolgt von {name1} und {name2}",
"account.featured": "Vorgestellt",
"account.featured.accounts": "Profile",
"account.featured.hashtags": "Hashtags",
@ -39,6 +42,7 @@
"account.followers": "Follower",
"account.followers.empty": "Diesem Profil folgt noch niemand.",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Follower}}",
"account.followers_you_know_counter": "{counter} bekannt",
"account.following": "Folge ich",
"account.following_counter": "{count, plural, one {{counter} Folge ich} other {{counter} Folge ich}}",
"account.follows.empty": "Dieses Profil folgt noch niemandem.",
@ -357,7 +361,7 @@
"filter_modal.select_filter.title": "Diesen Beitrag filtern",
"filter_modal.title.status": "Beitrag per Filter ausblenden",
"filter_warning.matches_filter": "Übereinstimmend mit dem Filter „<span>{title}</span>“",
"filtered_notifications_banner.pending_requests": "Von {count, plural, =0 {keinem, den} one {einer Person, die} other {# Personen, die}} du möglicherweise kennst",
"filtered_notifications_banner.pending_requests": "Von {count, plural, =0 {keinem Profil, das dir möglicherweise bekannt ist} one {einem Profil, das dir möglicherweise bekannt ist} other {# Profilen, die dir möglicherweise bekannt sind}}",
"filtered_notifications_banner.title": "Gefilterte Benachrichtigungen",
"firehose.all": "Alle Server",
"firehose.local": "Dieser Server",
@ -515,7 +519,6 @@
"lists.exclusive": "Mitglieder auf der Startseite ausblenden",
"lists.exclusive_hint": "Profile, die sich auf dieser Liste befinden, werden nicht auf deiner Startseite angezeigt, damit deren Beiträge nicht doppelt erscheinen.",
"lists.find_users_to_add": "Suche nach Profilen, um sie hinzuzufügen",
"lists.list_members": "Listenmitglieder",
"lists.list_members_count": "{count, plural, one {# Mitglied} other {# Mitglieder}}",
"lists.list_name": "Titel der Liste",
"lists.new_list_name": "Neuer Listentitel",
@ -861,6 +864,13 @@
"status.mute_conversation": "Unterhaltung stummschalten",
"status.open": "Beitrag öffnen",
"status.pin": "Im Profil vorstellen",
"status.quote_error.filtered": "Ausgeblendet wegen eines deiner Filter",
"status.quote_error.not_found": "Dieser Beitrag kann nicht angezeigt werden.",
"status.quote_error.pending_approval": "Dieser Beitrag muss noch durch das ursprüngliche Profil genehmigt werden.",
"status.quote_error.rejected": "Dieser Beitrag kann nicht angezeigt werden, weil das ursprüngliche Profil das Zitieren nicht erlaubt.",
"status.quote_error.removed": "Dieser Beitrag wurde durch das Profil entfernt.",
"status.quote_error.unauthorized": "Dieser Beitrag kann nicht angezeigt werden, weil du zum Ansehen nicht berechtigt bist.",
"status.quote_post_author": "Beitrag von {name}",
"status.read_more": "Gesamten Beitrag anschauen",
"status.reblog": "Teilen",
"status.reblog_private": "Mit der ursprünglichen Zielgruppe teilen",

View file

@ -497,7 +497,6 @@
"lists.exclusive": "Απόκρυψη μελών από την Αρχική",
"lists.exclusive_hint": "Αν κάποιος είναι σε αυτή τη λίστα, απόκρυψέ τον στην Αρχική σου για να αποφύγεις να βλέπεις τις αναρτήσεις του δύο φορές.",
"lists.find_users_to_add": "Εύρεση χρηστών για προσθήκη",
"lists.list_members": "Λίστα μελών",
"lists.list_members_count": "{count, plural, one {# μέλος} other {# μέλη}}",
"lists.list_name": "Όνομα λίστας",
"lists.new_list_name": "Νέο όνομα λίστας",

Some files were not shown because too many files have changed in this diff Show more