Merge remote-tracking branch 'parent/main' into kbtopic-remove-quote
This commit is contained in:
commit
7c65b6f9df
464 changed files with 8217 additions and 8135 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
# ------------------
|
||||
|
|
20
.github/renovate.json5
vendored
20
.github/renovate.json5
vendored
|
@ -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,
|
||||
|
|
18
.github/workflows/test-ruby.yml
vendored
18
.github/workflows/test-ruby.yml
vendored
|
@ -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
3
.gitignore
vendored
|
@ -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
2
.nvmrc
|
@ -1 +1 @@
|
|||
22.15
|
||||
22.16
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -1 +1 @@
|
|||
3.4.3
|
||||
3.4.4
|
||||
|
|
|
@ -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
11
Gemfile
|
@ -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'
|
||||
|
|
110
Gemfile.lock
110
Gemfile.lock
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
4
app.json
4
app.json
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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, **)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
3
app/javascript/entrypoints/common.ts
Normal file
3
app/javascript/entrypoints/common.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import { start } from 'mastodon/common';
|
||||
|
||||
start();
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import './public-path';
|
||||
import ready from '../mastodon/ready';
|
||||
|
||||
ready(() => {
|
||||
|
|
|
@ -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';
|
|
@ -1,3 +0,0 @@
|
|||
import '../styles/mailer.scss';
|
||||
|
||||
require.context('../icons');
|
|
@ -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,
|
||||
);
|
|
@ -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) => {
|
||||
|
|
|
@ -8,8 +8,6 @@ and performs no other task.
|
|||
|
||||
*/
|
||||
|
||||
import './public-path';
|
||||
|
||||
import axios from 'axios';
|
||||
|
||||
interface JRDLink {
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import './public-path';
|
||||
import axios from 'axios';
|
||||
|
||||
import ready from '../mastodon/ready';
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
},
|
||||
);
|
|
@ -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]));
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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[];
|
||||
}[];
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
import Rails from '@rails/ujs';
|
||||
|
||||
export function start() {
|
||||
require.context('../images/', true, /\.(jpg|png|svg)$/);
|
||||
|
||||
try {
|
||||
Rails.start();
|
||||
} catch {
|
|
@ -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',
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
23
app/javascript/mastodon/components/avatar_group.tsx
Normal file
23
app/javascript/mastodon/components/avatar_group.tsx
Normal 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>
|
||||
);
|
|
@ -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>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
211
app/javascript/mastodon/components/featured_carousel.tsx
Normal file
211
app/javascript/mastodon/components/featured_carousel.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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 && (
|
||||
<>
|
||||
·
|
||||
<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} />
|
||||
|
|
|
@ -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,
|
||||
};
|
21
app/javascript/mastodon/components/more_from_author.tsx
Normal file
21
app/javascript/mastodon/components/more_from_author.tsx
Normal 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>
|
||||
);
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
208
app/javascript/mastodon/components/status_quoted.tsx
Normal file
208
app/javascript/mastodon/components/status_quoted.tsx
Normal 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} />;
|
||||
};
|
|
@ -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>
|
||||
|
|
|
@ -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 friend’s 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 friend’s 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 friend’s 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 friend’s accounts on your profile?'
|
||||
values={{ acct }}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
};
|
|
@ -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'
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -45,7 +45,6 @@ type EmojiCompressed = [
|
|||
Category[],
|
||||
Data['aliases'],
|
||||
EmojisWithoutShortCodes,
|
||||
Data,
|
||||
];
|
||||
|
||||
/*
|
|
@ -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,
|
||||
]),
|
||||
);
|
|
@ -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<
|
||||
|
|
|
@ -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,
|
||||
};
|
53
app/javascript/mastodon/features/emoji/emoji_picker.tsx
Normal file
53
app/javascript/mastodon/features/emoji/emoji_picker.tsx
Normal 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 };
|
|
@ -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<
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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>
|
||||
);
|
|
@ -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'>
|
||||
·
|
||||
</span>
|
||||
<RelativeTimestamp timestamp={timestamp} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
192
app/javascript/mastodon/features/reaction_deck/index.tsx
Normal file
192
app/javascript/mastodon/features/reaction_deck/index.tsx
Normal 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;
|
|
@ -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
|
||||
/>
|
||||
))}
|
||||
|
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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ə",
|
||||
|
|
|
@ -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": "Ново име на списък",
|
||||
|
|
|
@ -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ñ",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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í",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue