Merge commit 'df9e26158d9787859b24bdc276af478abf05e1af' into bark-prod
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
Dalite 2024-07-07 11:12:47 +02:00
commit 6f29aea2fa
273 changed files with 3250 additions and 2051 deletions

View file

@ -20,10 +20,6 @@ module.exports = defineConfig({
es6: true,
},
globals: {
ATTACHMENT_HOST: false,
},
parser: '@typescript-eslint/parser',
plugins: [
@ -79,7 +75,7 @@ module.exports = defineConfig({
],
},
],
'no-empty': 'off',
'no-empty': ['error', { "allowEmptyCatch": true }],
'no-restricted-properties': [
'error',
{ property: 'substring', message: 'Use .slice instead of .substring.' },
@ -94,7 +90,6 @@ module.exports = defineConfig({
message: "Use '·' (middle dot) instead of '•' (bullet)",
},
],
'no-self-assign': 'off',
'no-unused-expressions': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
@ -119,12 +114,10 @@ module.exports = defineConfig({
'react/jsx-tag-spacing': 'error',
'react/jsx-uses-react': 'off', // not needed with new JSX transform
'react/jsx-wrap-multilines': 'error',
'react/no-deprecated': 'off',
'react/react-in-jsx-scope': 'off', // not needed with new JSX transform
'react/self-closing-comp': 'error',
// recommended values found in https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/v6.8.0/src/index.js#L46
'jsx-a11y/accessible-emoji': 'warn',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/label-has-associated-control': 'off',
'jsx-a11y/media-has-caption': 'off',
@ -139,23 +132,6 @@ module.exports = defineConfig({
// ],
'jsx-a11y/no-interactive-element-to-noninteractive-role': 'off',
// recommended rule is:
// 'jsx-a11y/no-noninteractive-element-interactions': [
// 'error',
// {
// body: ['onError', 'onLoad'],
// iframe: ['onError', 'onLoad'],
// img: ['onError', 'onLoad'],
// },
// ],
'jsx-a11y/no-noninteractive-element-interactions': [
'warn',
{
handlers: [
'onClick',
],
},
],
// recommended rule is:
// 'jsx-a11y/no-noninteractive-tabindex': [
// 'error',
// {
@ -165,7 +141,6 @@ module.exports = defineConfig({
// },
// ],
'jsx-a11y/no-noninteractive-tabindex': 'off',
'jsx-a11y/no-onchange': 'off',
// recommended is full 'error'
'jsx-a11y/no-static-element-interactions': [
'warn',

View file

@ -132,15 +132,17 @@ jobs:
additional-system-dependencies: ffmpeg libpam-dev
- name: Load database schema
run: './bin/rails db:create db:schema:load db:seed'
run: |
bin/rails db:setup
bin/flatware fan bin/rails db:test:prepare
- run: bin/rspec
- run: bin/flatware rspec -r ./spec/flatware_helper.rb
- name: Upload coverage reports to Codecov
if: matrix.ruby-version == '.ruby-version'
uses: codecov/codecov-action@v4
with:
files: coverage/lcov/mastodon.lcov
files: coverage/lcov/*.lcov
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
@ -285,9 +287,13 @@ jobs:
- uses: actions/download-artifact@v4
with:
path: './public'
path: './'
name: ${{ github.sha }}
- name: Expand archived asset artifacts
run: |
tar xvzf artifacts.tar.gz
- name: Set up Ruby environment
uses: ./.github/actions/setup-ruby
with:
@ -405,7 +411,7 @@ jobs:
- uses: actions/download-artifact@v4
with:
path: './public'
path: './'
name: ${{ github.sha }}
- name: Set up Ruby environment

2
.nvmrc
View file

@ -1 +1 @@
20.14
20.15

View file

@ -1,7 +1,6 @@
---
AllCops:
CacheRootDirectory: tmp
DisplayCopNames: true
DisplayStyleGuide: true
Exclude:
- Vagrantfile
@ -10,7 +9,6 @@ AllCops:
ExtraDetails: true
NewCops: enable
TargetRubyVersion: 3.1 # Oldest supported ruby version
UseCache: true
inherit_from:
- .rubocop/layout.yml

View file

@ -1,14 +1,13 @@
---
Rails/BulkChangeTable:
Enabled: false # Conflicts with strong_migrations features
Rails/FilePath:
EnforcedStyle: arguments
Rails/HttpStatus:
EnforcedStyle: numeric
Rails/LexicallyScopedActionFilter:
Exclude:
- app/controllers/auth/* # Conflicts with `Lint/UselessMethodDefinition` for inherited controller actions
Rails/NegateInclude:
Enabled: false
@ -22,6 +21,3 @@ Rails/RakeEnvironment:
Rails/SkipsModelValidations:
Enabled: false
Rails/UnusedIgnoredColumns:
Enabled: false # Preserve ability to migrate from arbitrary old versions

View file

@ -1,6 +1,6 @@
# This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.63.5.
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.64.1.
# The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new
@ -31,14 +31,6 @@ Rails/OutputSafety:
Exclude:
- 'config/initializers/simple_form.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowedMethods, AllowedPatterns.
# AllowedMethods: ==, equal?, eql?
Style/ClassEqualityComparison:
Exclude:
- 'app/helpers/jsonld_helper.rb'
- 'app/serializers/activitypub/outbox_serializer.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: AllowedVars.
Style/FetchEnvVar:
@ -55,7 +47,6 @@ Style/FetchEnvVar:
- 'config/initializers/vapid.rb'
- 'lib/mastodon/redis_config.rb'
- 'lib/tasks/repo.rake'
- 'spec/system/profile_spec.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle, MaxUnannotatedPlaceholdersAllowed, AllowedMethods, AllowedPatterns.
@ -66,40 +57,10 @@ Style/FormatStringToken:
- 'config/initializers/devise.rb'
- 'lib/paperclip/color_extractor.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
Style/GlobalStdStream:
Exclude:
- 'config/environments/development.rb'
- 'config/environments/production.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: MinBodyLength, AllowConsecutiveConditionals.
Style/GuardClause:
Exclude:
- 'app/lib/activitypub/activity/block.rb'
- 'app/lib/request.rb'
- 'app/lib/request_pool.rb'
- 'app/lib/webfinger.rb'
- 'app/lib/webfinger_resource.rb'
- 'app/models/concerns/account/counters.rb'
- 'app/models/concerns/user/ldap_authenticable.rb'
- 'app/models/tag.rb'
- 'app/models/user.rb'
- 'app/services/fan_out_on_write_service.rb'
- 'app/services/post_status_service.rb'
- 'app/services/process_hashtags_service.rb'
- 'app/workers/move_worker.rb'
- 'app/workers/redownload_avatar_worker.rb'
- 'app/workers/redownload_header_worker.rb'
- 'app/workers/redownload_media_worker.rb'
- 'app/workers/remote_account_refresh_worker.rb'
- 'config/initializers/devise.rb'
- 'lib/devise/strategies/two_factor_ldap_authenticatable.rb'
- 'lib/devise/strategies/two_factor_pam_authenticatable.rb'
- 'lib/mastodon/cli/accounts.rb'
- 'lib/mastodon/cli/maintenance.rb'
- 'lib/mastodon/cli/media.rb'
- 'lib/tasks/repo.rake'
Enabled: false
# This cop supports unsafe autocorrection (--autocorrect-all).
Style/HashTransformValues:
@ -121,16 +82,10 @@ Style/MutableConstant:
- 'app/services/delete_account_service.rb'
- 'lib/mastodon/migration_warning.rb'
# This cop supports safe autocorrection (--autocorrect).
Style/NilLambda:
Exclude:
- 'config/initializers/paperclip.rb'
# Configuration parameters: AllowedMethods.
# AllowedMethods: respond_to_missing?
Style/OptionalBooleanParameter:
Exclude:
- 'app/helpers/admin/account_moderation_notes_helper.rb'
- 'app/helpers/jsonld_helper.rb'
- 'app/lib/admin/system_check/message.rb'
- 'app/lib/request.rb'
@ -154,13 +109,6 @@ Style/RedundantConstantBase:
- 'config/environments/production.rb'
- 'config/initializers/sidekiq.rb'
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: ConvertCodeThatCanStartToReturnNil, AllowedMethods, MaxChainLength.
# AllowedMethods: present?, blank?, presence, try, try!
Style/SafeNavigation:
Exclude:
- 'app/models/concerns/account/finder_concern.rb'
# This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: WordRegex.
# SupportedStyles: percent, brackets

View file

@ -2,6 +2,37 @@
All notable changes to this project will be documented in this file.
## [4.2.10] - 2024-07-04
### Security
- Fix incorrect permission checking on multiple API endpoints ([GHSA-58x8-3qxw-6hm7](https://github.com/mastodon/mastodon/security/advisories/GHSA-58x8-3qxw-6hm7))
- Fix incorrect authorship checking when processing some activities (CVE-2024-37903, [GHSA-xjvf-fm67-4qc3](https://github.com/mastodon/mastodon/security/advisories/GHSA-xjvf-fm67-4qc3))
- Fix ongoing streaming sessions not being invalidated when application tokens get revoked ([GHSA-vp5r-5pgw-jwqx](https://github.com/mastodon/mastodon/security/advisories/GHSA-vp5r-5pgw-jwqx))
- Update dependencies
### Added
- Add yarn version specification to avoid confusion with Yarn 3 and Yarn 4
### Changed
- Change preview cards generation to skip unusually long URLs ([oneiros](https://github.com/mastodon/mastodon/pull/30854))
- Change search modifiers to be case-insensitive ([Gargron](https://github.com/mastodon/mastodon/pull/30865))
- Change `STATSD_ADDR` handling to emit a warning rather than crashing if the address is unreachable ([timothyjrogers](https://github.com/mastodon/mastodon/pull/30691))
- Change PWA start URL from `/home` to `/` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/27377))
### Removed
- Removed dependency on `posix-spawn` ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/18559))
### Fixed
- Fix scheduled statuses scheduled in less than 5 minutes being immediately published ([danielmbrasil](https://github.com/mastodon/mastodon/pull/30584))
- Fix encoding detection for link cards ([oneiros](https://github.com/mastodon/mastodon/pull/30780))
- Fix `/admin/accounts/:account_id/statuses/:id` for edited posts with media attachments ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30819))
- Fix duplicate `@context` attribute in user archive export ([ClearlyClaire](https://github.com/mastodon/mastodon/pull/30653))
## [4.2.9] - 2024-05-30
### Security

View file

@ -19,9 +19,9 @@ ARG NODE_MAJOR_VERSION="20"
# Debian image to use for base image, change with [--build-arg DEBIAN_VERSION="bookworm"]
ARG DEBIAN_VERSION="bookworm"
# Node image to use for base image based on combined variables (ex: 20-bookworm-slim)
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim as node
FROM docker.io/node:${NODE_MAJOR_VERSION}-${DEBIAN_VERSION}-slim AS node
# Ruby image to use for base image based on combined variables (ex: 3.3.x-slim-bookworm)
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} as ruby
FROM docker.io/ruby:${RUBY_VERSION}-slim-${DEBIAN_VERSION} AS ruby
# Resulting version string is vX.X.X-MASTODON_VERSION_PRERELEASE+MASTODON_VERSION_METADATA
# Example: v4.3.0-nightly.2023.11.09+pr-123456
@ -117,7 +117,7 @@ RUN \
;
# Create temporary build layer from base image
FROM ruby as build
FROM ruby AS build
# Copy Node package configuration files into working directory
COPY package.json yarn.lock .yarnrc.yml /opt/mastodon/
@ -185,7 +185,7 @@ RUN \
corepack prepare --activate;
# Create temporary libvips specific build layer from build layer
FROM build as libvips
FROM build AS libvips
# libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"]
# renovate: datasource=github-releases depName=libvips packageName=libvips/libvips
@ -205,7 +205,7 @@ RUN \
ninja install;
# Create temporary ffmpeg specific build layer from build layer
FROM build as ffmpeg
FROM build AS ffmpeg
# ffmpeg version to compile, change with [--build-arg FFMPEG_VERSION="7.0.x"]
# renovate: datasource=repology depName=ffmpeg packageName=openpkg_current/ffmpeg
@ -247,7 +247,7 @@ RUN \
make install;
# Create temporary bundler specific build layer from build layer
FROM build as bundler
FROM build AS bundler
ARG TARGETPLATFORM
@ -269,7 +269,7 @@ RUN \
bundle install -j"$(nproc)";
# Create temporary node specific build layer from build layer
FROM build as yarn
FROM build AS yarn
ARG TARGETPLATFORM
@ -286,7 +286,7 @@ RUN \
yarn workspaces focus --production @mastodon/mastodon;
# Create temporary assets build layer from build layer
FROM build as precompiler
FROM build AS precompiler
# Copy Mastodon sources into precompiler layer
COPY . /opt/mastodon/
@ -310,7 +310,7 @@ RUN \
rm -fr /opt/mastodon/tmp;
# Prep final Mastodon Ruby layer
FROM ruby as mastodon
FROM ruby AS mastodon
ARG TARGETPLATFORM

13
Gemfile
View file

@ -25,7 +25,7 @@ gem 'ruby-vips', '~> 2.2', require: false
gem 'active_model_serializers', '~> 0.10'
gem 'addressable', '~> 2.8'
gem 'bootsnap', '~> 1.18.0', require: false
gem 'browser'
gem 'browser', '< 6' # https://github.com/fnando/browser/issues/543
gem 'charlock_holmes', '~> 0.7.7'
gem 'chewy', '~> 7.3'
gem 'devise', '~> 4.9'
@ -69,7 +69,7 @@ gem 'oj', '~> 3.14'
gem 'ox', '~> 2.14'
gem 'parslet'
gem 'premailer-rails'
gem 'public_suffix', '~> 5.0'
gem 'public_suffix', '~> 6.0'
gem 'pundit', '~> 2.3'
gem 'rack-attack', '~> 6.6'
gem 'rack-cors', '~> 2.0', require: 'rack/cors'
@ -100,12 +100,10 @@ gem 'json-ld'
gem 'json-ld-preloaded', '~> 3.2'
gem 'rdf-normalize', '~> 0.5'
gem 'private_address_check', '~> 0.5'
gem 'opentelemetry-api', '~> 1.2.5'
group :opentelemetry do
gem 'opentelemetry-exporter-otlp', '~> 0.27.0', require: false
gem 'opentelemetry-exporter-otlp', '~> 0.28.0', require: false
gem 'opentelemetry-instrumentation-active_job', '~> 0.7.1', require: false
gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.20.1', require: false
gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.21.2', require: false
@ -116,13 +114,16 @@ group :opentelemetry do
gem 'opentelemetry-instrumentation-net_http', '~> 0.22.4', require: false
gem 'opentelemetry-instrumentation-pg', '~> 0.27.1', require: false
gem 'opentelemetry-instrumentation-rack', '~> 0.24.1', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.30.0', require: false
gem 'opentelemetry-instrumentation-rails', '~> 0.31.0', require: false
gem 'opentelemetry-instrumentation-redis', '~> 0.25.3', require: false
gem 'opentelemetry-instrumentation-sidekiq', '~> 0.25.2', require: false
gem 'opentelemetry-sdk', '~> 1.4', require: false
end
group :test do
# Enable usage of all available CPUs/cores during spec runs
gem 'flatware-rspec'
# Adds RSpec Error/Warning annotations to GitHub PRs on the Files tab
gem 'rspec-github', '~> 2.4', require: false

View file

@ -89,8 +89,8 @@ GEM
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
android_key_attestation (0.3.0)
annotate (3.2.0)
@ -100,19 +100,19 @@ GEM
attr_required (1.0.2)
awrence (1.2.1)
aws-eventstream (1.3.0)
aws-partitions (1.940.0)
aws-sdk-core (3.197.0)
aws-partitions (1.950.0)
aws-sdk-core (3.201.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.8)
jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.83.0)
aws-sdk-core (~> 3, >= 3.197.0)
aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.152.3)
aws-sdk-core (~> 3, >= 3.197.0)
aws-sdk-kms (1.88.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.156.0)
aws-sdk-core (~> 3, >= 3.201.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.8)
aws-sigv4 (~> 1.5)
aws-sigv4 (1.8.0)
aws-eventstream (~> 1, >= 1.0.2)
azure-storage-blob (2.0.3)
@ -143,7 +143,7 @@ GEM
brpoplpush-redis_script (0.1.3)
concurrent-ruby (~> 1.0, >= 1.0.5)
redis (>= 1.0, < 6)
builder (3.2.4)
builder (3.3.0)
bundler-audit (0.9.1)
bundler (>= 1.2.0, < 3)
thor (~> 1.0)
@ -159,7 +159,7 @@ GEM
case_transform (0.2)
activesupport
cbor (0.5.9.8)
charlock_holmes (0.7.7)
charlock_holmes (0.7.8)
chewy (7.6.0)
activesupport (>= 5.2)
elasticsearch (>= 7.14.0, < 8)
@ -195,7 +195,7 @@ GEM
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-two-factor (5.0.0)
devise-two-factor (5.1.0)
activesupport (~> 7.0)
devise (~> 4.0)
railties (~> 7.0)
@ -208,7 +208,7 @@ GEM
activerecord (>= 4.2, < 8)
docile (1.4.0)
domain_name (0.6.20240107)
doorkeeper (5.6.9)
doorkeeper (5.7.1)
railties (>= 5)
dotenv (3.1.2)
drb (2.2.1)
@ -226,7 +226,7 @@ GEM
htmlentities (~> 4.3.3)
launchy (~> 2.1)
mail (~> 2.7)
erubi (1.12.0)
erubi (1.13.0)
et-orbi (1.2.11)
tzinfo
excon (0.110.0)
@ -264,6 +264,11 @@ GEM
ffi-compiler (1.3.2)
ffi (>= 1.15.5)
rake
flatware (2.3.2)
thor (< 2.0)
flatware-rspec (2.3.2)
flatware (= 2.3.2)
rspec (>= 3.6)
fog-core (2.4.0)
builder
excon (~> 0.71)
@ -398,6 +403,7 @@ GEM
llhttp-ffi (0.5.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.6.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@ -425,7 +431,7 @@ GEM
mime-types-data (3.2024.0604)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.23.1)
minitest (5.24.1)
msgpack (1.7.2)
multi_json (1.15.0)
multipart-post (2.4.0)
@ -489,8 +495,8 @@ GEM
opentelemetry-api (1.2.5)
opentelemetry-common (0.20.1)
opentelemetry-api (~> 1.0)
opentelemetry-exporter-otlp (0.27.0)
google-protobuf (~> 3.14)
opentelemetry-exporter-otlp (0.28.0)
google-protobuf (>= 3.18)
googleapis-common-protos-types (~> 1.3)
opentelemetry-api (~> 1.1)
opentelemetry-common (~> 0.20)
@ -510,7 +516,7 @@ GEM
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-active_support (~> 0.1)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_job (0.7.1)
opentelemetry-instrumentation-active_job (0.7.2)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_model_serializers (0.20.1)
@ -519,7 +525,7 @@ GEM
opentelemetry-instrumentation-active_record (0.7.2)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-active_support (0.5.1)
opentelemetry-instrumentation-active_support (0.6.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-base (0.22.3)
@ -528,49 +534,42 @@ GEM
opentelemetry-instrumentation-concurrent_ruby (0.21.3)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-excon (0.22.1)
opentelemetry-instrumentation-excon (0.22.3)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.20.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-faraday (0.24.2)
opentelemetry-instrumentation-faraday (0.24.5)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.20.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-http (0.23.3)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-http_client (0.22.4)
opentelemetry-instrumentation-http_client (0.22.6)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.20.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-net_http (0.22.4)
opentelemetry-instrumentation-net_http (0.22.6)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.20.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-pg (0.27.3)
opentelemetry-api (~> 1.0)
opentelemetry-helpers-sql-obfuscation
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rack (0.24.3)
opentelemetry-instrumentation-rack (0.24.5)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.20.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-rails (0.30.2)
opentelemetry-instrumentation-rails (0.31.0)
opentelemetry-api (~> 1.0)
opentelemetry-instrumentation-action_mailer (~> 0.1.0)
opentelemetry-instrumentation-action_pack (~> 0.9.0)
opentelemetry-instrumentation-action_view (~> 0.7.0)
opentelemetry-instrumentation-active_job (~> 0.7.0)
opentelemetry-instrumentation-active_record (~> 0.7.0)
opentelemetry-instrumentation-active_support (~> 0.5.0)
opentelemetry-instrumentation-active_support (~> 0.6.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-redis (0.25.4)
opentelemetry-instrumentation-redis (0.25.6)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.20.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-instrumentation-sidekiq (0.25.3)
opentelemetry-instrumentation-sidekiq (0.25.6)
opentelemetry-api (~> 1.0)
opentelemetry-common (~> 0.20.0)
opentelemetry-instrumentation-base (~> 0.22.1)
opentelemetry-registry (0.3.1)
opentelemetry-api (~> 1.1)
@ -601,7 +600,6 @@ GEM
actionmailer (>= 3)
net-smtp
premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0)
propshaft (0.9.0)
actionpack (>= 7.0.0)
activesupport (>= 7.0.0)
@ -609,7 +607,7 @@ GEM
railties (>= 7.0.0)
psych (5.1.2)
stringio
public_suffix (5.1.1)
public_suffix (6.0.0)
puma (6.4.2)
nio4r (~> 2.0)
pundit (2.3.2)
@ -682,7 +680,7 @@ GEM
link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.7.0)
rdf (~> 3.3)
rdoc (6.6.3.1)
rdoc (6.7.0)
psych (>= 4.0.0)
redcarpet (3.6.0)
redis (4.8.1)
@ -698,7 +696,7 @@ GEM
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
rexml (3.3.0)
rexml (3.3.1)
strscan
rotp (6.3.0)
rouge (4.2.1)
@ -707,9 +705,13 @@ GEM
chunky_png (~> 1.0)
rqrcode_core (~> 1.0)
rqrcode_core (1.2.0)
rspec (3.13.0)
rspec-core (~> 3.13.0)
rspec-expectations (~> 3.13.0)
rspec-mocks (~> 3.13.0)
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.0)
rspec-expectations (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-github (2.4.0)
@ -717,7 +719,7 @@ GEM
rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (6.1.2)
rspec-rails (6.1.3)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
@ -749,12 +751,12 @@ GEM
rubocop-performance (1.21.1)
rubocop (>= 1.48.1, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rails (2.25.0)
rubocop-rails (2.25.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-ast (>= 1.31.1, < 2.0)
rubocop-rspec (3.0.1)
rubocop-rspec (3.0.2)
rubocop (~> 1.61)
rubocop-rspec_rails (2.30.0)
rubocop (~> 1.61)
@ -778,8 +780,9 @@ GEM
scenic (1.8.0)
activerecord (>= 4.0.0)
railties (>= 4.0.0)
selenium-webdriver (4.21.1)
selenium-webdriver (4.22.0)
base64 (~> 0.2)
logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
@ -830,7 +833,7 @@ GEM
unicode-display_width (>= 1.1.1, < 3)
terrapin (1.0.1)
climate_control
test-prof (1.3.3)
test-prof (1.3.3.1)
thor (1.3.1)
tilt (2.3.0)
timeout (0.4.1)
@ -898,7 +901,7 @@ GEM
xorcist (1.1.3)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.15)
zeitwerk (2.6.16)
PLATFORMS
ruby
@ -913,7 +916,7 @@ DEPENDENCIES
blurhash (~> 0.1)
bootsnap (~> 1.18.0)
brakeman (~> 6.0)
browser
browser (< 6)
bundler-audit (~> 0.9)
capybara (~> 3.39)
charlock_holmes (~> 0.7.7)
@ -938,6 +941,7 @@ DEPENDENCIES
faker (~> 3.2)
fast_blank (~> 1.0)
fastimage
flatware-rspec
fog-core (<= 2.4.0)
fog-openstack (~> 1.0)
fuubar (~> 2.5)
@ -979,7 +983,7 @@ DEPENDENCIES
omniauth-saml (~> 2.0)
omniauth_openid_connect (~> 0.6.1)
opentelemetry-api (~> 1.2.5)
opentelemetry-exporter-otlp (~> 0.27.0)
opentelemetry-exporter-otlp (~> 0.28.0)
opentelemetry-instrumentation-active_job (~> 0.7.1)
opentelemetry-instrumentation-active_model_serializers (~> 0.20.1)
opentelemetry-instrumentation-concurrent_ruby (~> 0.21.2)
@ -990,7 +994,7 @@ DEPENDENCIES
opentelemetry-instrumentation-net_http (~> 0.22.4)
opentelemetry-instrumentation-pg (~> 0.27.1)
opentelemetry-instrumentation-rack (~> 0.24.1)
opentelemetry-instrumentation-rails (~> 0.30.0)
opentelemetry-instrumentation-rails (~> 0.31.0)
opentelemetry-instrumentation-redis (~> 0.25.3)
opentelemetry-instrumentation-sidekiq (~> 0.25.2)
opentelemetry-sdk (~> 1.4)
@ -999,9 +1003,8 @@ DEPENDENCIES
pg (~> 1.5)
pghero
premailer-rails
private_address_check (~> 0.5)
propshaft
public_suffix (~> 5.0)
public_suffix (~> 6.0)
puma (~> 6.3)
pundit (~> 2.3)
rack (~> 2.2.7)

View file

@ -6,6 +6,7 @@ class Api::V1::ScheduledStatusesController < Api::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, except: [:update, :destroy]
before_action -> { doorkeeper_authorize! :write, :'write:statuses' }, only: [:update, :destroy]
before_action :require_user!
before_action :set_statuses, only: :index
before_action :set_status, except: :index

View file

@ -2,6 +2,7 @@
class Api::V1::Statuses::TranslationsController < Api::V1::Statuses::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }
before_action :require_user!
before_action :set_translation
rescue_from TranslationService::NotConfiguredError, with: :not_found

View file

@ -3,8 +3,14 @@
class Api::V1::Timelines::BaseController < Api::BaseController
after_action :insert_pagination_headers, unless: -> { @statuses.empty? }
before_action :require_user!, if: :require_auth?
private
def require_auth?
!Setting.timeline_preview
end
def pagination_collection
@statuses
end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :set_preview_card
before_action :set_statuses
@ -17,10 +17,6 @@ class Api::V1::Timelines::LinkController < Api::V1::Timelines::BaseController
private
def require_auth?
!Setting.timeline_preview
end
def set_preview_card
@preview_card = PreviewCard.joins(:trend).merge(PreviewCardTrend.allowed).find_by!(url: params[:url])
end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
before_action :require_user!, only: [:show], if: :require_auth?
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
PERMITTED_PARAMS = %i(local remote limit only_media).freeze
@ -13,10 +13,6 @@ class Api::V1::Timelines::PublicController < Api::V1::Timelines::BaseController
private
def require_auth?
!Setting.timeline_preview
end
def load_statuses
preloaded_public_statuses_page
end

View file

@ -1,7 +1,7 @@
# frozen_string_literal: true
class Api::V1::Timelines::TagController < Api::V1::Timelines::BaseController
before_action -> { doorkeeper_authorize! :read, :'read:statuses' }, only: :show, if: :require_auth?
before_action -> { authorize_if_got_token! :read, :'read:statuses' }
before_action :load_tag
PERMITTED_PARAMS = %i(local limit only_media).freeze

View file

@ -15,7 +15,7 @@ class Api::V2Alpha::NotificationsController < Api::BaseController
@relationships = StatusRelationshipsPresenter.new(target_statuses_from_notifications, current_user&.account_id)
end
render json: @notifications.map { |notification| NotificationGroup.from_notification(notification) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
render json: @notifications.map { |notification| NotificationGroup.from_notification(notification, max_id: @group_metadata.dig(notification.group_key, :max_id)) }, each_serializer: REST::NotificationGroupSerializer, relationships: @relationships, group_metadata: @group_metadata
end
def show

View file

@ -25,6 +25,14 @@ class Auth::RegistrationsController < Devise::RegistrationsController
super(&:build_invite_request)
end
def edit # rubocop:disable Lint/UselessMethodDefinition
super
end
def create # rubocop:disable Lint/UselessMethodDefinition
super
end
def update
super do |resource|
resource.clear_other_sessions(current_session.session_id) if resource.saved_change_to_encrypted_password?

View file

@ -17,6 +17,7 @@ class Oauth::AuthorizedApplicationsController < Doorkeeper::AuthorizedApplicatio
def destroy
Web::PushSubscription.unsubscribe_for(params[:id], current_resource_owner)
Doorkeeper::Application.find_by(id: params[:id])&.close_streaming_sessions(current_resource_owner)
super
end

View file

@ -141,7 +141,7 @@ module JsonLdHelper
def safe_for_forwarding?(original, compacted)
original.without('@context', 'signature').all? do |key, value|
compacted_value = compacted[key]
return false unless value.class == compacted_value.class
return false unless value.instance_of?(compacted_value.class)
if value.is_a?(Hash)
safe_for_forwarding?(value, compacted_value)

View file

@ -0,0 +1,61 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { openURL } from 'mastodon/actions/search';
import { useAppDispatch } from 'mastodon/store';
const isMentionClick = (element: HTMLAnchorElement) =>
element.classList.contains('mention');
const isHashtagClick = (element: HTMLAnchorElement) =>
element.textContent?.[0] === '#' ||
element.previousSibling?.textContent?.endsWith('#');
export const useLinks = () => {
const history = useHistory();
const dispatch = useAppDispatch();
const handleHashtagClick = useCallback(
(element: HTMLAnchorElement) => {
const { textContent } = element;
if (!textContent) return;
history.push(`/tags/${textContent.replace(/^#/, '')}`);
},
[history],
);
const handleMentionClick = useCallback(
(element: HTMLAnchorElement) => {
dispatch(
openURL(element.href, history, () => {
window.location.href = element.href;
}),
);
},
[dispatch, history],
);
const handleClick = useCallback(
(e: React.MouseEvent) => {
const target = (e.target as HTMLElement).closest('a');
if (!target || e.button !== 0 || e.ctrlKey || e.metaKey) {
return;
}
if (isMentionClick(target)) {
e.preventDefault();
handleMentionClick(target);
} else if (isHashtagClick(target)) {
e.preventDefault();
handleHashtagClick(target);
}
},
[handleMentionClick, handleHashtagClick],
);
return handleClick;
};

View file

@ -0,0 +1,44 @@
import { useRef, useCallback, useEffect } from 'react';
export const useTimeout = () => {
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const callbackRef = useRef<() => void>();
const set = useCallback((callback: () => void, delay: number) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
callbackRef.current = callback;
timeoutRef.current = setTimeout(callback, delay);
}, []);
const delay = useCallback((delay: number) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (!callbackRef.current) {
return;
}
timeoutRef.current = setTimeout(callbackRef.current, delay);
}, []);
const cancel = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = undefined;
callbackRef.current = undefined;
}
}, []);
useEffect(
() => () => {
cancel();
},
[cancel],
);
return [set, cancel, delay] as const;
};

View file

@ -1,62 +0,0 @@
import api from '../api';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const DIRECTORY_FETCH_REQUEST = 'DIRECTORY_FETCH_REQUEST';
export const DIRECTORY_FETCH_SUCCESS = 'DIRECTORY_FETCH_SUCCESS';
export const DIRECTORY_FETCH_FAIL = 'DIRECTORY_FETCH_FAIL';
export const DIRECTORY_EXPAND_REQUEST = 'DIRECTORY_EXPAND_REQUEST';
export const DIRECTORY_EXPAND_SUCCESS = 'DIRECTORY_EXPAND_SUCCESS';
export const DIRECTORY_EXPAND_FAIL = 'DIRECTORY_EXPAND_FAIL';
export const fetchDirectory = params => (dispatch) => {
dispatch(fetchDirectoryRequest());
api().get('/api/v1/directory', { params: { ...params, limit: 20 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchDirectorySuccess(data));
dispatch(fetchRelationships(data.map(x => x.id)));
}).catch(error => dispatch(fetchDirectoryFail(error)));
};
export const fetchDirectoryRequest = () => ({
type: DIRECTORY_FETCH_REQUEST,
});
export const fetchDirectorySuccess = accounts => ({
type: DIRECTORY_FETCH_SUCCESS,
accounts,
});
export const fetchDirectoryFail = error => ({
type: DIRECTORY_FETCH_FAIL,
error,
});
export const expandDirectory = params => (dispatch, getState) => {
dispatch(expandDirectoryRequest());
const loadedItems = getState().getIn(['user_lists', 'directory', 'items']).size;
api().get('/api/v1/directory', { params: { ...params, offset: loadedItems, limit: 20 } }).then(({ data }) => {
dispatch(importFetchedAccounts(data));
dispatch(expandDirectorySuccess(data));
dispatch(fetchRelationships(data.map(x => x.id)));
}).catch(error => dispatch(expandDirectoryFail(error)));
};
export const expandDirectoryRequest = () => ({
type: DIRECTORY_EXPAND_REQUEST,
});
export const expandDirectorySuccess = accounts => ({
type: DIRECTORY_EXPAND_SUCCESS,
accounts,
});
export const expandDirectoryFail = error => ({
type: DIRECTORY_EXPAND_FAIL,
error,
});

View file

@ -0,0 +1,37 @@
import type { List as ImmutableList } from 'immutable';
import { apiGetDirectory } from 'mastodon/api/directory';
import { createDataLoadingThunk } from 'mastodon/store/typed_functions';
import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer';
export const fetchDirectory = createDataLoadingThunk(
'directory/fetch',
async (params: Parameters<typeof apiGetDirectory>[0]) =>
apiGetDirectory(params),
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchRelationships(data.map((x) => x.id)));
return { accounts: data };
},
);
export const expandDirectory = createDataLoadingThunk(
'directory/expand',
async (params: Parameters<typeof apiGetDirectory>[0], { getState }) => {
const loadedItems = getState().user_lists.getIn([
'directory',
'items',
]) as ImmutableList<unknown>;
return apiGetDirectory({ ...params, offset: loadedItems.size }, 20);
},
(data, { dispatch }) => {
dispatch(importFetchedAccounts(data));
dispatch(fetchRelationships(data.map((x) => x.id)));
return { accounts: data };
},
);

View file

@ -76,8 +76,8 @@ export function importFetchedStatuses(statuses) {
pushUnique(polls, normalizePoll(status.poll, getState().getIn(['polls', status.poll.id])));
}
if (status.card?.author_account) {
pushUnique(accounts, status.card.author_account);
if (status.card) {
status.card.authors.forEach(author => author.account && pushUnique(accounts, author.account));
}
}

View file

@ -36,8 +36,15 @@ export function normalizeStatus(status, normalOldStatus) {
normalStatus.poll = status.poll.id;
}
if (status.card?.author_account) {
normalStatus.card = { ...status.card, author_account: status.card.author_account.id };
if (status.card) {
normalStatus.card = {
...status.card,
authors: status.card.authors.map(author => ({
...author,
accountId: author.account?.id,
account: undefined,
})),
};
}
if (status.filtered) {

View file

@ -77,7 +77,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
},
onDisconnect() {
dispatch(disconnectTimeline(timelineId));
dispatch(disconnectTimeline({ timeline: timelineId }));
if (options.fallback) {
// @ts-expect-error

View file

@ -6,9 +6,11 @@ import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
import { importFetchedStatus, importFetchedStatuses } from './importer';
import { submitMarkers } from './markers';
import {timelineDelete} from './timelines_typed';
export { disconnectTimeline } from './timelines_typed';
export const TIMELINE_UPDATE = 'TIMELINE_UPDATE';
export const TIMELINE_DELETE = 'TIMELINE_DELETE';
export const TIMELINE_CLEAR = 'TIMELINE_CLEAR';
export const TIMELINE_EXPAND_REQUEST = 'TIMELINE_EXPAND_REQUEST';
@ -17,7 +19,6 @@ export const TIMELINE_EXPAND_FAIL = 'TIMELINE_EXPAND_FAIL';
export const TIMELINE_SCROLL_TOP = 'TIMELINE_SCROLL_TOP';
export const TIMELINE_LOAD_PENDING = 'TIMELINE_LOAD_PENDING';
export const TIMELINE_DISCONNECT = 'TIMELINE_DISCONNECT';
export const TIMELINE_CONNECT = 'TIMELINE_CONNECT';
export const TIMELINE_MARK_AS_PARTIAL = 'TIMELINE_MARK_AS_PARTIAL';
@ -62,16 +63,10 @@ export function updateTimeline(timeline, status, accept) {
export function deleteFromTimelines(id) {
return (dispatch, getState) => {
const accountId = getState().getIn(['statuses', id, 'account']);
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id'));
const references = getState().get('statuses').filter(status => status.get('reblog') === id).map(status => status.get('id')).valueSeq().toJSON();
const reblogOf = getState().getIn(['statuses', id, 'reblog'], null);
dispatch({
type: TIMELINE_DELETE,
id,
accountId,
references,
reblogOf,
});
dispatch(timelineDelete({ statusId: id, accountId, references, reblogOf }));
};
}
@ -163,6 +158,7 @@ export const expandAccountTimeline = (accountId, { maxId, withReplies, t
export const expandAccountFeaturedTimeline = (accountId, { tagged } = {}) => expandTimeline(`account:${accountId}:pinned${tagged ? `:${tagged}` : ''}`, `/api/v1/accounts/${accountId}/statuses`, { pinned: true, tagged });
export const expandAccountMediaTimeline = (accountId, { maxId } = {}) => expandTimeline(`account:${accountId}:media`, `/api/v1/accounts/${accountId}/statuses`, { max_id: maxId, only_media: true, limit: 40 });
export const expandListTimeline = (id, { maxId } = {}, done = noOp) => expandTimeline(`list:${id}`, `/api/v1/timelines/list/${id}`, { max_id: maxId }, done);
export const expandLinkTimeline = (url, { maxId } = {}, done = noOp) => expandTimeline(`link:${url}`, `/api/v1/timelines/link`, { url, max_id: maxId }, done);
export const expandHashtagTimeline = (hashtag, { maxId, tags, local } = {}, done = noOp) => {
return expandTimeline(`hashtag:${hashtag}${local ? ':local' : ''}`, `/api/v1/timelines/tag/${hashtag}`, {
max_id: maxId,
@ -225,12 +221,6 @@ export function connectTimeline(timeline) {
};
}
export const disconnectTimeline = timeline => ({
type: TIMELINE_DISCONNECT,
timeline,
usePendingItems: preferPendingItems,
});
export const markAsPartial = timeline => ({
type: TIMELINE_MARK_AS_PARTIAL,
timeline,

View file

@ -0,0 +1,20 @@
import { createAction } from '@reduxjs/toolkit';
import { usePendingItems as preferPendingItems } from 'mastodon/initial_state';
export const disconnectTimeline = createAction(
'timeline/disconnect',
({ timeline }: { timeline: string }) => ({
payload: {
timeline,
usePendingItems: preferPendingItems,
},
}),
);
export const timelineDelete = createAction<{
statusId: string;
accountId: string;
references: string[];
reblogOf: string | null;
}>('timelines/delete');

View file

@ -51,7 +51,7 @@ export const fetchTrendingLinks = () => (dispatch) => {
api()
.get('/api/v1/trends/links', { params: { limit: 20 } })
.then(({ data }) => {
dispatch(importFetchedAccounts(data.map(link => link.author_account).filter(account => !!account)));
dispatch(importFetchedAccounts(data.flatMap(link => link.authors.map(author => author.account)).filter(account => !!account)));
dispatch(fetchTrendingLinksSuccess(data));
})
.catch(err => dispatch(fetchTrendingLinksFail(err)));

View file

@ -59,16 +59,49 @@ export default function api(withAuthorization = true) {
});
}
type RequestParamsOrData = Record<string, unknown>;
export async function apiRequest<ApiResponse = unknown>(
method: Method,
url: string,
params?: Record<string, unknown>,
args: {
params?: RequestParamsOrData;
data?: RequestParamsOrData;
} = {},
) {
const { data } = await api().request<ApiResponse>({
method,
url: '/api/' + url,
data: params,
...args,
});
return data;
}
export async function apiRequestGet<ApiResponse = unknown>(
url: string,
params?: RequestParamsOrData,
) {
return apiRequest<ApiResponse>('GET', url, { params });
}
export async function apiRequestPost<ApiResponse = unknown>(
url: string,
data?: RequestParamsOrData,
) {
return apiRequest<ApiResponse>('POST', url, { data });
}
export async function apiRequestPut<ApiResponse = unknown>(
url: string,
data?: RequestParamsOrData,
) {
return apiRequest<ApiResponse>('PUT', url, { data });
}
export async function apiRequestDelete<ApiResponse = unknown>(
url: string,
params?: RequestParamsOrData,
) {
return apiRequest<ApiResponse>('DELETE', url, { params });
}

View file

@ -1,7 +1,7 @@
import { apiRequest } from 'mastodon/api';
import { apiRequestPost } from 'mastodon/api';
import type { ApiRelationshipJSON } from 'mastodon/api_types/relationships';
export const apiSubmitAccountNote = (id: string, value: string) =>
apiRequest<ApiRelationshipJSON>('post', `v1/accounts/${id}/note`, {
apiRequestPost<ApiRelationshipJSON>(`v1/accounts/${id}/note`, {
comment: value,
});

View file

@ -0,0 +1,15 @@
import { apiRequestGet } from 'mastodon/api';
import type { ApiAccountJSON } from 'mastodon/api_types/accounts';
export const apiGetDirectory = (
params: {
order: string;
local: boolean;
offset?: number;
},
limit = 20,
) =>
apiRequestGet<ApiAccountJSON[]>('v1/directory', {
...params,
limit,
});

View file

@ -1,10 +1,10 @@
import { apiRequest } from 'mastodon/api';
import { apiRequestPost } from 'mastodon/api';
import type { Status, StatusVisibility } from 'mastodon/models/status';
export const apiReblog = (statusId: string, visibility: StatusVisibility) =>
apiRequest<{ reblog: Status }>('post', `v1/statuses/${statusId}/reblog`, {
apiRequestPost<{ reblog: Status }>(`v1/statuses/${statusId}/reblog`, {
visibility,
});
export const apiUnreblog = (statusId: string) =>
apiRequest<Status>('post', `v1/statuses/${statusId}/unreblog`);
apiRequestPost<Status>(`v1/statuses/${statusId}/unreblog`);

View file

@ -1,10 +1,9 @@
import { apiRequest } from 'mastodon/api';
import { apiRequestGet, apiRequestPut } from 'mastodon/api';
import type { NotificationPolicyJSON } from 'mastodon/api_types/notification_policies';
export const apiGetNotificationPolicy = () =>
apiRequest<NotificationPolicyJSON>('GET', '/v1/notifications/policy');
apiRequestGet<NotificationPolicyJSON>('/v1/notifications/policy');
export const apiUpdateNotificationsPolicy = (
policy: Partial<NotificationPolicyJSON>,
) =>
apiRequest<NotificationPolicyJSON>('PUT', '/v1/notifications/policy', policy);
) => apiRequestPut<NotificationPolicyJSON>('/v1/notifications/policy', policy);

View file

@ -30,6 +30,12 @@ export interface ApiMentionJSON {
acct: string;
}
export interface ApiPreviewCardAuthorJSON {
name: string;
url: string;
account?: ApiAccountJSON;
}
export interface ApiPreviewCardJSON {
url: string;
title: string;
@ -38,6 +44,7 @@ export interface ApiPreviewCardJSON {
type: string;
author_name: string;
author_url: string;
author_account?: ApiAccountJSON;
provider_name: string;
provider_url: string;
html: string;
@ -48,6 +55,7 @@ export interface ApiPreviewCardJSON {
embed_url: string;
blurhash: string;
published_at: string;
authors: ApiPreviewCardAuthorJSON[];
}
export interface ApiStatusJSON {

View file

@ -0,0 +1,20 @@
import { useLinks } from 'mastodon/../hooks/useLinks';
export const AccountBio: React.FC<{
note: string;
className: string;
}> = ({ note, className }) => {
const handleClick = useLinks();
if (note.length === 0 || note === '<p></p>') {
return null;
}
return (
<div
className={`${className} translate`}
dangerouslySetInnerHTML={{ __html: note }}
onClickCapture={handleClick}
/>
);
};

View file

@ -0,0 +1,42 @@
import classNames from 'classnames';
import CheckIcon from '@/material-icons/400-24px/check.svg?react';
import { useLinks } from 'mastodon/../hooks/useLinks';
import { Icon } from 'mastodon/components/icon';
import type { Account } from 'mastodon/models/account';
export const AccountFields: React.FC<{
fields: Account['fields'];
limit: number;
}> = ({ fields, limit = -1 }) => {
const handleClick = useLinks();
if (fields.size === 0) {
return null;
}
return (
<div className='account-fields' onClickCapture={handleClick}>
{fields.take(limit).map((pair, i) => (
<dl
key={i}
className={classNames({ verified: pair.get('verified_at') })}
>
<dt
dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }}
className='translate'
/>
<dd className='translate' title={pair.get('value_plain') ?? ''}>
{pair.get('verified_at') && (
<Icon id='check' icon={CheckIcon} className='verified__mark' />
)}
<span
dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }}
/>
</dd>
</dl>
))}
</div>
);
};

View file

@ -1,233 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent, useCallback } from 'react';
import { FormattedMessage, injectIntl, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
import { useAppHistory } from './router';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: { id: 'column_header.moveLeft_settings', defaultMessage: 'Move column to the left' },
moveRight: { id: 'column_header.moveRight_settings', defaultMessage: 'Move column to the right' },
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
const BackButton = ({ onlyIcon }) => {
const history = useAppHistory();
const intl = useIntl();
const handleBackClick = useCallback(() => {
if (history.location?.state?.fromMastodon) {
history.goBack();
} else {
history.push('/');
}
}, [history]);
return (
<button onClick={handleBackClick} className={classNames('column-header__back-button', { 'compact': onlyIcon })} aria-label={intl.formatMessage(messages.back)}>
<Icon id='chevron-left' icon={ArrowBackIcon} className='column-back-button__icon' />
{!onlyIcon && <FormattedMessage id='column_back_button.label' defaultMessage='Back' />}
</button>
);
};
BackButton.propTypes = {
onlyIcon: PropTypes.bool,
};
class ColumnHeader extends PureComponent {
static propTypes = {
identity: identityContextPropShape,
intl: PropTypes.object.isRequired,
title: PropTypes.node,
icon: PropTypes.string,
iconComponent: PropTypes.func,
active: PropTypes.bool,
multiColumn: PropTypes.bool,
extraButton: PropTypes.node,
showBackButton: PropTypes.bool,
children: PropTypes.node,
pinned: PropTypes.bool,
placeholder: PropTypes.bool,
onPin: PropTypes.func,
onMove: PropTypes.func,
onClick: PropTypes.func,
appendContent: PropTypes.node,
collapseIssues: PropTypes.bool,
...WithRouterPropTypes,
};
state = {
collapsed: true,
animating: false,
};
handleToggleClick = (e) => {
e.stopPropagation();
this.setState({ collapsed: !this.state.collapsed, animating: true });
};
handleTitleClick = () => {
this.props.onClick?.();
};
handleMoveLeft = () => {
this.props.onMove(-1);
};
handleMoveRight = () => {
this.props.onMove(1);
};
handleTransitionEnd = () => {
this.setState({ animating: false });
};
handlePin = () => {
if (!this.props.pinned) {
this.props.history.replace('/');
}
this.props.onPin();
};
render () {
const { title, icon, iconComponent, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props;
const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', {
'active': active,
});
const buttonClassName = classNames('column-header', {
'active': active,
});
const collapsibleClassName = classNames('column-header__collapsible', {
'collapsed': collapsed,
'animating': animating,
});
const collapsibleButtonClassName = classNames('column-header__button', {
'active': !collapsed,
});
let extraContent, pinButton, moveButtons, backButton, collapseButton;
if (children) {
extraContent = (
<div key='extra-content' className='column-header__collapsible__extra'>
{children}
</div>
);
}
if (multiColumn && pinned) {
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='times' icon={CloseIcon} /> <FormattedMessage id='column_header.unpin' defaultMessage='Unpin' /></button>;
moveButtons = (
<div className='column-header__setting-arrows'>
<button title={formatMessage(messages.moveLeft)} aria-label={formatMessage(messages.moveLeft)} className='icon-button column-header__setting-btn' onClick={this.handleMoveLeft}><Icon id='chevron-left' icon={ChevronLeftIcon} /></button>
<button title={formatMessage(messages.moveRight)} aria-label={formatMessage(messages.moveRight)} className='icon-button column-header__setting-btn' onClick={this.handleMoveRight}><Icon id='chevron-right' icon={ChevronRightIcon} /></button>
</div>
);
} else if (multiColumn && this.props.onPin) {
pinButton = <button className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' icon={AddIcon} /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
}
if (history && !pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) {
backButton = <BackButton onlyIcon={!!title} />;
}
const collapsedContent = [
extraContent,
];
if (multiColumn) {
collapsedContent.push(
<div key='buttons' className='column-header__advanced-buttons'>
{pinButton}
{moveButtons}
</div>
);
}
if (this.props.identity.signedIn && (children || (multiColumn && this.props.onPin))) {
collapseButton = (
<button
className={collapsibleButtonClassName}
title={formatMessage(collapsed ? messages.show : messages.hide)}
aria-label={formatMessage(collapsed ? messages.show : messages.hide)}
onClick={this.handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' icon={SettingsIcon} />
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>
);
}
const hasTitle = (icon || iconComponent) && title;
const component = (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
{hasTitle && (
<>
{backButton}
<button onClick={this.handleTitleClick} className='column-header__title'>
{!backButton && <Icon id={icon} icon={iconComponent} className='column-header__icon' />}
{title}
</button>
</>
)}
{!hasTitle && backButton}
<div className='column-header__buttons'>
{extraButton}
{collapseButton}
</div>
</h1>
<div className={collapsibleClassName} tabIndex={collapsed ? -1 : null} onTransitionEnd={this.handleTransitionEnd}>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}
</div>
</div>
{appendContent}
</div>
);
if (placeholder) {
return component;
} else {
return (<ButtonInTabsBar>
{component}
</ButtonInTabsBar>);
}
}
}
export default injectIntl(withIdentity(withRouter(ColumnHeader)));

View file

@ -0,0 +1,301 @@
import { useCallback, useState } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import AddIcon from '@/material-icons/400-24px/add.svg?react';
import ArrowBackIcon from '@/material-icons/400-24px/arrow_back.svg?react';
import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import SettingsIcon from '@/material-icons/400-24px/settings.svg?react';
import type { IconProp } from 'mastodon/components/icon';
import { Icon } from 'mastodon/components/icon';
import { ButtonInTabsBar } from 'mastodon/features/ui/util/columns_context';
import { useIdentity } from 'mastodon/identity_context';
import { useAppHistory } from './router';
const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
hide: { id: 'column_header.hide_settings', defaultMessage: 'Hide settings' },
moveLeft: {
id: 'column_header.moveLeft_settings',
defaultMessage: 'Move column to the left',
},
moveRight: {
id: 'column_header.moveRight_settings',
defaultMessage: 'Move column to the right',
},
back: { id: 'column_back_button.label', defaultMessage: 'Back' },
});
const BackButton: React.FC<{
onlyIcon: boolean;
}> = ({ onlyIcon }) => {
const history = useAppHistory();
const intl = useIntl();
const handleBackClick = useCallback(() => {
if (history.location.state?.fromMastodon) {
history.goBack();
} else {
history.push('/');
}
}, [history]);
return (
<button
onClick={handleBackClick}
className={classNames('column-header__back-button', {
compact: onlyIcon,
})}
aria-label={intl.formatMessage(messages.back)}
>
<Icon
id='chevron-left'
icon={ArrowBackIcon}
className='column-back-button__icon'
/>
{!onlyIcon && (
<FormattedMessage id='column_back_button.label' defaultMessage='Back' />
)}
</button>
);
};
export interface Props {
title?: string;
icon?: string;
iconComponent?: IconProp;
active?: boolean;
children?: React.ReactNode;
pinned?: boolean;
multiColumn?: boolean;
extraButton?: React.ReactNode;
showBackButton?: boolean;
placeholder?: boolean;
appendContent?: React.ReactNode;
collapseIssues?: boolean;
onClick?: () => void;
onMove?: (arg0: number) => void;
onPin?: () => void;
}
export const ColumnHeader: React.FC<Props> = ({
title,
icon,
iconComponent,
active,
children,
pinned,
multiColumn,
extraButton,
showBackButton,
placeholder,
appendContent,
collapseIssues,
onClick,
onMove,
onPin,
}) => {
const intl = useIntl();
const { signedIn } = useIdentity();
const history = useAppHistory();
const [collapsed, setCollapsed] = useState(true);
const [animating, setAnimating] = useState(false);
const handleToggleClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
setCollapsed((value) => !value);
setAnimating(true);
},
[setCollapsed, setAnimating],
);
const handleTitleClick = useCallback(() => {
onClick?.();
}, [onClick]);
const handleMoveLeft = useCallback(() => {
onMove?.(-1);
}, [onMove]);
const handleMoveRight = useCallback(() => {
onMove?.(1);
}, [onMove]);
const handleTransitionEnd = useCallback(() => {
setAnimating(false);
}, [setAnimating]);
const handlePin = useCallback(() => {
if (!pinned) {
history.replace('/');
}
onPin?.();
}, [history, pinned, onPin]);
const wrapperClassName = classNames('column-header__wrapper', {
active,
});
const buttonClassName = classNames('column-header', {
active,
});
const collapsibleClassName = classNames('column-header__collapsible', {
collapsed,
animating,
});
const collapsibleButtonClassName = classNames('column-header__button', {
active: !collapsed,
});
let extraContent, pinButton, moveButtons, backButton, collapseButton;
if (children) {
extraContent = (
<div key='extra-content' className='column-header__collapsible__extra'>
{children}
</div>
);
}
if (multiColumn && pinned) {
pinButton = (
<button
className='text-btn column-header__setting-btn'
onClick={handlePin}
>
<Icon id='times' icon={CloseIcon} />{' '}
<FormattedMessage id='column_header.unpin' defaultMessage='Unpin' />
</button>
);
moveButtons = (
<div className='column-header__setting-arrows'>
<button
title={intl.formatMessage(messages.moveLeft)}
aria-label={intl.formatMessage(messages.moveLeft)}
className='icon-button column-header__setting-btn'
onClick={handleMoveLeft}
>
<Icon id='chevron-left' icon={ChevronLeftIcon} />
</button>
<button
title={intl.formatMessage(messages.moveRight)}
aria-label={intl.formatMessage(messages.moveRight)}
className='icon-button column-header__setting-btn'
onClick={handleMoveRight}
>
<Icon id='chevron-right' icon={ChevronRightIcon} />
</button>
</div>
);
} else if (multiColumn && onPin) {
pinButton = (
<button
className='text-btn column-header__setting-btn'
onClick={handlePin}
>
<Icon id='plus' icon={AddIcon} />{' '}
<FormattedMessage id='column_header.pin' defaultMessage='Pin' />
</button>
);
}
if (
!pinned &&
((multiColumn && history.location.state?.fromMastodon) || showBackButton)
) {
backButton = <BackButton onlyIcon={!!title} />;
}
const collapsedContent = [extraContent];
if (multiColumn) {
collapsedContent.push(
<div key='buttons' className='column-header__advanced-buttons'>
{pinButton}
{moveButtons}
</div>,
);
}
if (signedIn && (children || (multiColumn && onPin))) {
collapseButton = (
<button
className={collapsibleButtonClassName}
title={intl.formatMessage(collapsed ? messages.show : messages.hide)}
aria-label={intl.formatMessage(
collapsed ? messages.show : messages.hide,
)}
onClick={handleToggleClick}
>
<i className='icon-with-badge'>
<Icon id='sliders' icon={SettingsIcon} />
{collapseIssues && <i className='icon-with-badge__issue-badge' />}
</i>
</button>
);
}
const hasIcon = icon && iconComponent;
const hasTitle = hasIcon && title;
const component = (
<div className={wrapperClassName}>
<h1 className={buttonClassName}>
{hasTitle && (
<>
{backButton}
<button onClick={handleTitleClick} className='column-header__title'>
{!backButton && (
<Icon
id={icon}
icon={iconComponent}
className='column-header__icon'
/>
)}
{title}
</button>
</>
)}
{!hasTitle && backButton}
<div className='column-header__buttons'>
{extraButton}
{collapseButton}
</div>
</h1>
<div
className={collapsibleClassName}
tabIndex={collapsed ? -1 : undefined}
onTransitionEnd={handleTransitionEnd}
>
<div className='column-header__collapsible-inner'>
{(!collapsed || animating) && collapsedContent}
</div>
</div>
{appendContent}
</div>
);
if (placeholder) {
return component;
} else {
return <ButtonInTabsBar>{component}</ButtonInTabsBar>;
}
};
// eslint-disable-next-line import/no-default-export
export default ColumnHeader;

View file

@ -0,0 +1,128 @@
import { useCallback, useEffect } from 'react';
import { useIntl, defineMessages, FormattedMessage } from 'react-intl';
import { useIdentity } from '@/mastodon/identity_context';
import {
fetchRelationships,
followAccount,
unfollowAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { Button } from 'mastodon/components/button';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { me } from 'mastodon/initial_state';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
followBack: { id: 'account.follow_back', defaultMessage: 'Follow back' },
mutual: { id: 'account.mutual', defaultMessage: 'Mutual' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
export const FollowButton: React.FC<{
accountId?: string;
}> = ({ accountId }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const { signedIn } = useIdentity();
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
const relationship = useAppSelector((state) =>
accountId ? state.relationships.get(accountId) : undefined,
);
const following = relationship?.following || relationship?.requested;
useEffect(() => {
if (accountId && signedIn) {
dispatch(fetchRelationships([accountId]));
}
}, [dispatch, accountId, signedIn]);
const handleClick = useCallback(() => {
if (!signedIn) {
dispatch(
openModal({
modalType: 'INTERACTION',
modalProps: {
type: 'follow',
accountId: accountId,
url: account?.url,
},
}),
);
}
if (!relationship) return;
if (accountId === me) {
return;
} else if (relationship.following || relationship.requested) {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
message: (
<FormattedMessage
id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account?.acct}</strong> }}
/>
),
confirm: intl.formatMessage(messages.unfollow),
onConfirm: () => {
dispatch(unfollowAccount(accountId));
},
},
}),
);
} else {
dispatch(followAccount(accountId));
}
}, [dispatch, intl, accountId, relationship, account, signedIn]);
let label;
if (!signedIn) {
label = intl.formatMessage(messages.follow);
} else if (accountId === me) {
label = intl.formatMessage(messages.edit_profile);
} else if (!relationship) {
label = <LoadingIndicator />;
} else if (relationship.following && relationship.followed_by) {
label = intl.formatMessage(messages.mutual);
} else if (!relationship.following && relationship.followed_by) {
label = intl.formatMessage(messages.followBack);
} else if (relationship.following || relationship.requested) {
label = intl.formatMessage(messages.unfollow);
} else {
label = intl.formatMessage(messages.follow);
}
if (accountId === me) {
return (
<a
href='/settings/profile'
target='_blank'
rel='noreferrer noopener'
className='button button-secondary'
>
{label}
</a>
);
}
return (
<Button
onClick={handleClick}
disabled={relationship?.blocked_by || relationship?.blocking}
secondary={following}
className={following ? 'button--destructive' : undefined}
>
{label}
</Button>
);
};

View file

@ -0,0 +1,74 @@
import { useEffect, forwardRef } from 'react';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
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 { 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 { domain } from 'mastodon/initial_state';
import { useAppSelector, useAppDispatch } from 'mastodon/store';
export const HoverCardAccount = forwardRef<
HTMLDivElement,
{ accountId?: string }
>(({ accountId }, ref) => {
const dispatch = useAppDispatch();
const account = useAppSelector((state) =>
accountId ? state.accounts.get(accountId) : undefined,
);
useEffect(() => {
if (accountId && !account) {
dispatch(fetchAccount(accountId));
}
}, [dispatch, accountId, account]);
return (
<div
ref={ref}
id='hover-card'
role='tooltip'
className={classNames('hover-card dropdown-animation', {
'hover-card--loading': !account,
})}
>
{account ? (
<>
<Link to={`/@${account.acct}`} className='hover-card__name'>
<Avatar account={account} size={46} />
<DisplayName account={account} localDomain={domain} />
</Link>
<div className='hover-card__text-row'>
<AccountBio
note={account.note_emojified}
className='hover-card__bio'
/>
<AccountFields fields={account.fields} limit={2} />
</div>
<div className='hover-card__number'>
<ShortNumber
value={account.followers_count}
renderer={FollowersCounter}
/>
</div>
<FollowButton accountId={accountId} />
</>
) : (
<LoadingIndicator />
)}
</div>
);
});
HoverCardAccount.displayName = 'HoverCardAccount';

View file

@ -0,0 +1,176 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import Overlay from 'react-overlays/Overlay';
import type {
OffsetValue,
UsePopperOptions,
} from 'react-overlays/esm/usePopper';
import { useTimeout } from 'mastodon/../hooks/useTimeout';
import { HoverCardAccount } from 'mastodon/components/hover_card_account';
const offset = [-12, 4] as OffsetValue;
const enterDelay = 750;
const leaveDelay = 150;
const popperConfig = { strategy: 'fixed' } as UsePopperOptions;
const isHoverCardAnchor = (element: HTMLElement) =>
element.matches('[data-hover-card-account]');
export const HoverCardController: React.FC = () => {
const [open, setOpen] = useState(false);
const [accountId, setAccountId] = useState<string | undefined>();
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout();
const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout();
const [setScrollTimeout] = useTimeout();
const location = useLocation();
const handleClose = useCallback(() => {
cancelEnterTimeout();
cancelLeaveTimeout();
setOpen(false);
setAnchor(null);
}, [cancelEnterTimeout, cancelLeaveTimeout, setOpen, setAnchor]);
useEffect(() => {
handleClose();
}, [handleClose, location]);
useEffect(() => {
let isScrolling = false;
let currentAnchor: HTMLElement | null = null;
const open = (target: HTMLElement) => {
target.setAttribute('aria-describedby', 'hover-card');
setOpen(true);
setAnchor(target);
setAccountId(target.getAttribute('data-hover-card-account') ?? undefined);
};
const close = () => {
currentAnchor?.removeAttribute('aria-describedby');
currentAnchor = null;
setOpen(false);
setAnchor(null);
setAccountId(undefined);
};
const handleMouseEnter = (e: MouseEvent) => {
const { target } = e;
// We've exited the window
if (!(target instanceof HTMLElement)) {
close();
return;
}
// We've entered an anchor
if (!isScrolling && isHoverCardAnchor(target)) {
cancelLeaveTimeout();
currentAnchor?.removeAttribute('aria-describedby');
currentAnchor = target;
setEnterTimeout(() => {
open(target);
}, enterDelay);
}
// We've entered the hover card
if (
!isScrolling &&
(target === currentAnchor || target === cardRef.current)
) {
cancelLeaveTimeout();
}
};
const handleMouseLeave = (e: MouseEvent) => {
if (!currentAnchor) {
return;
}
if (e.target === currentAnchor || e.target === cardRef.current) {
cancelEnterTimeout();
setLeaveTimeout(() => {
close();
}, leaveDelay);
}
};
const handleScrollEnd = () => {
isScrolling = false;
};
const handleScroll = () => {
isScrolling = true;
cancelEnterTimeout();
setScrollTimeout(handleScrollEnd, 100);
};
const handleMouseMove = () => {
delayEnterTimeout(enterDelay);
};
document.body.addEventListener('mouseenter', handleMouseEnter, {
passive: true,
capture: true,
});
document.body.addEventListener('mousemove', handleMouseMove, {
passive: true,
capture: false,
});
document.body.addEventListener('mouseleave', handleMouseLeave, {
passive: true,
capture: true,
});
document.addEventListener('scroll', handleScroll, {
passive: true,
capture: true,
});
return () => {
document.body.removeEventListener('mouseenter', handleMouseEnter);
document.body.removeEventListener('mousemove', handleMouseMove);
document.body.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('scroll', handleScroll);
};
}, [
setEnterTimeout,
setLeaveTimeout,
setScrollTimeout,
cancelEnterTimeout,
cancelLeaveTimeout,
delayEnterTimeout,
setOpen,
setAccountId,
setAnchor,
]);
return (
<Overlay
rootClose
onHide={handleClose}
show={open}
target={anchor}
placement='bottom-start'
flip
offset={offset}
popperConfig={popperConfig}
>
{({ props }) => (
<div {...props} className='hover-card-controller'>
<HoverCardAccount accountId={accountId} ref={cardRef} />
</div>
)}
</Overlay>
);
};

View file

@ -425,7 +425,7 @@ class Status extends ImmutablePureComponent {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='retweet' icon={RepeatIcon} className='status__prepend-icon' /></div>
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
<FormattedMessage id='status.reblogged_by' defaultMessage='{name} boosted' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
);
@ -446,7 +446,7 @@ class Status extends ImmutablePureComponent {
prepend = (
<div className='status__prepend'>
<div className='status__prepend-icon-wrapper'><Icon id='reply' icon={ReplyIcon} className='status__prepend-icon' /></div>
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
<FormattedMessage id='status.replied_to' defaultMessage='Replied to {name}' values={{ name: <a onClick={this.handlePrependAccountClick} data-id={status.getIn(['account', 'id'])} data-hover-card-account={status.getIn(['account', 'id'])} href={`/@${status.getIn(['account', 'acct'])}`} className='status__display-name muted'><bdi><strong dangerouslySetInnerHTML={display_name_html} /></bdi></a> }} />
</div>
);
}
@ -562,7 +562,7 @@ class Status extends ImmutablePureComponent {
<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>}
</a>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} title={status.getIn(['account', 'acct'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<a onClick={this.handleAccountClick} href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} className='status__display-name' target='_blank' rel='noopener noreferrer'>
<div className='status__avatar'>
{statusAvatar}
</div>

View file

@ -116,8 +116,9 @@ class StatusContent extends PureComponent {
if (mention) {
link.addEventListener('click', this.onMentionClick.bind(this, mention), false);
link.setAttribute('title', `@${mention.get('acct')}`);
link.removeAttribute('title');
link.setAttribute('href', `/@${mention.get('acct')}`);
link.setAttribute('data-hover-card-account', mention.get('id'));
} else if (link.textContent[0] === '#' || (link.previousSibling && link.previousSibling.textContent && link.previousSibling.textContent[link.previousSibling.textContent.length - 1] === '#')) {
link.addEventListener('click', this.onHashtagClick.bind(this, link.text), false);
link.setAttribute('href', `/tags/${link.text.replace(/^#/, '')}`);

View file

@ -33,6 +33,7 @@ export default class StatusList extends ImmutablePureComponent {
withCounters: PropTypes.bool,
timelineId: PropTypes.string,
lastId: PropTypes.string,
bindToDocument: PropTypes.bool,
};
static defaultProps = {

View file

@ -94,7 +94,7 @@ const messageForFollowButton = relationship => {
return messages.mutual;
} else if (!relationship.get('following') && relationship.get('followed_by')) {
return messages.followBack;
} else if (relationship.get('following')) {
} else if (relationship.get('following') || relationship.get('requested')) {
return messages.unfollow;
} else {
return messages.follow;
@ -291,10 +291,8 @@ class Header extends ImmutablePureComponent {
if (me !== account.get('id')) {
if (signedIn && !account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = <Button disabled><LoadingIndicator /></Button>;
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.props.onFollow} />;
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(messageForFollowButton(account.get('relationship')))} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) })} text={intl.formatMessage(messageForFollowButton(account.get('relationship')))} onClick={signedIn ? this.props.onFollow : this.props.onInteractionModal} />;
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button text={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.props.onBlock} />;
}

View file

@ -25,7 +25,6 @@ import { makeGetAccount, getAccountHidden } from '../../../selectors';
import Header from '../components/header';
const messages = defineMessages({
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
blockDomainConfirm: { id: 'confirmations.domain_block.confirm', defaultMessage: 'Block entire domain' },
});
@ -45,7 +44,7 @@ const makeMapStateToProps = () => {
const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow (account) {
if (account.getIn(['relationship', 'following'])) {
if (account.getIn(['relationship', 'following']) || account.getIn(['relationship', 'requested'])) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
@ -54,15 +53,6 @@ const mapDispatchToProps = (dispatch, { intl }) => ({
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
},
}));
} else if (account.getIn(['relationship', 'requested'])) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
},
}));
} else {
dispatch(followAccount(account.get('id')));
}

View file

@ -133,7 +133,7 @@ class AccountTimeline extends ImmutablePureComponent {
}
if (prevProps.accountId === me && accountId !== me) {
dispatch(disconnectTimeline(`account:${me}`));
dispatch(disconnectTimeline({ timeline: `account:${me}` }));
}
}
@ -141,7 +141,7 @@ class AccountTimeline extends ImmutablePureComponent {
const { dispatch, accountId } = this.props;
if (accountId === me) {
dispatch(disconnectTimeline(`account:${me}`));
dispatch(disconnectTimeline({ timeline: `account:${me}` }));
}
}

View file

@ -163,7 +163,7 @@ export const Conversation = ({ conversation, scrollKey, onMoveUp, onMoveDown })
menu.push({ text: intl.formatMessage(messages.delete), action: handleDelete });
const names = accounts.map(a => (
<Link to={`/@${a.get('acct')}`} key={a.get('id')} title={a.get('acct')}>
<Link to={`/@${a.get('acct')}`} key={a.get('id')} data-hover-card-account={a.get('id')}>
<bdi>
<strong
className='display-name__html'

View file

@ -1,234 +0,0 @@
import PropTypes from 'prop-types';
import { FormattedMessage, injectIntl, defineMessages } 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';
import { connect } from 'react-redux';
import {
followAccount,
unfollowAccount,
unblockAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { ShortNumber } from 'mastodon/components/short_number';
import { autoPlayGif, me } from 'mastodon/initial_state';
import { makeGetAccount } from 'mastodon/selectors';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
cancelFollowRequestConfirm: { id: 'confirmations.cancel_follow_request.confirm', defaultMessage: 'Withdraw request' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' },
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
const makeMapStateToProps = () => {
const getAccount = makeGetAccount();
const mapStateToProps = (state, { id }) => ({
account: getAccount(state, id),
});
return mapStateToProps;
};
const mapDispatchToProps = (dispatch, { intl }) => ({
onFollow(account) {
if (account.getIn(['relationship', 'following'])) {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
message: (
<FormattedMessage
id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
),
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
} }),
);
} else if (account.getIn(['relationship', 'requested'])) {
dispatch(openModal({
modalType: 'CONFIRM',
modalProps: {
message: <FormattedMessage id='confirmations.cancel_follow_request.message' defaultMessage='Are you sure you want to withdraw your request to follow {name}?' values={{ name: <strong>@{account.get('acct')}</strong> }} />,
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
onConfirm: () => dispatch(unfollowAccount(account.get('id'))),
},
}));
} else {
dispatch(followAccount(account.get('id')));
}
},
onBlock(account) {
if (account.getIn(['relationship', 'blocking'])) {
dispatch(unblockAccount(account.get('id')));
}
},
onMute(account) {
if (account.getIn(['relationship', 'muting'])) {
dispatch(unmuteAccount(account.get('id')));
}
},
});
class AccountCard extends ImmutablePureComponent {
static propTypes = {
account: ImmutablePropTypes.record.isRequired,
intl: PropTypes.object.isRequired,
onFollow: PropTypes.func.isRequired,
onBlock: PropTypes.func.isRequired,
onMute: PropTypes.func.isRequired,
};
handleMouseEnter = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-original');
}
};
handleMouseLeave = ({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis = currentTarget.querySelectorAll('.custom-emoji');
for (var i = 0; i < emojis.length; i++) {
let emoji = emojis[i];
emoji.src = emoji.getAttribute('data-static');
}
};
handleFollow = () => {
this.props.onFollow(this.props.account);
};
handleBlock = () => {
this.props.onBlock(this.props.account);
};
handleMute = () => {
this.props.onMute(this.props.account);
};
handleEditProfile = () => {
window.open('/settings/profile', '_blank');
};
render() {
const { account, intl } = this.props;
let actionBtn;
if (me !== account.get('id')) {
if (!account.get('relationship')) { // Wait until the relationship is loaded
actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = <Button text={intl.formatMessage(messages.cancel_follow_request)} title={intl.formatMessage(messages.requested)} onClick={this.handleFollow} />;
} else if (account.getIn(['relationship', 'muting'])) {
actionBtn = <Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />;
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button disabled={account.getIn(['relationship', 'blocked_by'])} className={classNames({ 'button--destructive': account.getIn(['relationship', 'following']) })} text={intl.formatMessage(account.getIn(['relationship', 'following']) ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
}
} else {
actionBtn = <Button text={intl.formatMessage(messages.edit_profile)} onClick={this.handleEditProfile} />;
}
return (
<div className='account-card'>
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
<div className='account-card__header'>
<img
src={
autoPlayGif ? account.get('header') : account.get('header_static')
}
alt=''
/>
</div>
<div className='account-card__title'>
<div className='account-card__title__avatar'><Avatar account={account} size={56} /></div>
<DisplayName account={account} />
</div>
</Link>
{account.get('note').length > 0 && (
<div
className='account-card__bio translate'
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
)}
<div className='account-card__actions'>
<div className='account-card__counters'>
<div className='account-card__counters__item'>
<ShortNumber value={account.get('statuses_count')} />
<small>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
</small>
</div>
<div className='account-card__counters__item'>
<ShortNumber value={account.get('followers_count')} />{' '}
<small>
<FormattedMessage
id='account.followers'
defaultMessage='Followers'
/>
</small>
</div>
<div className='account-card__counters__item'>
<ShortNumber value={account.get('following_count')} />{' '}
<small>
<FormattedMessage
id='account.following'
defaultMessage='Following'
/>
</small>
</div>
</div>
<div className='account-card__actions__button'>
{actionBtn}
</div>
</div>
</div>
);
}
}
export default injectIntl(connect(makeMapStateToProps, mapDispatchToProps)(AccountCard));

View file

@ -0,0 +1,269 @@
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { FormattedMessage, defineMessages, useIntl } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import {
followAccount,
unfollowAccount,
unblockAccount,
unmuteAccount,
} from 'mastodon/actions/accounts';
import { openModal } from 'mastodon/actions/modal';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { ShortNumber } from 'mastodon/components/short_number';
import { autoPlayGif, me } from 'mastodon/initial_state';
import type { Account } from 'mastodon/models/account';
import { makeGetAccount } from 'mastodon/selectors';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
const messages = defineMessages({
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
follow: { id: 'account.follow', defaultMessage: 'Follow' },
cancel_follow_request: {
id: 'account.cancel_follow_request',
defaultMessage: 'Withdraw follow request',
},
cancelFollowRequestConfirm: {
id: 'confirmations.cancel_follow_request.confirm',
defaultMessage: 'Withdraw request',
},
requested: {
id: 'account.requested',
defaultMessage: 'Awaiting approval. Click to cancel follow request',
},
unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
unfollowConfirm: {
id: 'confirmations.unfollow.confirm',
defaultMessage: 'Unfollow',
},
edit_profile: { id: 'account.edit_profile', defaultMessage: 'Edit profile' },
});
const getAccount = makeGetAccount();
export const AccountCard: React.FC<{ accountId: string }> = ({ accountId }) => {
const intl = useIntl();
const account = useAppSelector((s) => getAccount(s, accountId));
const dispatch = useAppDispatch();
const handleMouseEnter = useCallback<MouseEventHandler>(
({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
emojis.forEach((emoji) => {
const original = emoji.getAttribute('data-original');
if (original) emoji.src = original;
});
},
[],
);
const handleMouseLeave = useCallback<MouseEventHandler>(
({ currentTarget }) => {
if (autoPlayGif) {
return;
}
const emojis =
currentTarget.querySelectorAll<HTMLImageElement>('.custom-emoji');
emojis.forEach((emoji) => {
const staticUrl = emoji.getAttribute('data-static');
if (staticUrl) emoji.src = staticUrl;
});
},
[],
);
const handleFollow = useCallback(() => {
if (!account) return;
if (account.getIn(['relationship', 'following'])) {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
message: (
<FormattedMessage
id='confirmations.unfollow.message'
defaultMessage='Are you sure you want to unfollow {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
),
confirm: intl.formatMessage(messages.unfollowConfirm),
onConfirm: () => {
dispatch(unfollowAccount(account.get('id')));
},
},
}),
);
} else if (account.getIn(['relationship', 'requested'])) {
dispatch(
openModal({
modalType: 'CONFIRM',
modalProps: {
message: (
<FormattedMessage
id='confirmations.cancel_follow_request.message'
defaultMessage='Are you sure you want to withdraw your request to follow {name}?'
values={{ name: <strong>@{account.get('acct')}</strong> }}
/>
),
confirm: intl.formatMessage(messages.cancelFollowRequestConfirm),
onConfirm: () => {
dispatch(unfollowAccount(account.get('id')));
},
},
}),
);
} else {
dispatch(followAccount(account.get('id')));
}
}, [account, dispatch, intl]);
const handleBlock = useCallback(() => {
if (account?.relationship?.blocking) {
dispatch(unblockAccount(account.get('id')));
}
}, [account, dispatch]);
const handleMute = useCallback(() => {
if (account?.relationship?.muting) {
dispatch(unmuteAccount(account.get('id')));
}
}, [account, dispatch]);
const handleEditProfile = useCallback(() => {
window.open('/settings/profile', '_blank');
}, []);
if (!account) return null;
let actionBtn;
if (me !== account.get('id')) {
if (!account.get('relationship')) {
// Wait until the relationship is loaded
actionBtn = '';
} else if (account.getIn(['relationship', 'requested'])) {
actionBtn = (
<Button
text={intl.formatMessage(messages.cancel_follow_request)}
title={intl.formatMessage(messages.requested)}
onClick={handleFollow}
/>
);
} else if (account.getIn(['relationship', 'muting'])) {
actionBtn = (
<Button
text={intl.formatMessage(messages.unmute)}
onClick={handleMute}
/>
);
} else if (!account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<Button
disabled={account.relationship?.blocked_by}
className={classNames({
'button--destructive': account.getIn(['relationship', 'following']),
})}
text={intl.formatMessage(
account.getIn(['relationship', 'following'])
? messages.unfollow
: messages.follow,
)}
onClick={handleFollow}
/>
);
} else if (account.getIn(['relationship', 'blocking'])) {
actionBtn = (
<Button
text={intl.formatMessage(messages.unblock)}
onClick={handleBlock}
/>
);
}
} else {
actionBtn = (
<Button
text={intl.formatMessage(messages.edit_profile)}
onClick={handleEditProfile}
/>
);
}
return (
<div className='account-card'>
<Link to={`/@${account.get('acct')}`} className='account-card__permalink'>
<div className='account-card__header'>
<img
src={
autoPlayGif ? account.get('header') : account.get('header_static')
}
alt=''
/>
</div>
<div className='account-card__title'>
<div className='account-card__title__avatar'>
<Avatar account={account as Account} size={56} />
</div>
<DisplayName account={account as Account} />
</div>
</Link>
{account.get('note').length > 0 && (
<div
className='account-card__bio translate'
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
dangerouslySetInnerHTML={{ __html: account.get('note_emojified') }}
/>
)}
<div className='account-card__actions'>
<div className='account-card__counters'>
<div className='account-card__counters__item'>
<ShortNumber value={account.get('statuses_count')} />
<small>
<FormattedMessage id='account.posts' defaultMessage='Posts' />
</small>
</div>
<div className='account-card__counters__item'>
<ShortNumber value={account.get('followers_count')} />{' '}
<small>
<FormattedMessage
id='account.followers'
defaultMessage='Followers'
/>
</small>
</div>
<div className='account-card__counters__item'>
<ShortNumber value={account.get('following_count')} />{' '}
<small>
<FormattedMessage
id='account.following'
defaultMessage='Following'
/>
</small>
</div>
</div>
<div className='account-card__actions__button'>{actionBtn}</div>
</div>
</div>
);
};

View file

@ -1,181 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { List as ImmutableList } from 'immutable';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import { addColumn, removeColumn, moveColumn, changeColumnParams } from 'mastodon/actions/columns';
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
import Column from 'mastodon/components/column';
import ColumnHeader from 'mastodon/components/column_header';
import { LoadMore } from 'mastodon/components/load_more';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RadioButton } from 'mastodon/components/radio_button';
import ScrollContainer from 'mastodon/containers/scroll_container';
import AccountCard from './components/account_card';
const messages = defineMessages({
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
recentlyActive: { id: 'directory.recently_active', defaultMessage: 'Recently active' },
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
federated: { id: 'directory.federated', defaultMessage: 'From known fediverse' },
});
const mapStateToProps = state => ({
accountIds: state.getIn(['user_lists', 'directory', 'items'], ImmutableList()),
isLoading: state.getIn(['user_lists', 'directory', 'isLoading'], true),
domain: state.getIn(['meta', 'domain']),
});
class Directory extends PureComponent {
static propTypes = {
isLoading: PropTypes.bool,
accountIds: ImmutablePropTypes.list.isRequired,
dispatch: PropTypes.func.isRequired,
columnId: PropTypes.string,
intl: PropTypes.object.isRequired,
multiColumn: PropTypes.bool,
domain: PropTypes.string.isRequired,
params: PropTypes.shape({
order: PropTypes.string,
local: PropTypes.bool,
}),
};
state = {
order: null,
local: null,
};
handlePin = () => {
const { columnId, dispatch } = this.props;
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECTORY', this.getParams(this.props, this.state)));
}
};
getParams = (props, state) => ({
order: state.order === null ? (props.params.order || 'active') : state.order,
local: state.local === null ? (props.params.local || false) : state.local,
});
handleMove = dir => {
const { columnId, dispatch } = this.props;
dispatch(moveColumn(columnId, dir));
};
handleHeaderClick = () => {
this.column.scrollTop();
};
componentDidMount () {
const { dispatch } = this.props;
dispatch(fetchDirectory(this.getParams(this.props, this.state)));
}
componentDidUpdate (prevProps, prevState) {
const { dispatch } = this.props;
const paramsOld = this.getParams(prevProps, prevState);
const paramsNew = this.getParams(this.props, this.state);
if (paramsOld.order !== paramsNew.order || paramsOld.local !== paramsNew.local) {
dispatch(fetchDirectory(paramsNew));
}
}
setRef = c => {
this.column = c;
};
handleChangeOrder = e => {
const { dispatch, columnId } = this.props;
if (columnId) {
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
} else {
this.setState({ order: e.target.value });
}
};
handleChangeLocal = e => {
const { dispatch, columnId } = this.props;
if (columnId) {
dispatch(changeColumnParams(columnId, ['local'], e.target.value === '1'));
} else {
this.setState({ local: e.target.value === '1' });
}
};
handleLoadMore = () => {
const { dispatch } = this.props;
dispatch(expandDirectory(this.getParams(this.props, this.state)));
};
render () {
const { isLoading, accountIds, intl, columnId, multiColumn, domain } = this.props;
const { order, local } = this.getParams(this.props, this.state);
const pinned = !!columnId;
const scrollableArea = (
<div className='scrollable'>
<div className='filter-form'>
<div className='filter-form__column' role='group'>
<RadioButton name='order' value='active' label={intl.formatMessage(messages.recentlyActive)} checked={order === 'active'} onChange={this.handleChangeOrder} />
<RadioButton name='order' value='new' label={intl.formatMessage(messages.newArrivals)} checked={order === 'new'} onChange={this.handleChangeOrder} />
</div>
<div className='filter-form__column' role='group'>
<RadioButton name='local' value='1' label={intl.formatMessage(messages.local, { domain })} checked={local} onChange={this.handleChangeLocal} />
<RadioButton name='local' value='0' label={intl.formatMessage(messages.federated)} checked={!local} onChange={this.handleChangeLocal} />
</div>
</div>
<div className='directory__list'>
{isLoading ? <LoadingIndicator /> : accountIds.map(accountId => (
<AccountCard id={accountId} key={accountId} />
))}
</div>
<LoadMore onClick={this.handleLoadMore} visible={!isLoading} />
</div>
);
return (
<Column bindToDocument={!multiColumn} ref={this.setRef} label={intl.formatMessage(messages.title)}>
<ColumnHeader
icon='address-book-o'
iconComponent={PeopleIcon}
title={intl.formatMessage(messages.title)}
onPin={this.handlePin}
onMove={this.handleMove}
onClick={this.handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
{multiColumn && !pinned ? <ScrollContainer scrollKey='directory'>{scrollableArea}</ScrollContainer> : scrollableArea}
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
}
}
export default connect(mapStateToProps)(injectIntl(Directory));

View file

@ -0,0 +1,216 @@
import type { ChangeEventHandler } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import { Helmet } from 'react-helmet';
import { List as ImmutableList } from 'immutable';
import PeopleIcon from '@/material-icons/400-24px/group.svg?react';
import {
addColumn,
removeColumn,
moveColumn,
changeColumnParams,
} from 'mastodon/actions/columns';
import { fetchDirectory, expandDirectory } from 'mastodon/actions/directory';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import { LoadMore } from 'mastodon/components/load_more';
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
import { RadioButton } from 'mastodon/components/radio_button';
import ScrollContainer from 'mastodon/containers/scroll_container';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
import { AccountCard } from './components/account_card';
const messages = defineMessages({
title: { id: 'column.directory', defaultMessage: 'Browse profiles' },
recentlyActive: {
id: 'directory.recently_active',
defaultMessage: 'Recently active',
},
newArrivals: { id: 'directory.new_arrivals', defaultMessage: 'New arrivals' },
local: { id: 'directory.local', defaultMessage: 'From {domain} only' },
federated: {
id: 'directory.federated',
defaultMessage: 'From known fediverse',
},
});
export const Directory: React.FC<{
columnId?: string;
multiColumn?: boolean;
params?: { order: string; local?: boolean };
}> = ({ columnId, multiColumn, params }) => {
const intl = useIntl();
const dispatch = useAppDispatch();
const [state, setState] = useState<{
order: string | null;
local: boolean | null;
}>({
order: null,
local: null,
});
const column = useRef<Column>(null);
const order = state.order ?? params?.order ?? 'active';
const local = state.local ?? params?.local ?? false;
const handlePin = useCallback(() => {
if (columnId) {
dispatch(removeColumn(columnId));
} else {
dispatch(addColumn('DIRECTORY', { order, local }));
}
}, [dispatch, columnId, order, local]);
const domain = useAppSelector((s) => s.meta.get('domain') as string);
const accountIds = useAppSelector(
(state) =>
state.user_lists.getIn(
['directory', 'items'],
ImmutableList(),
) as ImmutableList<string>,
);
const isLoading = useAppSelector(
(state) =>
state.user_lists.getIn(['directory', 'isLoading'], true) as boolean,
);
useEffect(() => {
void dispatch(fetchDirectory({ order, local }));
}, [dispatch, order, local]);
const handleMove = useCallback(
(dir: number) => {
dispatch(moveColumn(columnId, dir));
},
[dispatch, columnId],
);
const handleHeaderClick = useCallback(() => {
column.current?.scrollTop();
}, []);
const handleChangeOrder = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => {
if (columnId) {
dispatch(changeColumnParams(columnId, ['order'], e.target.value));
} else {
setState((s) => ({ order: e.target.value, local: s.local }));
}
},
[dispatch, columnId],
);
const handleChangeLocal = useCallback<ChangeEventHandler<HTMLInputElement>>(
(e) => {
if (columnId) {
dispatch(
changeColumnParams(columnId, ['local'], e.target.value === '1'),
);
} else {
setState((s) => ({ local: e.target.value === '1', order: s.order }));
}
},
[dispatch, columnId],
);
const handleLoadMore = useCallback(() => {
void dispatch(expandDirectory({ order, local }));
}, [dispatch, order, local]);
const pinned = !!columnId;
const scrollableArea = (
<div className='scrollable'>
<div className='filter-form'>
<div className='filter-form__column' role='group'>
<RadioButton
name='order'
value='active'
label={intl.formatMessage(messages.recentlyActive)}
checked={order === 'active'}
onChange={handleChangeOrder}
/>
<RadioButton
name='order'
value='new'
label={intl.formatMessage(messages.newArrivals)}
checked={order === 'new'}
onChange={handleChangeOrder}
/>
</div>
<div className='filter-form__column' role='group'>
<RadioButton
name='local'
value='1'
label={intl.formatMessage(messages.local, { domain })}
checked={local}
onChange={handleChangeLocal}
/>
<RadioButton
name='local'
value='0'
label={intl.formatMessage(messages.federated)}
checked={!local}
onChange={handleChangeLocal}
/>
</div>
</div>
<div className='directory__list'>
{isLoading ? (
<LoadingIndicator />
) : (
accountIds.map((accountId) => (
<AccountCard accountId={accountId} key={accountId} />
))
)}
</div>
<LoadMore onClick={handleLoadMore} visible={!isLoading} />
</div>
);
return (
<Column
bindToDocument={!multiColumn}
ref={column}
label={intl.formatMessage(messages.title)}
>
<ColumnHeader
icon='address-book-o'
iconComponent={PeopleIcon}
title={intl.formatMessage(messages.title)}
onPin={handlePin}
onMove={handleMove}
onClick={handleHeaderClick}
pinned={pinned}
multiColumn={multiColumn}
/>
{multiColumn && !pinned ? (
// @ts-expect-error ScrollContainer is not properly typed yet
<ScrollContainer scrollKey='directory'>
{scrollableArea}
</ScrollContainer>
) : (
scrollableArea
)}
<Helmet>
<title>{intl.formatMessage(messages.title)}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export -- Needed because this is called as an async components
export default Directory;

View file

@ -8,8 +8,12 @@ import { useAppSelector } from 'mastodon/store';
export const AuthorLink = ({ accountId }) => {
const account = useAppSelector(state => state.getIn(['accounts', accountId]));
if (!account) {
return null;
}
return (
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link'>
<Link to={`/@${account.get('acct')}`} className='story__details__shared__author-link' data-hover-card-account={accountId}>
<Avatar account={account} size={16} />
<bdi dangerouslySetInnerHTML={{ __html: account.get('display_name_html') }} />
</Link>

View file

@ -8,34 +8,21 @@ import { Link } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
import { dismissSuggestion } from 'mastodon/actions/suggestions';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { IconButton } from 'mastodon/components/icon_button';
import { domain } from 'mastodon/initial_state';
const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
dismiss: { id: 'follow_suggestions.dismiss', defaultMessage: "Don't show again" },
});
export const Card = ({ id, source }) => {
const intl = useIntl();
const account = useSelector(state => state.getIn(['accounts', id]));
const relationship = useSelector(state => state.getIn(['relationships', id]));
const dispatch = useDispatch();
const following = relationship?.get('following') ?? relationship?.get('requested');
const handleFollow = useCallback(() => {
if (following) {
dispatch(unfollowAccount(id));
} else {
dispatch(followAccount(id));
}
}, [id, following, dispatch]);
const handleDismiss = useCallback(() => {
dispatch(dismissSuggestion(id));
@ -74,7 +61,7 @@ export const Card = ({ id, source }) => {
<div className='explore__suggestions__card__body__main__name-button'>
<Link className='explore__suggestions__card__body__main__name-button__name' to={`/@${account.get('acct')}`}><DisplayName account={account} /></Link>
<IconButton iconComponent={CloseIcon} onClick={handleDismiss} title={intl.formatMessage(messages.dismiss)} />
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
<FollowButton accountId={account.get('id')} />
</div>
</div>
</div>

View file

@ -4,6 +4,8 @@ import { useState, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { Blurhash } from 'mastodon/components/blurhash';
@ -57,7 +59,7 @@ export const Story = ({
<div className='story__details__shared'>
{author ? <FormattedMessage id='link_preview.author' className='story__details__shared__author' defaultMessage='By {name}' values={{ name: authorAccount ? <AuthorLink accountId={authorAccount} /> : <strong>{author}</strong> }} /> : <span />}
{typeof sharedTimes === 'number' ? <span className='story__details__shared__pill'><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></span> : <Skeleton width='10ch' />}
{typeof sharedTimes === 'number' ? <Link className='story__details__shared__pill' to={`/links/${encodeURIComponent(url)}`}><ShortNumber value={sharedTimes} renderer={sharesCountRenderer} /></Link> : <Skeleton width='10ch' />}
</div>
</div>

View file

@ -75,7 +75,7 @@ class Links extends PureComponent {
publisher={link.get('provider_name')}
publishedAt={link.get('published_at')}
author={link.get('author_name')}
authorAccount={link.getIn(['author_account', 'id'])}
authorAccount={link.getIn(['authors', 0, 'account', 'id'])}
sharedTimes={link.getIn(['history', 0, 'accounts']) * 1 + link.getIn(['history', 1, 'accounts']) * 1}
thumbnail={link.get('image')}
thumbnailDescription={link.get('image_description')}

View file

@ -12,12 +12,11 @@ import ChevronLeftIcon from '@/material-icons/400-24px/chevron_left.svg?react';
import ChevronRightIcon from '@/material-icons/400-24px/chevron_right.svg?react';
import CloseIcon from '@/material-icons/400-24px/close.svg?react';
import InfoIcon from '@/material-icons/400-24px/info.svg?react';
import { followAccount, unfollowAccount } from 'mastodon/actions/accounts';
import { changeSetting } from 'mastodon/actions/settings';
import { fetchSuggestions, dismissSuggestion } from 'mastodon/actions/suggestions';
import { Avatar } from 'mastodon/components/avatar';
import { Button } from 'mastodon/components/button';
import { DisplayName } from 'mastodon/components/display_name';
import { FollowButton } from 'mastodon/components/follow_button';
import { Icon } from 'mastodon/components/icon';
import { IconButton } from 'mastodon/components/icon_button';
import { VerifiedBadge } from 'mastodon/components/verified_badge';
@ -79,18 +78,8 @@ Source.propTypes = {
const Card = ({ id, sources }) => {
const intl = useIntl();
const account = useSelector(state => state.getIn(['accounts', id]));
const relationship = useSelector(state => state.getIn(['relationships', id]));
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
const dispatch = useDispatch();
const following = relationship?.get('following') ?? relationship?.get('requested');
const handleFollow = useCallback(() => {
if (following) {
dispatch(unfollowAccount(id));
} else {
dispatch(followAccount(id));
}
}, [id, following, dispatch]);
const handleDismiss = useCallback(() => {
dispatch(dismissSuggestion(id));
@ -109,7 +98,7 @@ const Card = ({ id, sources }) => {
{firstVerifiedField ? <VerifiedBadge link={firstVerifiedField.get('value')} /> : <Source id={sources.get(0)} />}
</div>
<Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} secondary={following} onClick={handleFollow} />
<FollowButton accountId={id} />
</div>
);
};

View file

@ -0,0 +1,76 @@
import { useRef, useEffect, useCallback } from 'react';
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router-dom';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
import { expandLinkTimeline } from 'mastodon/actions/timelines';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import StatusListContainer from 'mastodon/features/ui/containers/status_list_container';
import type { Card } from 'mastodon/models/status';
import { useAppDispatch, useAppSelector } from 'mastodon/store';
export const LinkTimeline: React.FC<{
multiColumn: boolean;
}> = ({ multiColumn }) => {
const { url } = useParams<{ url: string }>();
const decodedUrl = url ? decodeURIComponent(url) : undefined;
const dispatch = useAppDispatch();
const columnRef = useRef<Column>(null);
const firstStatusId = useAppSelector((state) =>
decodedUrl
? // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(state.timelines.getIn([`link:${decodedUrl}`, 'items', 0]) as string)
: undefined,
);
const story = useAppSelector((state) =>
firstStatusId
? (state.statuses.getIn([firstStatusId, 'card']) as Card)
: undefined,
);
const handleHeaderClick = useCallback(() => {
columnRef.current?.scrollTop();
}, []);
const handleLoadMore = useCallback(
(maxId: string) => {
dispatch(expandLinkTimeline(decodedUrl, { maxId }));
},
[dispatch, decodedUrl],
);
useEffect(() => {
dispatch(expandLinkTimeline(decodedUrl));
}, [dispatch, decodedUrl]);
return (
<Column bindToDocument={!multiColumn} ref={columnRef} label={story?.title}>
<ColumnHeader
icon='explore'
iconComponent={ExploreIcon}
title={story?.title}
onClick={handleHeaderClick}
multiColumn={multiColumn}
showBackButton
/>
<StatusListContainer
timelineId={`link:${decodedUrl}`}
onLoadMore={handleLoadMore}
trackScroll
scrollKey={`link_timeline-${decodedUrl}`}
bindToDocument={!multiColumn}
/>
<Helmet>
<title>{story?.title}</title>
<meta name='robots' content='noindex' />
</Helmet>
</Column>
);
};
// eslint-disable-next-line import/no-default-export
export default LinkTimeline;

View file

@ -435,7 +435,7 @@ class Notification extends ImmutablePureComponent {
const targetAccount = report.get('target_account');
const targetDisplayNameHtml = { __html: targetAccount.get('display_name_html') };
const targetLink = <bdi><Link className='notification__display-name' title={targetAccount.get('acct')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
const targetLink = <bdi><Link className='notification__display-name' data-hover-card-account={targetAccount.get('id')} to={`/@${targetAccount.get('acct')}`} dangerouslySetInnerHTML={targetDisplayNameHtml} /></bdi>;
return (
<HotKeys handlers={this.getHandlers()}>
@ -458,7 +458,7 @@ class Notification extends ImmutablePureComponent {
const { notification } = this.props;
const account = notification.get('account');
const displayNameHtml = { __html: account.get('display_name_html') };
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} title={account.get('acct')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
const link = <bdi><Link className='notification__display-name' href={`/@${account.get('acct')}`} data-hover-card-account={account.get('id')} to={`/@${account.get('acct')}`} dangerouslySetInnerHTML={displayNameHtml} /></bdi>;
switch(notification.get('type')) {
case 'follow':

View file

@ -138,7 +138,7 @@ export default class Card extends PureComponent {
const interactive = card.get('type') === 'video';
const language = card.get('language') || '';
const largeImage = (card.get('image')?.length > 0 && card.get('width') > card.get('height')) || interactive;
const showAuthor = !!card.get('author_account');
const showAuthor = !!card.getIn(['authors', 0, 'accountId']);
const description = (
<div className='status-card__content'>
@ -244,7 +244,7 @@ export default class Card extends PureComponent {
{description}
</a>
{showAuthor && <MoreFromAuthor accountId={card.get('author_account')} />}
{showAuthor && <MoreFromAuthor accountId={card.getIn(['authors', 0, 'accountId'])} />}
</>
);
}

View file

@ -272,7 +272,7 @@ class DetailedStatus extends ImmutablePureComponent {
<FormattedMessage id='status.direct_indicator' defaultMessage='Private mention' />
</div>
)}
<a href={`/@${status.getIn(['account', 'acct'])}`} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<a href={`/@${status.getIn(['account', 'acct'])}`} data-hover-card-account={status.getIn(['account', 'id'])} onClick={this.handleAccountClick} className='detailed-status__display-name'>
<div className='detailed-status__display-avatar'><Avatar account={status.get('account')} size={46} /></div>
<DisplayName account={status.get('account')} localDomain={this.props.domain} />
</a>

View file

@ -1,11 +1,8 @@
import Column from '../../../components/column';
import ColumnHeader from '../../../components/column_header';
import Column from 'mastodon/components/column';
import { ColumnHeader } from 'mastodon/components/column_header';
import type { Props as ColumnHeaderProps } from 'mastodon/components/column_header';
interface Props {
multiColumn?: boolean;
}
export const ColumnLoading: React.FC<Props> = (otherProps) => (
export const ColumnLoading: React.FC<ColumnHeaderProps> = (otherProps) => (
<Column>
<ColumnHeader {...otherProps} />
<div className='scrollable' />

View file

@ -14,6 +14,7 @@ import { HotKeys } from 'react-hotkeys';
import { focusApp, unfocusApp, changeLayout } from 'mastodon/actions/app';
import { synchronouslySubmitMarkers, submitMarkers, fetchMarkers } from 'mastodon/actions/markers';
import { INTRODUCTION_VERSION } from 'mastodon/actions/onboarding';
import { HoverCardController } from 'mastodon/components/hover_card_controller';
import { PictureInPicture } from 'mastodon/features/picture_in_picture';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { layoutFromWindow } from 'mastodon/is_mobile';
@ -55,6 +56,7 @@ import {
FavouritedStatuses,
BookmarkedStatuses,
FollowedTags,
LinkTimeline,
ListTimeline,
Blocks,
DomainBlocks,
@ -201,6 +203,7 @@ class SwitchingColumnsArea extends PureComponent {
<WrappedRoute path='/public/remote' exact component={Firehose} componentParams={{ feedType: 'public:remote' }} content={children} />
<WrappedRoute path={['/conversations', '/timelines/direct']} component={DirectTimeline} content={children} />
<WrappedRoute path='/tags/:id' component={HashtagTimeline} content={children} />
<WrappedRoute path='/links/:url' component={LinkTimeline} content={children} />
<WrappedRoute path='/lists/:id' component={ListTimeline} content={children} />
<WrappedRoute path='/notifications' component={Notifications} content={children} exact />
<WrappedRoute path='/notifications/requests' component={NotificationRequests} content={children} exact />
@ -585,6 +588,7 @@ class UI extends PureComponent {
{layout !== 'mobile' && <PictureInPicture />}
<NotificationsContainer />
<HoverCardController />
<LoadingBarContainer className='loading-bar' />
<ModalContainer />
<UploadArea active={draggingOver} onClose={this.closeUploadModal} />

View file

@ -201,3 +201,7 @@ export function NotificationRequests () {
export function NotificationRequest () {
return import(/*webpackChunkName: "features/notifications/request" */'../../notifications/request');
}
export function LinkTimeline () {
return import(/*webpackChunkName: "features/link_timeline" */'../../link_timeline');
}

View file

@ -50,7 +50,6 @@
"account.requested_follow": "{name} het versoek om jou te volg",
"account.share": "Deel @{name} se profiel",
"account.show_reblogs": "Wys aangestuurde plasings van @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Plaas} other {{counter} Plasings}}",
"account.unblock": "Deblokkeer @{name}",
"account.unblock_domain": "Deblokkeer domein {domain}",
"account.unblock_short": "Deblokkeer",

View file

@ -31,9 +31,7 @@
"account.follow": "Seguir",
"account.followers": "Seguidores",
"account.followers.empty": "Encara no sigue dengún a este usuario.",
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
"account.following": "Seguindo",
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Seguindo}}",
"account.follows.empty": "Este usuario encara no sigue a dengún.",
"account.go_to_profile": "Ir ta lo perfil",
"account.hide_reblogs": "Amagar retutz de @{name}",
@ -54,7 +52,6 @@
"account.requested_follow": "{name} ha demandau seguir-te",
"account.share": "Compartir lo perfil de @{name}",
"account.show_reblogs": "Amostrar retutz de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Publicación} other {{counter} Publicaciones}}",
"account.unblock": "Desblocar a @{name}",
"account.unblock_domain": "Amostrar a {domain}",
"account.unblock_short": "Desblocar",

View file

@ -35,9 +35,7 @@
"account.follow_back": "رد المتابعة",
"account.followers": "مُتابِعون",
"account.followers.empty": "لا أحدَ يُتابع هذا المُستخدم إلى حد الآن.",
"account.followers_counter": "{count, plural, zero{لا مُتابع} one {مُتابعٌ واحِد} two {مُتابعانِ اِثنان} few {{counter} مُتابِعين} many {{counter} مُتابِعًا} other {{counter} مُتابع}}",
"account.following": "الاشتراكات",
"account.following_counter": "{count, plural, zero{لا يُتابِع أحدًا} one {يُتابِعُ واحد} two{يُتابِعُ اِثنان} few{يُتابِعُ {counter}} many{يُتابِعُ {counter}} other {يُتابِعُ {counter}}}",
"account.follows.empty": "لا يُتابع هذا المُستخدمُ أيَّ أحدٍ حتى الآن.",
"account.go_to_profile": "اذهب إلى الملف الشخصي",
"account.hide_reblogs": "إخفاء المعاد نشرها مِن @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "لقد طلب {name} متابعتك",
"account.share": "شارِك الملف التعريفي لـ @{name}",
"account.show_reblogs": "اعرض إعادات نشر @{name}",
"account.statuses_counter": "{count, plural, zero {لَا منشورات} one {منشور واحد} two {منشوران إثنان} few {{counter} منشورات} many {{counter} منشورًا} other {{counter} منشور}}",
"account.unblock": "إلغاء الحَظر عن @{name}",
"account.unblock_domain": "إلغاء الحَظر عن النِّطاق {domain}",
"account.unblock_short": "ألغ الحجب",

View file

@ -32,7 +32,6 @@
"account.followers": "Siguidores",
"account.followers.empty": "Naide sigue a esti perfil.",
"account.following": "Siguiendo",
"account.following_counter": "{count, plural,one {Sigue a {counter}} other {Sigue a {counter}}}",
"account.follows.empty": "Esti perfil nun sigue a naide.",
"account.go_to_profile": "Dir al perfil",
"account.hide_reblogs": "Anubrir los artículos compartíos de @{name}",
@ -49,7 +48,6 @@
"account.report": "Informar de: @{name}",
"account.requested_follow": "{name} solicitó siguite",
"account.show_reblogs": "Amosar los artículos compartíos de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} artículu} other {{counter} artículos}}",
"account.unblock": "Desbloquiar a @{name}",
"account.unblock_domain": "Desbloquiar el dominiu «{domain}»",
"account.unblock_short": "Desbloquiar",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Падпісацца ў адказ",
"account.followers": "Падпісчыкі",
"account.followers.empty": "Ніхто пакуль не падпісаны на гэтага карыстальніка.",
"account.followers_counter": "{count, plural, one {{counter} падпісчык} few {{counter} падпісчыкі} many {{counter} падпісчыкаў} other {{counter} падпісчыка}}",
"account.following": "Падпіскі",
"account.following_counter": "{count, plural, one {{counter} падпіска} few {{counter} падпіскі} many {{counter} падпісак} other {{counter} падпіскі}}",
"account.follows.empty": "Карыстальнік ні на каго не падпісаны.",
"account.go_to_profile": "Перайсці да профілю",
"account.hide_reblogs": "Схаваць пашырэнні ад @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} адправіў запыт на падпіску",
"account.share": "Абагуліць профіль @{name}",
"account.show_reblogs": "Паказаць падштурхоўванні ад @{name}",
"account.statuses_counter": "{count, plural, one {{counter} допіс} few {{counter} допісы} many {{counter} допісаў} other {{counter} допісу}}",
"account.unblock": "Разблакіраваць @{name}",
"account.unblock_domain": "Разблакіраваць дамен {domain}",
"account.unblock_short": "Разблакіраваць",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Последване взаимно",
"account.followers": "Последователи",
"account.followers.empty": "Още никой не следва потребителя.",
"account.followers_counter": "{count, plural, one {{counter} последовател} other {{counter} последователи}}",
"account.following": "Последвано",
"account.following_counter": "{count, plural, one {{counter} последван} other {{counter} последвани}}",
"account.follows.empty": "Потребителят още никого не следва.",
"account.go_to_profile": "Към профила",
"account.hide_reblogs": "Скриване на подсилвания от @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} поиска да ви последва",
"account.share": "Споделяне на профила на @{name}",
"account.show_reblogs": "Показване на подсилвания от @{name}",
"account.statuses_counter": "{count, plural, one {{counter} публикация} other {{counter} публикации}}",
"account.unblock": "Отблокиране на @{name}",
"account.unblock_domain": "Отблокиране на домейн {domain}",
"account.unblock_short": "Отблокиране",

View file

@ -33,9 +33,7 @@
"account.follow": "অনুসরণ",
"account.followers": "অনুসরণকারী",
"account.followers.empty": "এই ব্যক্তিকে এখনো কেউ অনুসরণ করে না.",
"account.followers_counter": "{count, plural,one {{counter} জন অনুসরণকারী } other {{counter} জন অনুসরণকারী}}",
"account.following": "অনুসরণ করা হচ্ছে",
"account.following_counter": "{count, plural,one {{counter} জনকে অনুসরণ} other {{counter} জনকে অনুসরণ}}",
"account.follows.empty": "এই সদস্য কাউকে এখনো ফলো করেন না.",
"account.go_to_profile": "প্রোফাইলে যান",
"account.hide_reblogs": "@{name}'র সমর্থনগুলি লুকিয়ে ফেলুন",
@ -60,7 +58,6 @@
"account.requested_follow": "{name} আপনাকে অনুসরণ করার জন্য অনুরোধ করেছে",
"account.share": "@{name} র প্রোফাইল অন্যদের দেখান",
"account.show_reblogs": "@{name} র সমর্থনগুলো দেখান",
"account.statuses_counter": "{count, plural,one {{counter} টুট} other {{counter} টুট}}",
"account.unblock": "@{name} র কার্যকলাপ দেখুন",
"account.unblock_domain": "{domain} কে আবার দেখুন",
"account.unblock_short": "আনব্লক করুন",

View file

@ -35,9 +35,8 @@
"account.follow_back": "Heuliañ d'ho tro",
"account.followers": "Tud koumanantet",
"account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.",
"account.followers_counter": "{count, plural, other{{counter} Heulier·ez}}",
"account.followers_counter": "{count, plural, one {{counter} heulier} two {{counter} heulier} few {{counter} heulier} many {{counter} heulier} other {{counter} heulier}}",
"account.following": "Koumanantoù",
"account.following_counter": "{count, plural, one{{counter} C'houmanant} two{{counter} Goumanant} other {{counter} a Goumanant}}",
"account.follows.empty": "An implijer·ez-mañ na heul den ebet.",
"account.go_to_profile": "Gwelet ar profil",
"account.hide_reblogs": "Kuzh skignadennoù gant @{name}",
@ -62,7 +61,7 @@
"account.requested_follow": "Gant {name} eo bet goulennet ho heuliañ",
"account.share": "Skignañ profil @{name}",
"account.show_reblogs": "Diskouez skignadennoù @{name}",
"account.statuses_counter": "{count, plural, one {{counter} C'hannad} two {{counter} Gannad} other {{counter} a Gannadoù}}",
"account.statuses_counter": "{count, plural, one {{counter} embannadur} two {{counter} embannadur} few {{counter} embannadur} many {{counter} embannadur} other {{counter} embannadur}}",
"account.unblock": "Diverzañ @{name}",
"account.unblock_domain": "Diverzañ an domani {domain}",
"account.unblock_short": "Distankañ",

View file

@ -35,9 +35,9 @@
"account.follow_back": "Segueix tu també",
"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_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidors}}",
"account.following": "Seguint",
"account.following_counter": "{count, plural, other {{counter} Seguint-ne}}",
"account.following_counter": "{count, plural, other {Seguint-ne {counter}}}",
"account.follows.empty": "Aquest usuari encara no segueix ningú.",
"account.go_to_profile": "Vés al perfil",
"account.hide_reblogs": "Amaga els impulsos de @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} ha demanat de seguir-te",
"account.share": "Comparteix el perfil de @{name}",
"account.show_reblogs": "Mostra els impulsos de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Tut} other {{counter} Tuts}}",
"account.statuses_counter": "{count, plural, one {{counter} publicació} other {{counter} publicacions}}",
"account.unblock": "Desbloca @{name}",
"account.unblock_domain": "Desbloca el domini {domain}",
"account.unblock_short": "Desbloca",

View file

@ -35,9 +35,7 @@
"account.follow_back": "فۆڵۆو بکەنەوە",
"account.followers": "شوێنکەوتووان",
"account.followers.empty": "کەسێک شوێن ئەم بەکارهێنەرە نەکەوتووە",
"account.followers_counter": "{count, plural, one {{counter} شوێنکەوتوو} other {{counter} شوێنکەوتوو}}",
"account.following": "بەدوادا",
"account.following_counter": "{count, plural, one {{counter} شوێنکەوتوو} other {{counter} شوێنکەوتوو}}",
"account.follows.empty": "ئەم بەکارهێنەرە تا ئێستا شوێن کەس نەکەوتووە.",
"account.go_to_profile": "بڕۆ بۆ پڕۆفایلی",
"account.hide_reblogs": "داشاردنی بووستەکان لە @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} داوای کردووە شوێنت بکەوێت",
"account.share": "پرۆفایلی @{name} هاوبەش بکە",
"account.show_reblogs": "پیشاندانی بەرزکردنەوەکان لە @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
"account.unblock": "@{name} لاببە",
"account.unblock_domain": "کردنەوەی دۆمەینی {domain}",
"account.unblock_short": "لابردنی بەربەست",

View file

@ -16,8 +16,6 @@
"account.follow": "Siguità",
"account.followers": "Abbunati",
"account.followers.empty": "Nisunu hè abbunatu à st'utilizatore.",
"account.followers_counter": "{count, plural, one {{counter} Abbunatu} other {{counter} Abbunati}}",
"account.following_counter": "{count, plural, one {{counter} Abbunamentu} other {{counter} Abbunamenti}}",
"account.follows.empty": "St'utilizatore ùn seguita nisunu.",
"account.hide_reblogs": "Piattà spartere da @{name}",
"account.link_verified_on": "A prupietà di stu ligame hè stata verificata u {date}",
@ -32,7 +30,6 @@
"account.requested": "In attesa d'apprubazione. Cliccate per annullà a dumanda",
"account.share": "Sparte u prufile di @{name}",
"account.show_reblogs": "Vede spartere da @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Statutu} other {{counter} Statuti}}",
"account.unblock": "Sbluccà @{name}",
"account.unblock_domain": "Ùn piattà più {domain}",
"account.unendorse": "Ùn fà figurà nant'à u prufilu",

View file

@ -20,7 +20,7 @@
"account.block_short": "Zablokovat",
"account.blocked": "Blokovaný",
"account.browse_more_on_origin_server": "Více na původním profilu",
"account.cancel_follow_request": "Zrušit žádost o sledování",
"account.cancel_follow_request": "Zrušit sledování",
"account.copy": "Kopírovat odkaz na profil",
"account.direct": "Soukromě zmínit @{name}",
"account.disable_notifications": "Přestat mě upozorňovat, když @{name} zveřejní příspěvek",
@ -35,9 +35,9 @@
"account.follow_back": "Také sledovat",
"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_counter": "{count, plural, one {{counter} sledující} few {{counter} sledující} many {{counter} sledujících} other {{counter} sledujících}}",
"account.following": "Sledujete",
"account.following_counter": "{count, plural, one {{counter} Sledovaný} few {{counter} Sledovaní} many {{counter} Sledovaných} other {{counter} Sledovaných}}",
"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.",
"account.go_to_profile": "Přejít na profil",
"account.hide_reblogs": "Skrýt boosty od @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} tě požádal o sledování",
"account.share": "Sdílet profil @{name}",
"account.show_reblogs": "Zobrazit boosty od @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Příspěvek} few {{counter} Příspěvky} many {{counter} Příspěvků} other {{counter} Příspěvků}}",
"account.statuses_counter": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}",
"account.unblock": "Odblokovat @{name}",
"account.unblock_domain": "Odblokovat doménu {domain}",
"account.unblock_short": "Odblokovat",
@ -78,9 +78,9 @@
"admin.dashboard.retention.average": "Průměr",
"admin.dashboard.retention.cohort": "Měsíc registrace",
"admin.dashboard.retention.cohort_size": "Noví uživatelé",
"admin.impact_report.instance_accounts": "Profily účtů, které by odstranily",
"admin.impact_report.instance_followers": "Sledovatelé, o které by naši uživatelé přišli",
"admin.impact_report.instance_follows": "Následovníci jejich uživatelé by ztratili",
"admin.impact_report.instance_accounts": "Profily účtů, které by byli odstaněny",
"admin.impact_report.instance_followers": "Sledující, o které by naši uživatelé přišli",
"admin.impact_report.instance_follows": "Sledující, o které by naši uživatelé přišli",
"admin.impact_report.title": "Shrnutí dopadu",
"alert.rate_limited.message": "Zkuste to prosím znovu po {retry_time, time, medium}.",
"alert.rate_limited.title": "Spojení omezena",
@ -89,7 +89,7 @@
"announcement.announcement": "Oznámení",
"attachments_list.unprocessed": "(nezpracováno)",
"audio.hide": "Skrýt zvuk",
"block_modal.remote_users_caveat": "Požádáme server {domain}, aby respektoval vaše rozhodnutí. Úplné dodržování nastavení však není zaručeno, protože některé servery mohou řešit blokování různě. Veřejné příspěvky mohou být stále viditelné pro nepřihlášené uživatele.",
"block_modal.remote_users_caveat": "Požádáme server {domain}, aby respektoval vaše rozhodnutí. Úplné dodržování nastavení však není zaručeno, protože některé servery mohou řešit blokování různě. Veřejné příspěvky mohou stále být viditelné pro nepřihlášené uživatele.",
"block_modal.show_less": "Zobrazit méně",
"block_modal.show_more": "Zobrazit více",
"block_modal.they_cant_mention": "Nemůže vás zmiňovat ani sledovat.",
@ -414,6 +414,8 @@
"limited_account_hint.action": "Přesto profil zobrazit",
"limited_account_hint.title": "Tento profil byl skryt moderátory {domain}.",
"link_preview.author": "Podle {name}",
"link_preview.more_from_author": "Více od {name}",
"link_preview.shares": "{count, plural, one {{counter} příspěvek} few {{counter} příspěvky} many {{counter} příspěvků} other {{counter} příspěvků}}",
"lists.account.add": "Přidat do seznamu",
"lists.account.remove": "Odebrat ze seznamu",
"lists.delete": "Smazat seznam",
@ -694,8 +696,11 @@
"server_banner.about_active_users": "Lidé používající tento server během posledních 30 dní (měsíční aktivní uživatelé)",
"server_banner.active_users": "aktivní uživatelé",
"server_banner.administered_by": "Spravováno:",
"server_banner.is_one_of_many": "{domain} je jedním z mnoha Mastodon serverů, které můžete použít k účasti na fediversu.",
"server_banner.server_stats": "Statistiky serveru:",
"sign_in_banner.create_account": "Vytvořit účet",
"sign_in_banner.follow_anyone": "Sledujte kohokoli napříč fediversem a uvidíte vše v chronologickém pořadí. Bez algoritmů, reklam a clickbaitu.",
"sign_in_banner.mastodon_is": "Mastodon je ten nejlepší způsob, jak udržet krok s tím, co se právě děje.",
"sign_in_banner.sign_in": "Přihlásit se",
"sign_in_banner.sso_redirect": "Přihlášení nebo Registrace",
"status.admin_account": "Otevřít moderátorské rozhraní pro @{name}",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Dilyn yn ôl",
"account.followers": "Dilynwyr",
"account.followers.empty": "Does neb yn dilyn y defnyddiwr hwn eto.",
"account.followers_counter": "{count, plural, one {Dilynwr: {counter}} other {Dilynwyr: {counter}}}",
"account.following": "Yn dilyn",
"account.following_counter": "{count, plural, one {Yn dilyn: {counter}} other {Yn dilyn: {counter}}}",
"account.follows.empty": "Nid yw'r defnyddiwr hwn yn dilyn unrhyw un eto.",
"account.go_to_profile": "Mynd i'r proffil",
"account.hide_reblogs": "Cuddio hybiau gan @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "Mae {name} wedi gwneud cais i'ch dilyn",
"account.share": "Rhannwch broffil @{name}",
"account.show_reblogs": "Dangos hybiau gan @{name}",
"account.statuses_counter": "{count, plural, one {Postiad: {counter}} other {Postiad: {counter}}}",
"account.unblock": "Dadflocio @{name}",
"account.unblock_domain": "Dadflocio parth {domain}",
"account.unblock_short": "Dadflocio",

View file

@ -35,9 +35,8 @@
"account.follow_back": "Følg tilbage",
"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_counter": "{count, plural, one {{counter} følger} other {{counter} følgere}}",
"account.following": "Følger",
"account.following_counter": "{count, plural, one {{counter} Følges} other {{counter} Følges}}",
"account.follows.empty": "Denne bruger følger ikke nogen endnu.",
"account.go_to_profile": "Gå til profil",
"account.hide_reblogs": "Skjul boosts fra @{name}",
@ -63,7 +62,6 @@
"account.requested_follow": "{name} har anmodet om at følge dig",
"account.share": "Del @{name}s profil",
"account.show_reblogs": "Vis fremhævelser fra @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Indlæg} other {{counter} Indlæg}}",
"account.unblock": "Afblokér @{name}",
"account.unblock_domain": "Afblokér domænet {domain}",
"account.unblock_short": "Afblokér",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Ακολούθησε και εσύ",
"account.followers": "Ακόλουθοι",
"account.followers.empty": "Κανείς δεν ακολουθεί αυτόν τον χρήστη ακόμα.",
"account.followers_counter": "{count, plural, one {{counter} Ακόλουθος} other {{counter} Ακόλουθοι}}",
"account.following": "Ακολουθείτε",
"account.following_counter": "{count, plural, one {{counter} Ακολουθεί} other {{counter} Ακολουθούν}}",
"account.follows.empty": "Αυτός ο χρήστης δεν ακολουθεί κανέναν ακόμα.",
"account.go_to_profile": "Μετάβαση στο προφίλ",
"account.hide_reblogs": "Απόκρυψη ενισχύσεων από @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "Ο/Η {name} αιτήθηκε να σε ακολουθήσει",
"account.share": "Κοινοποίηση του προφίλ @{name}",
"account.show_reblogs": "Εμφάνιση ενισχύσεων από @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Ανάρτηση} other {{counter} Αναρτήσεις}}",
"account.unblock": "Άρση αποκλεισμού @{name}",
"account.unblock_domain": "Άρση αποκλεισμού του τομέα {domain}",
"account.unblock_short": "Άρση αποκλεισμού",

View file

@ -35,9 +35,9 @@
"account.follow_back": "Follow back",
"account.followers": "Followers",
"account.followers.empty": "No one follows this user yet.",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
"account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
"account.following": "Following",
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
"account.following_counter": "{count, plural, one {{counter} following} other {{counter} following}}",
"account.follows.empty": "This user doesn't follow anyone yet.",
"account.go_to_profile": "Go to profile",
"account.hide_reblogs": "Hide boosts from @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} has requested to follow you",
"account.share": "Share @{name}'s profile",
"account.show_reblogs": "Show boosts from @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unblock domain {domain}",
"account.unblock_short": "Unblock",
@ -414,6 +414,8 @@
"limited_account_hint.action": "Show profile anyway",
"limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.",
"link_preview.author": "By {name}",
"link_preview.more_from_author": "More from {name}",
"link_preview.shares": "{count, plural, one {{counter} post} other {{counter} posts}}",
"lists.account.add": "Add to list",
"lists.account.remove": "Remove from list",
"lists.delete": "Delete list",
@ -694,8 +696,11 @@
"server_banner.about_active_users": "People using this server during the last 30 days (Monthly Active Users)",
"server_banner.active_users": "active users",
"server_banner.administered_by": "Administered by:",
"server_banner.is_one_of_many": "{domain} is one of the many independent Mastodon servers you can use to participate in the fediverse.",
"server_banner.server_stats": "Server stats:",
"sign_in_banner.create_account": "Create account",
"sign_in_banner.follow_anyone": "Follow anyone across the fediverse and see it all in chronological order. No algorithms, ads, or clickbait in sight.",
"sign_in_banner.mastodon_is": "Mastodon is the best way to keep up with what's happening.",
"sign_in_banner.sign_in": "Sign in",
"sign_in_banner.sso_redirect": "Login or Register",
"status.admin_account": "Open moderation interface for @{name}",

View file

@ -35,9 +35,9 @@
"account.follow_back": "Follow back",
"account.followers": "Followers",
"account.followers.empty": "No one follows this user yet.",
"account.followers_counter": "{count, plural, one {{counter} Follower} other {{counter} Followers}}",
"account.followers_counter": "{count, plural, one {{counter} follower} other {{counter} followers}}",
"account.following": "Following",
"account.following_counter": "{count, plural, one {{counter} Following} other {{counter} Following}}",
"account.following_counter": "{count, plural, one {{counter} following} other {{counter} following}}",
"account.follows.empty": "This user doesn't follow anyone yet.",
"account.go_to_profile": "Go to profile",
"account.hide_reblogs": "Hide boosts from @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} has requested to follow you",
"account.share": "Share @{name}'s profile",
"account.show_reblogs": "Show boosts from @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Post} other {{counter} Posts}}",
"account.statuses_counter": "{count, plural, one {{counter} post} other {{counter} posts}}",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unblock domain {domain}",
"account.unblock_short": "Unblock",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Sekvu reen",
"account.followers": "Sekvantoj",
"account.followers.empty": "Ankoraŭ neniu sekvas ĉi tiun uzanton.",
"account.followers_counter": "{count, plural, one{{counter} Sekvanto} other {{counter} Sekvantoj}}",
"account.following": "Sekvatoj",
"account.following_counter": "{count, plural, one {{counter} Sekvato} other {{counter} Sekvatoj}}",
"account.follows.empty": "La uzanto ankoraŭ ne sekvas iun ajn.",
"account.go_to_profile": "Iri al profilo",
"account.hide_reblogs": "Kaŝi diskonigojn de @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} petis sekvi vin",
"account.share": "Diskonigi la profilon de @{name}",
"account.show_reblogs": "Montri diskonigojn de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Afiŝo} other {{counter} Afiŝoj}}",
"account.unblock": "Malbloki @{name}",
"account.unblock_domain": "Malbloki la domajnon {domain}",
"account.unblock_short": "Malbloki",

View file

@ -37,7 +37,7 @@
"account.followers.empty": "Todavía nadie sigue a este usuario.",
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
"account.following": "Siguiendo",
"account.following_counter": "{count, plural, other {Siguiendo a {counter}}}",
"account.following_counter": "{count, plural, one {siguiendo a {counter}} other {siguiendo a {counter}}}",
"account.follows.empty": "Todavía este usuario no sigue a nadie.",
"account.go_to_profile": "Ir al perfil",
"account.hide_reblogs": "Ocultar adhesiones de @{name}",

View file

@ -35,9 +35,9 @@
"account.follow_back": "Seguir también",
"account.followers": "Seguidores",
"account.followers.empty": "Todavía nadie sigue a este usuario.",
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
"account.following": "Siguiendo",
"account.following_counter": "{count, plural, other {{counter} Siguiendo}}",
"account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
"account.follows.empty": "Este usuario todavía no sigue a nadie.",
"account.go_to_profile": "Ir al perfil",
"account.hide_reblogs": "Ocultar retoots de @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} ha solicitado seguirte",
"account.share": "Compartir el perfil de @{name}",
"account.show_reblogs": "Mostrar retoots de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Toot} other {{counter} Toots}}",
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Mostrar a {domain}",
"account.unblock_short": "Desbloquear",

View file

@ -35,9 +35,9 @@
"account.follow_back": "Seguir también",
"account.followers": "Seguidores",
"account.followers.empty": "Todavía nadie sigue a este usuario.",
"account.followers_counter": "{count, plural, one {{counter} Seguidor} other {{counter} Seguidores}}",
"account.followers_counter": "{count, plural, one {{counter} seguidor} other {{counter} seguidores}}",
"account.following": "Siguiendo",
"account.following_counter": "{count, plural, other {Siguiendo a {counter}}}",
"account.following_counter": "{count, plural, one {{counter} siguiendo} other {{counter} siguiendo}}",
"account.follows.empty": "Este usuario todavía no sigue a nadie.",
"account.go_to_profile": "Ir al perfil",
"account.hide_reblogs": "Ocultar impulsos de @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} ha solicitado seguirte",
"account.share": "Compartir el perfil de @{name}",
"account.show_reblogs": "Mostrar impulsos de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Publicación} other {{counter} Publicaciones}}",
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicaciones}}",
"account.unblock": "Desbloquear a @{name}",
"account.unblock_domain": "Desbloquear dominio {domain}",
"account.unblock_short": "Desbloquear",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Jälgi vastu",
"account.followers": "Jälgijad",
"account.followers.empty": "Keegi ei jälgi veel seda kasutajat.",
"account.followers_counter": "{count, plural, one {{counter} jälgija} other {{counter} jälgijat}}",
"account.following": "Jälgib",
"account.following_counter": "{count, plural, one {{counter} jälgitav} other {{counter} jälgitavat}}",
"account.follows.empty": "See kasutaja ei jälgi veel kedagi.",
"account.go_to_profile": "Mine profiilile",
"account.hide_reblogs": "Peida @{name} jagamised",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} on taodelnud sinu jälgimist",
"account.share": "Jaga @{name} profiili",
"account.show_reblogs": "Näita @{name} jagamisi",
"account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
"account.unblock": "Eemalda blokeering @{name}",
"account.unblock_domain": "Tee {domain} nähtavaks",
"account.unblock_short": "Eemalda blokeering",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Jarraitu bueltan",
"account.followers": "Jarraitzaileak",
"account.followers.empty": "Ez du inork erabiltzaile hau jarraitzen oraindik.",
"account.followers_counter": "{count, plural, one {Jarraitzaile {counter}} other {{counter} jarraitzaile}}",
"account.following": "Jarraitzen",
"account.following_counter": "{count, plural, one {{counter} jarraitzen} other {{counter} jarraitzen}}",
"account.follows.empty": "Erabiltzaile honek ez du inor jarraitzen oraindik.",
"account.go_to_profile": "Joan profilera",
"account.hide_reblogs": "Ezkutatu @{name} erabiltzailearen bultzadak",
@ -63,7 +61,6 @@
"account.requested_follow": "{name}-(e)k zu jarraitzeko eskaera egin du",
"account.share": "Partekatu @{name} erabiltzailearen profila",
"account.show_reblogs": "Erakutsi @{name} erabiltzailearen bultzadak",
"account.statuses_counter": "{count, plural, one {Bidalketa {counter}} other {{counter} bidalketa}}",
"account.unblock": "Desblokeatu @{name}",
"account.unblock_domain": "Berriz erakutsi {domain}",
"account.unblock_short": "Desblokeatu",

View file

@ -35,9 +35,7 @@
"account.follow_back": "دنبال کردن متقابل",
"account.followers": "پی‌گیرندگان",
"account.followers.empty": "هنوز کسی پی‌گیر این کاربر نیست.",
"account.followers_counter": "{count, plural, one {{counter} پی‌گیرنده} other {{counter} پی‌گیرنده}}",
"account.following": "پی می‌گیرید",
"account.following_counter": "{count, plural, one {{counter} پی‌گرفته} other {{counter} پی‌گرفته}}",
"account.follows.empty": "این کاربر هنوز پی‌گیر کسی نیست.",
"account.go_to_profile": "رفتن به نمایه",
"account.hide_reblogs": "نهفتن تقویت‌های @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} درخواست پی‌گیریتان را داد",
"account.share": "هم‌رسانی نمایهٔ @{name}",
"account.show_reblogs": "نمایش تقویت‌های @{name}",
"account.statuses_counter": "{count, plural, one {{counter} فرسته} other {{counter} فرسته}}",
"account.unblock": "رفع مسدودیت @{name}",
"account.unblock_domain": "رفع مسدودیت دامنهٔ {domain}",
"account.unblock_short": "رفع مسدودیت",

View file

@ -699,7 +699,7 @@
"server_banner.is_one_of_many": "{domain} on yksi monista itsenäisistä Mastodon-palvelimista, joiden välityksellä voit toimia fediversumissa.",
"server_banner.server_stats": "Palvelimen tilastot:",
"sign_in_banner.create_account": "Luo tili",
"sign_in_banner.follow_anyone": "Seuraa kenen tahansa julkaisuja fediversumissa ja näe ne kaikki aikajärjestyksessä. Ei algoritmejä, mainoksia tai klikkikalastelua.",
"sign_in_banner.follow_anyone": "Seuraa kenen tahansa julkaisuja fediversumissa ja näe ne kaikki aikajärjestyksessä. Ei algoritmeja, mainoksia tai klikkausten kalastelua.",
"sign_in_banner.mastodon_is": "Mastodon on paras tapa pysyä ajan tasalla siitä, mitä ympärillä tapahtuu.",
"sign_in_banner.sign_in": "Kirjaudu",
"sign_in_banner.sso_redirect": "Kirjaudu tai rekisteröidy",

View file

@ -118,6 +118,7 @@
"confirmations.delete_list.confirm": "Tanggalin",
"confirmations.delete_list.message": "Sigurado ka bang gusto mong burahin ang listahang ito?",
"confirmations.discard_edit_media.confirm": "Ipagpaliban",
"confirmations.domain_block.confirm": "Harangan ang serbiro",
"confirmations.edit.confirm": "Baguhin",
"confirmations.reply.confirm": "Tumugon",
"conversation.mark_as_read": "Markahan bilang nabasa na",
@ -186,6 +187,7 @@
"follow_request.authorize": "Tanggapin",
"follow_request.reject": "Tanggihan",
"follow_suggestions.dismiss": "Huwag nang ipakita muli",
"follow_suggestions.popular_suggestion_longer": "Sikat sa {domain}",
"follow_suggestions.view_all": "Tingnan lahat",
"follow_suggestions.who_to_follow": "Sinong maaaring sundan",
"footer.about": "Tungkol dito",
@ -220,6 +222,7 @@
"link_preview.author": "Ni/ng {name}",
"lists.account.add": "Idagdag sa talaan",
"lists.account.remove": "Tanggalin mula sa talaan",
"lists.delete": "Burahin ang talaan",
"lists.new.create": "Idagdag sa talaan",
"lists.new.title_placeholder": "Bagong pangalan ng talaan",
"lists.replies_policy.title": "Ipakita ang mga tugon sa:",
@ -287,9 +290,13 @@
"reply_indicator.cancel": "Ipagpaliban",
"report.block": "Harangan",
"report.categories.other": "Iba pa",
"report.categories.violation": "Lumalabag ang nilalaman sa isa o higit pang mga patakaran ng serbiro",
"report.category.subtitle": "Piliin ang pinakamahusay na tugma",
"report.category.title": "Sabihin mo sa amin kung anong nangyari sa {type} na ito",
"report.close": "Tapos na",
"report.next": "Sunod",
"report.placeholder": "Mga Karagdagang Puna",
"report.reasons.dislike": "Hindi ko gusto ito",
"report.reasons.violation": "Lumalabag ito sa mga panuntunan ng serbiro",
"report.reasons.violation_description": "Alam mo na lumalabag ito sa mga partikular na panuntunan",
"report.rules.title": "Aling mga patakaran ang nilabag?",

View file

@ -35,7 +35,7 @@
"account.follow_back": "Fylg aftur",
"account.followers": "Fylgjarar",
"account.followers.empty": "Ongar fylgjarar enn.",
"account.followers_counter": "{count, plural, one {{counter} Fylgjari} other {{counter} Fylgjarar}}",
"account.followers_counter": "{count, plural, one {{counter} fylgjari} other {{counter} fylgjarar}}",
"account.following": "Fylgir",
"account.following_counter": "{count, plural, one {{counter} fylgir} other {{counter} fylgja}}",
"account.follows.empty": "Hesin brúkari fylgir ongum enn.",

View file

@ -35,9 +35,7 @@
"account.follow_back": "S'abonner en retour",
"account.followers": "abonné·e·s",
"account.followers.empty": "Personne ne suit ce compte pour l'instant.",
"account.followers_counter": "{count, plural, one {{counter} Abonné·e} other {{counter} Abonné·e·s}}",
"account.following": "Abonné·e",
"account.following_counter": "{count, plural, one {{counter} Abonnement} other {{counter} Abonnements}}",
"account.follows.empty": "Ce compte ne suit personne présentement.",
"account.go_to_profile": "Voir ce profil",
"account.hide_reblogs": "Masquer les boosts de @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} a demandé à vous suivre",
"account.share": "Partager le profil de @{name}",
"account.show_reblogs": "Afficher les boosts de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Publication} other {{counter} Publications}}",
"account.unblock": "Débloquer @{name}",
"account.unblock_domain": "Débloquer le domaine {domain}",
"account.unblock_short": "Débloquer",

View file

@ -35,9 +35,7 @@
"account.follow_back": "S'abonner en retour",
"account.followers": "Abonné·e·s",
"account.followers.empty": "Personne ne suit cet·te utilisateur·rice pour linstant.",
"account.followers_counter": "{count, plural, one {{counter} Abonné·e} other {{counter} Abonné·e·s}}",
"account.following": "Abonnements",
"account.following_counter": "{count, plural, one {{counter} Abonnement} other {{counter} Abonnements}}",
"account.follows.empty": "Cet·te utilisateur·rice ne suit personne pour linstant.",
"account.go_to_profile": "Aller au profil",
"account.hide_reblogs": "Masquer les partages de @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} a demandé à vous suivre",
"account.share": "Partager le profil de @{name}",
"account.show_reblogs": "Afficher les partages de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Message} other {{counter} Messages}}",
"account.unblock": "Débloquer @{name}",
"account.unblock_domain": "Débloquer le domaine {domain}",
"account.unblock_short": "Débloquer",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Weromfolgje",
"account.followers": "Folgers",
"account.followers.empty": "Noch net ien folget dizze brûker.",
"account.followers_counter": "{count, plural, one {{counter} folger} other {{counter} folgers}}",
"account.following": "Folgjend",
"account.following_counter": "{count, plural, one {{counter} folgjend} other {{counter} folgjend}}",
"account.follows.empty": "Dizze brûker folget noch net ien.",
"account.go_to_profile": "Gean nei profyl",
"account.hide_reblogs": "Boosts fan @{name} ferstopje",
@ -63,7 +61,6 @@
"account.requested_follow": "{name} hat dy in folchfersyk stjoerd",
"account.share": "Profyl fan @{name} diele",
"account.show_reblogs": "Boosts fan @{name} toane",
"account.statuses_counter": "{count, plural, one {{counter} berjocht} other {{counter} berjochten}}",
"account.unblock": "@{name} deblokkearje",
"account.unblock_domain": "Domein {domain} deblokkearje",
"account.unblock_short": "Deblokkearje",

View file

@ -31,9 +31,7 @@
"account.follow": "Lean",
"account.followers": "Leantóirí",
"account.followers.empty": "Ní leanann éinne an t-úsáideoir seo fós.",
"account.followers_counter": "{count, plural, one {Leantóir amháin} other {{counter} Leantóir}}",
"account.following": "Ag leanúint",
"account.following_counter": "{count, plural, one {Ag leanúint cúntas amháin} other {Ag leanúint {counter} cúntas}}",
"account.follows.empty": "Ní leanann an t-úsáideoir seo duine ar bith fós.",
"account.go_to_profile": "Téigh go dtí próifíl",
"account.hide_reblogs": "Folaigh moltaí ó @{name}",
@ -55,7 +53,6 @@
"account.requested_follow": "D'iarr {name} ort do chuntas a leanúint",
"account.share": "Roinn próifíl @{name}",
"account.show_reblogs": "Taispeáin moltaí ó @{name}",
"account.statuses_counter": "{count, plural, one {Postáil amháin} other {{counter} Postáil}}",
"account.unblock": "Bain bac de @{name}",
"account.unblock_domain": "Bain bac den ainm fearainn {domain}",
"account.unblock_short": "Bain bac de",

View file

@ -35,9 +35,7 @@
"account.follow_back": "Lean air ais",
"account.followers": "Luchd-leantainn",
"account.followers.empty": "Chan eil neach sam bith a leantainn air a chleachdaiche seo fhathast.",
"account.followers_counter": "{count, plural, one {{counter} neach-leantainn} two {{counter} neach-leantainn} few {{counter} luchd-leantainn} other {{counter} luchd-leantainn}}",
"account.following": "A leantainn",
"account.following_counter": "{count, plural, one {A leantainn {counter}} two {A leantainn {counter}} few {A leantainn {counter}} other {A leantainn {counter}}}",
"account.follows.empty": "Chan eil an cleachdaiche seo a leantainn neach sam bith fhathast.",
"account.go_to_profile": "Tadhail air a phròifil",
"account.hide_reblogs": "Falaich na brosnachaidhean o @{name}",
@ -63,7 +61,6 @@
"account.requested_follow": "Dhiarr {name} gad leantainn",
"account.share": "Co-roinn a phròifil aig @{name}",
"account.show_reblogs": "Seall na brosnachaidhean o @{name}",
"account.statuses_counter": "{count, plural, one {{counter} phost} two {{counter} phost} few {{counter} postaichean} other {{counter} post}}",
"account.unblock": "Dì-bhac @{name}",
"account.unblock_domain": "Dì-bhac an àrainn {domain}",
"account.unblock_short": "Dì-bhac",

View file

@ -35,9 +35,9 @@
"account.follow_back": "Seguir tamén",
"account.followers": "Seguidoras",
"account.followers.empty": "Aínda ninguén segue esta usuaria.",
"account.followers_counter": "{count, plural, one {{counter} Seguidora} other {{counter} Seguidoras}}",
"account.followers_counter": "{count, plural, one {{counter} seguidora} other {{counter} seguidoras}}",
"account.following": "Seguindo",
"account.following_counter": "{count, plural, one {{counter} Seguindo} other {{counter} Seguindo}}",
"account.following_counter": "{count, plural, one {{counter} seguimento} other {{counter} seguimentos}}",
"account.follows.empty": "Esta usuaria aínda non segue a ninguén.",
"account.go_to_profile": "Ir ao perfil",
"account.hide_reblogs": "Agochar promocións de @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} solicitou seguirte",
"account.share": "Compartir o perfil de @{name}",
"account.show_reblogs": "Amosar compartidos de @{name}",
"account.statuses_counter": "{count, plural, one {{counter} Publicación} other {{counter} Publicacións}}",
"account.statuses_counter": "{count, plural, one {{counter} publicación} other {{counter} publicacións}}",
"account.unblock": "Desbloquear @{name}",
"account.unblock_domain": "Amosar {domain}",
"account.unblock_short": "Desbloquear",
@ -224,7 +224,7 @@
"domain_pill.their_server": "O seu fogar dixital, onde están as súas publicacións.",
"domain_pill.their_username": "O seu identificador único no seu servidor. É posible atopar usuarias co mesmo nome de usuaria en diferentes servidores.",
"domain_pill.username": "Nome de usuaria",
"domain_pill.whats_in_a_handle": "Que é o alcume?",
"domain_pill.whats_in_a_handle": "As partes do alcume?",
"domain_pill.who_they_are": "O alcume dinos quen é esa persoa e onde está, para que poidas interactuar con ela en toda a web social de <button>plataformas ActivityPub</button>.",
"domain_pill.who_you_are": "Como o teu alcume informa de quen es e onde estás, as persoas poden interactuar contigo desde toda a web social de <button>plataformas ActivityPub</button>.",
"domain_pill.your_handle": "O teu alcume:",

View file

@ -35,9 +35,9 @@
"account.follow_back": "לעקוב בחזרה",
"account.followers": "עוקבים",
"account.followers.empty": "אף אחד לא עוקב אחר המשתמש הזה עדיין.",
"account.followers_counter": "{count, plural,one {עוקב אחד} other {{counter} עוקבים}}",
"account.followers_counter": "{count, plural,one {עוקב אחד} other {{count} עוקבים}}",
"account.following": "נעקבים",
"account.following_counter": "{count, plural,one {עוקב אחרי {counter}}other {עוקב אחרי {counter}}}",
"account.following_counter": "{count, plural,one {עוקב אחרי {count}}other {עוקב אחרי {count}}}",
"account.follows.empty": "משתמש זה עדיין לא עוקב אחרי אף אחד.",
"account.go_to_profile": "מעבר לפרופיל",
"account.hide_reblogs": "להסתיר הידהודים מאת @{name}",
@ -63,7 +63,7 @@
"account.requested_follow": "{name} ביקשו לעקוב אחריך",
"account.share": "שתף את הפרופיל של @{name}",
"account.show_reblogs": "הצג הדהודים מאת @{name}",
"account.statuses_counter": "{count, plural, one {הודעה} two {הודעותיים} many {{count} הודעות} other {{count} הודעות}}",
"account.statuses_counter": "{count, plural, one {הודעה אחת} two {הודעותיים} many {{count} הודעות} other {{count} הודעות}}",
"account.unblock": "להסיר חסימה ל- @{name}",
"account.unblock_domain": "הסירי את החסימה של קהילת {domain}",
"account.unblock_short": "הסר חסימה",
@ -696,8 +696,11 @@
"server_banner.about_active_users": "משתמשים פעילים בשרת ב־30 הימים האחרונים (משתמשים פעילים חודשיים)",
"server_banner.active_users": "משתמשים פעילים",
"server_banner.administered_by": "מנוהל ע\"י:",
"server_banner.is_one_of_many": "{domain} הוא שרת אחד משרתי מסטודון עצמאיים רבים שדרגם תוכלו להשתתף בפדיוורס (רשת חברתית מבוזרת).",
"server_banner.server_stats": "סטטיסטיקות שרת:",
"sign_in_banner.create_account": "יצירת חשבון",
"sign_in_banner.follow_anyone": "תוכלו לעקוב אחרי כל משמתמש בפדיוורס ולקרוא הכל לפי סדר הפרסום בציר הזמן. אין אלגוריתמים, פרסומות, או קליקבייט מטעם בעלי הרשת.",
"sign_in_banner.mastodon_is": "מסטודון הוא הדרך הטובה ביותר לעקוב אחרי מה שקורה.",
"sign_in_banner.sign_in": "התחברות",
"sign_in_banner.sso_redirect": "התחברות/הרשמה",
"status.admin_account": "פתח/י ממשק ניהול עבור @{name}",
@ -774,7 +777,7 @@
"timeline_hint.resources.followers": "עוקבים",
"timeline_hint.resources.follows": "נעקבים",
"timeline_hint.resources.statuses": "הודעות ישנות יותר",
"trends.counter_by_accounts": "{count, plural, one {אדם {count}} other {{count} א.נשים}} {days, plural, one {מאז אתמול} two {ביומיים האחרונים} other {במשך {days} הימים האחרונים}}",
"trends.counter_by_accounts": "{count, plural, one {אדם אחד} other {{count} א.נשים}} {days, plural, one {מאז אתמול} two {ביומיים האחרונים} other {במשך {days} הימים האחרונים}}",
"trends.trending_now": "נושאים חמים",
"ui.beforeunload": "הטיוטא תאבד אם תעזבו את מסטודון.",
"units.short.billion": "{count} מליארד",

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