Merge remote-tracking branch 'origin/kb_migration' into kb_development

This commit is contained in:
KMY 2023-06-06 18:26:10 +09:00
commit aa86bf9e96
309 changed files with 4605 additions and 6803 deletions

View file

@ -81,6 +81,15 @@ module.exports = {
{ property: 'substring', message: 'Use .slice instead of .substring.' }, { property: 'substring', message: 'Use .slice instead of .substring.' },
{ property: 'substr', message: 'Use .slice instead of .substr.' }, { property: 'substr', message: 'Use .slice instead of .substr.' },
], ],
'no-restricted-syntax': [
'error',
{
// eslint-disable-next-line no-restricted-syntax
selector: 'Literal[value=/•/], JSXText[value=/•/]',
// eslint-disable-next-line no-restricted-syntax
message: "Use '·' (middle dot) instead of '•' (bullet)",
},
],
'no-self-assign': 'off', 'no-self-assign': 'off',
'no-unused-expressions': 'error', 'no-unused-expressions': 'error',
'no-unused-vars': 'off', 'no-unused-vars': 'off',
@ -293,6 +302,7 @@ module.exports = {
'.*rc.js', '.*rc.js',
'ide-helper.js', 'ide-helper.js',
'config/webpack/**/*', 'config/webpack/**/*',
'config/formatjs-formatter.js',
], ],
env: { env: {
@ -323,7 +333,7 @@ module.exports = {
'plugin:import/recommended', 'plugin:import/recommended',
'plugin:import/typescript', 'plugin:import/typescript',
'plugin:promise/recommended', 'plugin:promise/recommended',
'plugin:jsdoc/recommended', 'plugin:jsdoc/recommended-typescript',
'plugin:prettier/recommended', 'plugin:prettier/recommended',
], ],

View file

@ -25,18 +25,6 @@ updates:
- dependency-name: 'react-hotkeys' - dependency-name: 'react-hotkeys'
versions: versions:
- '>= 2' - '>= 2'
# TODO: This version has breaking changes
- dependency-name: 'intl-messageformat'
versions:
- '>= 3'
# TODO: This version has breaking changes
- dependency-name: 'react-intl'
versions:
- '>= 3'
# TODO: This version has breaking changes
- dependency-name: 'babel-plugin-react-intl'
versions:
- '>= 7'
# TODO: This version requires code changes # TODO: This version requires code changes
- dependency-name: 'webpack-dev-server' - dependency-name: 'webpack-dev-server'
versions: versions:

View file

@ -41,8 +41,7 @@ jobs:
- name: Check for missing strings in English JSON - name: Check for missing strings in English JSON
run: | run: |
yarn build:development yarn i18n:extract --throws
yarn manage:translations en
git diff --exit-code git diff --exit-code
- name: Check locale file normalization - name: Check locale file normalization

View file

@ -48,4 +48,4 @@ jobs:
- run: echo "::add-matcher::.github/stylelint-matcher.json" - run: echo "::add-matcher::.github/stylelint-matcher.json"
- name: Stylelint - name: Stylelint
run: yarn test:lint:sass run: yarn lint:sass

View file

@ -48,7 +48,7 @@ jobs:
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: ESLint - name: ESLint
run: yarn test:lint:js --max-warnings 0 run: yarn lint:js --max-warnings 0
- name: Typecheck - name: Typecheck
run: yarn test:typecheck run: yarn typecheck

View file

@ -40,4 +40,4 @@ jobs:
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Prettier - name: Prettier
run: yarn prettier --check "**/*.json" run: yarn lint:json

View file

@ -5,6 +5,7 @@ on:
- 'dependabot/**' - 'dependabot/**'
paths: paths:
- '.github/workflows/lint-md.yml' - '.github/workflows/lint-md.yml'
- '.nvmrc'
- '.prettier*' - '.prettier*'
- '**/*.md' - '**/*.md'
- '!AUTHORS.md' - '!AUTHORS.md'
@ -14,6 +15,7 @@ on:
pull_request: pull_request:
paths: paths:
- '.github/workflows/lint-md.yml' - '.github/workflows/lint-md.yml'
- '.nvmrc'
- '.prettier*' - '.prettier*'
- '**/*.md' - '**/*.md'
- '!AUTHORS.md' - '!AUTHORS.md'
@ -32,9 +34,10 @@ jobs:
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
cache: yarn cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages - name: Install all yarn packages
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Prettier - name: Prettier
run: yarn prettier --check "**/*.md" run: yarn lint:md

View file

@ -42,4 +42,4 @@ jobs:
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Prettier - name: Prettier
run: yarn prettier --check "**/*.{yml,yaml}" run: yarn lint:yml

View file

@ -44,4 +44,4 @@ jobs:
run: yarn --frozen-lockfile run: yarn --frozen-lockfile
- name: Jest testing - name: Jest testing
run: yarn test:jest --reporters github-actions summary run: yarn jest --reporters github-actions summary

View file

@ -4,6 +4,11 @@ exclude:
- 'vendor/**/*' - 'vendor/**/*'
- lib/templates/haml/scaffold/_form.html.haml - lib/templates/haml/scaffold/_form.html.haml
require:
- ./lib/linter/haml_middle_dot.rb
linters: linters:
AltText: AltText:
enabled: true enabled: true
MiddleDot:
enabled: true

View file

@ -61,7 +61,7 @@ docker-compose.override.yml
/app/javascript/mastodon/features/emoji/emoji_map.json /app/javascript/mastodon/features/emoji/emoji_map.json
# Ignore locale files # Ignore locale files
/app/javascript/mastodon/locales /app/javascript/mastodon/locales/*.json
/config/locales /config/locales
# Ignore vendored CSS reset # Ignore vendored CSS reset

View file

@ -11,6 +11,7 @@ require:
- rubocop-rspec - rubocop-rspec
- rubocop-performance - rubocop-performance
- rubocop-capybara - rubocop-capybara
- ./lib/linter/rubocop_middle_dot
AllCops: AllCops:
TargetRubyVersion: 3.0 # Set to minimum supported version of CI TargetRubyVersion: 3.0 # Set to minimum supported version of CI
@ -53,6 +54,28 @@ Lint/UselessAccessModifier:
ContextCreatingMethods: ContextCreatingMethods:
- class_methods - class_methods
## Disable most Metrics/*Length cops
# Reason: those are often triggered and force significant refactors when this happend
# but the team feel they are not really improving the code quality.
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocklength
Metrics/BlockLength:
Enabled: false
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsclasslength
Metrics/ClassLength:
Enabled: false
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmethodlength
Metrics/MethodLength:
Enabled: false
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmodulelength
Metrics/ModuleLength:
Enabled: false
## End Disable Metrics/*Length cops
# Reason: Currently disabled in .rubocop_todo.yml # Reason: Currently disabled in .rubocop_todo.yml
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsabcsize
Metrics/AbcSize: Metrics/AbcSize:
@ -61,95 +84,12 @@ Metrics/AbcSize:
- 'lib/mastodon/cli/*.rb' - 'lib/mastodon/cli/*.rb'
- db/*migrate/**/* - db/*migrate/**/*
# Reason: Some functions cannot be broken up, but others may be refactor candidates
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocklength
Metrics/BlockLength:
CountAsOne: ['array', 'hash', 'heredoc', 'method_call']
Exclude:
- 'config/routes.rb'
- 'lib/mastodon/cli/*.rb'
- 'lib/tasks/*.rake'
- 'app/models/concerns/account_associations.rb'
- 'app/models/concerns/account_interactions.rb'
- 'app/models/concerns/ldap_authenticable.rb'
- 'app/models/concerns/omniauthable.rb'
- 'app/models/concerns/pam_authenticable.rb'
- 'app/models/concerns/remotable.rb'
- 'app/services/suspend_account_service.rb'
- 'app/services/unsuspend_account_service.rb'
- 'app/views/accounts/show.rss.ruby'
- 'app/views/tags/show.rss.ruby'
- 'config/environments/development.rb'
- 'config/environments/production.rb'
- 'config/initializers/devise.rb'
- 'config/initializers/doorkeeper.rb'
- 'config/initializers/omniauth.rb'
- 'config/initializers/simple_form.rb'
- 'config/navigation.rb'
- 'config/routes.rb'
- 'config/routes/*.rb'
- 'db/post_migrate/20221101190723_backfill_admin_action_logs.rb'
- 'db/post_migrate/20221206114142_backfill_admin_action_logs_again.rb'
- 'lib/paperclip/gif_transcoder.rb'
# Reason: # Reason:
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocknesting # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsblocknesting
Metrics/BlockNesting: Metrics/BlockNesting:
Exclude: Exclude:
- 'lib/mastodon/cli/*.rb' - 'lib/mastodon/cli/*.rb'
# Reason: Some Excluded files would be candidates for refactoring but not currently addressed
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsclasslength
Metrics/ClassLength:
CountAsOne: ['array', 'hash', 'heredoc', 'method_call']
Exclude:
- 'lib/mastodon/cli/*.rb'
- 'app/controllers/admin/accounts_controller.rb'
- 'app/controllers/api/base_controller.rb'
- 'app/controllers/api/v1/admin/accounts_controller.rb'
- 'app/controllers/application_controller.rb'
- 'app/controllers/auth/registrations_controller.rb'
- 'app/controllers/auth/sessions_controller.rb'
- 'app/lib/activitypub/activity.rb'
- 'app/lib/activitypub/activity/create.rb'
- 'app/lib/activitypub/activity/undo.rb'
- 'app/lib/activitypub/tag_manager.rb'
- 'app/lib/feed_manager.rb'
- 'app/lib/link_details_extractor.rb'
- 'app/lib/request.rb'
- 'app/lib/status_reach_finder.rb'
- 'app/lib/text_formatter.rb'
- 'app/lib/user_settings_decorator.rb'
- 'app/mailers/user_mailer.rb'
- 'app/models/account.rb'
- 'app/models/account_statuses_filter.rb'
- 'app/models/admin/account_action.rb'
- 'app/models/antenna.rb'
- 'app/models/form/account_batch.rb'
- 'app/models/media_attachment.rb'
- 'app/models/status.rb'
- 'app/models/tag.rb'
- 'app/models/user.rb'
- 'app/policies/status_policy.rb'
- 'app/serializers/activitypub/actor_serializer.rb'
- 'app/serializers/activitypub/note_serializer.rb'
- 'app/serializers/rest/account_serializer.rb'
- 'app/serializers/rest/status_serializer.rb'
- 'app/services/account_search_service.rb'
- 'app/services/activitypub/process_account_service.rb'
- 'app/services/activitypub/process_status_update_service.rb'
- 'app/services/backup_service.rb'
- 'app/services/bulk_import_service.rb'
- 'app/services/delete_account_service.rb'
- 'app/services/fan_out_on_write_service.rb'
- 'app/services/fetch_link_card_service.rb'
- 'app/services/import_service.rb'
- 'app/services/notify_service.rb'
- 'app/services/post_status_service.rb'
- 'app/services/search_service.rb'
- 'app/services/update_status_service.rb'
- 'lib/paperclip/color_extractor.rb'
# Reason: Currently disabled in .rubocop_todo.yml # Reason: Currently disabled in .rubocop_todo.yml
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity # https://docs.rubocop.org/rubocop/cops_metrics.html#metricscyclomaticcomplexity
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
@ -160,17 +100,10 @@ Metrics/CyclomaticComplexity:
- lib/mastodon/cli/*.rb - lib/mastodon/cli/*.rb
- db/*migrate/**/* - db/*migrate/**/*
# Reason: Currently disabled in .rubocop_todo.yml
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmethodlength
Metrics/MethodLength:
CountAsOne: [array, heredoc]
Exclude:
- 'lib/mastodon/cli/*.rb'
# Reason: # Reason:
# https://docs.rubocop.org/rubocop/cops_metrics.html#metricsmodulelength # https://docs.rubocop.org/rubocop/cops_metrics.html#metricsparameterlists
Metrics/ModuleLength: Metrics/ParameterLists:
CountAsOne: [array, heredoc] CountKeywordArgs: false
Metrics/PerceivedComplexity: Metrics/PerceivedComplexity:
Exclude: Exclude:
@ -281,3 +214,6 @@ Style/TrailingCommaInArrayLiteral:
# https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral # https://docs.rubocop.org/rubocop/cops_style.html#styletrailingcommainhashliteral
Style/TrailingCommaInHashLiteral: Style/TrailingCommaInHashLiteral:
EnforcedStyleForMultiline: 'comma' EnforcedStyleForMultiline: 'comma'
Style/MiddleDot:
Enabled: true

View file

@ -154,12 +154,6 @@ Lint/Void:
Metrics/AbcSize: Metrics/AbcSize:
Max: 150 Max: 150
# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns, inherit_mode.
# AllowedMethods: refine
Metrics/BlockLength:
Exclude:
- 'app/models/concerns/status_safe_reblog_insert.rb'
# Configuration parameters: CountBlocks, Max. # Configuration parameters: CountBlocks, Max.
Metrics/BlockNesting: Metrics/BlockNesting:
Exclude: Exclude:
@ -169,27 +163,6 @@ Metrics/BlockNesting:
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Max: 25 Max: 25
# Configuration parameters: CountComments, CountAsOne, AllowedMethods, AllowedPatterns.
Metrics/MethodLength:
Max: 58
# Configuration parameters: CountComments, Max, CountAsOne.
Metrics/ModuleLength:
Exclude:
- 'app/controllers/concerns/signature_verification.rb'
- 'app/helpers/application_helper.rb'
- 'app/helpers/jsonld_helper.rb'
- 'app/models/concerns/account_interactions.rb'
- 'app/models/concerns/has_user_settings.rb'
# Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters.
Metrics/ParameterLists:
Exclude:
- 'app/models/concerns/account_interactions.rb'
- 'app/services/activitypub/fetch_remote_account_service.rb'
- 'app/services/activitypub/fetch_remote_actor_service.rb'
- 'app/services/activitypub/fetch_remote_status_service.rb'
# Configuration parameters: AllowedMethods, AllowedPatterns. # Configuration parameters: AllowedMethods, AllowedPatterns.
Metrics/PerceivedComplexity: Metrics/PerceivedComplexity:
Max: 28 Max: 28

View file

@ -5,7 +5,7 @@ ruby '>= 3.0.0'
gem 'pkg-config', '~> 1.5' gem 'pkg-config', '~> 1.5'
gem 'puma', '~> 6.2' gem 'puma', '~> 6.3'
gem 'rails', '~> 6.1.7' gem 'rails', '~> 6.1.7'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.2' gem 'thor', '~> 1.2'
@ -17,10 +17,10 @@ gem 'makara', '~> 0.5'
gem 'pghero' gem 'pghero'
gem 'dotenv-rails', '~> 2.8' gem 'dotenv-rails', '~> 2.8'
gem 'aws-sdk-s3', '~> 1.122', require: false gem 'aws-sdk-s3', '~> 1.123', require: false
gem 'fog-core', '<= 2.4.0' gem 'fog-core', '<= 2.4.0'
gem 'fog-openstack', '~> 0.3', require: false gem 'fog-openstack', '~> 0.3', require: false
gem 'kt-paperclip', '~> 7.1', github: 'kreeti/kt-paperclip', ref: '11abf222dc31bff71160a1d138b445214f434b2b' gem 'kt-paperclip', '~> 7.2'
gem 'blurhash', '~> 0.1' gem 'blurhash', '~> 0.1'
gem 'active_model_serializers', '~> 0.10' gem 'active_model_serializers', '~> 0.10'
@ -60,7 +60,6 @@ gem 'kaminari', '~> 1.2'
gem 'link_header', '~> 0.0' gem 'link_header', '~> 0.0'
gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar' gem 'mime-types', '~> 3.4.1', require: 'mime/types/columnar'
gem 'nokogiri', '~> 1.15' gem 'nokogiri', '~> 1.15'
gem 'nsa', '~> 0.2'
gem 'oj', '~> 3.14' gem 'oj', '~> 3.14'
gem 'ox', '~> 2.14' gem 'ox', '~> 2.14'
gem 'parslet' gem 'parslet'

View file

@ -7,18 +7,6 @@ GIT
hkdf (~> 0.2) hkdf (~> 0.2)
jwt (~> 2.0) jwt (~> 2.0)
GIT
remote: https://github.com/kreeti/kt-paperclip.git
revision: 11abf222dc31bff71160a1d138b445214f434b2b
ref: 11abf222dc31bff71160a1d138b445214f434b2b
specs:
kt-paperclip (7.1.1)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
marcel (~> 1.0.1)
mime-types
terrapin (~> 0.6.0)
GIT GIT
remote: https://github.com/mastodon/rails-settings-cached.git remote: https://github.com/mastodon/rails-settings-cached.git
revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab revision: 86328ef0bd04ce21cc0504ff5e334591e8c2ccab
@ -109,17 +97,17 @@ GEM
attr_required (1.0.1) attr_required (1.0.1)
awrence (1.2.1) awrence (1.2.1)
aws-eventstream (1.2.0) aws-eventstream (1.2.0)
aws-partitions (1.761.0) aws-partitions (1.772.0)
aws-sdk-core (3.172.0) aws-sdk-core (3.174.0)
aws-eventstream (~> 1, >= 1.0.2) aws-eventstream (~> 1, >= 1.0.2)
aws-partitions (~> 1, >= 1.651.0) aws-partitions (~> 1, >= 1.651.0)
aws-sigv4 (~> 1.5) aws-sigv4 (~> 1.5)
jmespath (~> 1, >= 1.6.1) jmespath (~> 1, >= 1.6.1)
aws-sdk-kms (1.64.0) aws-sdk-kms (1.65.0)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sigv4 (~> 1.1) aws-sigv4 (~> 1.1)
aws-sdk-s3 (1.122.0) aws-sdk-s3 (1.123.0)
aws-sdk-core (~> 3, >= 3.165.0) aws-sdk-core (~> 3, >= 3.174.0)
aws-sdk-kms (~> 1) aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.4) aws-sigv4 (~> 1.4)
aws-sigv4 (1.5.2) aws-sigv4 (1.5.2)
@ -380,6 +368,12 @@ GEM
activerecord activerecord
kaminari-core (= 1.2.2) kaminari-core (= 1.2.2)
kaminari-core (1.2.2) kaminari-core (1.2.2)
kt-paperclip (7.2.0)
activemodel (>= 4.2.0)
activesupport (>= 4.2.0)
marcel (~> 1.0.1)
mime-types
terrapin (~> 0.6.0)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
letter_opener (1.8.1) letter_opener (1.8.1)
@ -442,11 +436,6 @@ GEM
nokogiri (1.15.2) nokogiri (1.15.2)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
nsa (0.2.8)
activesupport (>= 4.2, < 7)
concurrent-ruby (~> 1.0, >= 1.0.2)
sidekiq (>= 3.5)
statsd-ruby (~> 1.4, >= 1.4.0)
oj (3.14.3) oj (3.14.3)
omniauth (1.9.2) omniauth (1.9.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
@ -501,7 +490,7 @@ GEM
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0) private_address_check (0.5.0)
public_suffix (5.0.1) public_suffix (5.0.1)
puma (6.2.2) puma (6.3.0)
nio4r (~> 2.0) nio4r (~> 2.0)
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
@ -544,8 +533,9 @@ GEM
rails-dom-testing (2.0.3) rails-dom-testing (2.0.3)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.5.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.19, >= 2.19.1) loofah (~> 2.21)
nokogiri (~> 1.14)
rails-i18n (6.0.0) rails-i18n (6.0.0)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 7) railties (>= 6.0.0, < 7)
@ -588,7 +578,7 @@ GEM
rspec-mocks (3.12.5) rspec-mocks (3.12.5)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.12.0)
rspec-rails (6.0.2) rspec-rails (6.0.3)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
railties (>= 6.1) railties (>= 6.1)
@ -648,7 +638,7 @@ GEM
redis (>= 4.5.0, < 5) redis (>= 4.5.0, < 5)
sidekiq-bulk (0.2.0) sidekiq-bulk (0.2.0)
sidekiq sidekiq
sidekiq-scheduler (5.0.2) sidekiq-scheduler (5.0.3)
rufus-scheduler (~> 3.2) rufus-scheduler (~> 3.2)
sidekiq (>= 6, < 8) sidekiq (>= 6, < 8)
tilt (>= 1.4.0) tilt (>= 1.4.0)
@ -681,7 +671,6 @@ GEM
net-scp (>= 1.1.2) net-scp (>= 1.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0)
stoplight (3.0.1) stoplight (3.0.1)
redlock (~> 1.0) redlock (~> 1.0)
strong_migrations (0.8.0) strong_migrations (0.8.0)
@ -770,7 +759,7 @@ DEPENDENCIES
active_model_serializers (~> 0.10) active_model_serializers (~> 0.10)
addressable (~> 2.8) addressable (~> 2.8)
annotate (~> 3.2) annotate (~> 3.2)
aws-sdk-s3 (~> 1.122) aws-sdk-s3 (~> 1.123)
better_errors (~> 2.9) better_errors (~> 2.9)
binding_of_caller (~> 1.0) binding_of_caller (~> 1.0)
blurhash (~> 0.1) blurhash (~> 0.1)
@ -818,7 +807,7 @@ DEPENDENCIES
json-ld-preloaded (~> 3.2) json-ld-preloaded (~> 3.2)
json-schema (~> 4.0) json-schema (~> 4.0)
kaminari (~> 1.2) kaminari (~> 1.2)
kt-paperclip (~> 7.1)! kt-paperclip (~> 7.2)
letter_opener (~> 1.8) letter_opener (~> 1.8)
letter_opener_web (~> 2.0) letter_opener_web (~> 2.0)
link_header (~> 0.0) link_header (~> 0.0)
@ -830,7 +819,6 @@ DEPENDENCIES
net-http (~> 0.3.2) net-http (~> 0.3.2)
net-ldap (~> 0.18) net-ldap (~> 0.18)
nokogiri (~> 1.15) nokogiri (~> 1.15)
nsa (~> 0.2)
oj (~> 3.14) oj (~> 3.14)
omniauth (~> 1.9) omniauth (~> 1.9)
omniauth-cas (~> 2.0) omniauth-cas (~> 2.0)
@ -846,7 +834,7 @@ DEPENDENCIES
premailer-rails premailer-rails
private_address_check (~> 0.5) private_address_check (~> 0.5)
public_suffix (~> 5.0) public_suffix (~> 5.0)
puma (~> 6.2) puma (~> 6.3)
pundit (~> 2.3) pundit (~> 2.3)
rack (~> 2.2.7) rack (~> 2.2.7)
rack-attack (~> 6.6) rack-attack (~> 6.6)

View file

@ -31,31 +31,41 @@ module Admin
@domain_block = DomainBlock.new(resource_params) @domain_block = DomainBlock.new(resource_params)
existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil existing_domain_block = resource_params[:domain].present? ? DomainBlock.rule_for(resource_params[:domain]) : nil
# Disallow accidentally downgrading a domain block
if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block) if existing_domain_block.present? && !@domain_block.stricter_than?(existing_domain_block)
@domain_block.save @domain_block.save
flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe flash.now[:alert] = I18n.t('admin.domain_blocks.existing_domain_block_html', name: existing_domain_block.domain, unblock_url: admin_domain_block_path(existing_domain_block)).html_safe
@domain_block.errors.delete(:domain) @domain_block.errors.delete(:domain)
render :new return render :new
else end
if existing_domain_block.present?
@domain_block = existing_domain_block
@domain_block.update(resource_params)
end
if @domain_block.save # Allow transparently upgrading a domain block
DomainBlockWorker.perform_async(@domain_block.id) if existing_domain_block.present?
log_action :create, @domain_block @domain_block = existing_domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') @domain_block.assign_attributes(resource_params)
else end
render :new
end # Require explicit confirmation when suspending
return render :confirm_suspension if requires_confirmation?
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id)
log_action :create, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
else
render :new
end end
end end
def update def update
authorize :domain_block, :update? authorize :domain_block, :update?
if @domain_block.update(update_params) @domain_block.assign_attributes(update_params)
# Require explicit confirmation when suspending
return render :confirm_suspension if requires_confirmation?
if @domain_block.save
DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?) DomainBlockWorker.perform_async(@domain_block.id, @domain_block.severity_previously_changed?)
log_action :update, @domain_block log_action :update, @domain_block
redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg') redirect_to admin_instances_path(limited: '1'), notice: I18n.t('admin.domain_blocks.created_msg')
@ -92,5 +102,9 @@ module Admin
def action_from_button def action_from_button
'save' if params[:save] 'save' if params[:save]
end end
def requires_confirmation?
@domain_block.valid? && (@domain_block.new_record? || @domain_block.severity_changed?) && @domain_block.severity.to_s == 'suspend' && !params[:confirm]
end
end end
end end

View file

@ -11,7 +11,7 @@ class Api::V1::ConversationsController < Api::BaseController
def index def index
@conversations = paginated_conversations @conversations = paginated_conversations
render json: @conversations, each_serializer: REST::ConversationSerializer render json: @conversations, each_serializer: REST::ConversationSerializer, relationships: StatusRelationshipsPresenter.new(@conversations.map(&:last_status), current_user&.account_id)
end end
def read def read
@ -32,7 +32,20 @@ class Api::V1::ConversationsController < Api::BaseController
def paginated_conversations def paginated_conversations
AccountConversation.where(account: current_account) AccountConversation.where(account: current_account)
.to_a_paginated_by_id(limit_param(LIMIT), params_slice(:max_id, :since_id, :min_id)) .includes(
account: :account_stat,
last_status: [
:media_attachments,
:preview_cards,
:status_stat,
:tags,
{
active_mentions: [account: :account_stat],
account: :account_stat,
},
]
)
.to_a_paginated_by_id(limit_param(LIMIT), **params_slice(:max_id, :since_id, :min_id))
end end
def insert_pagination_headers def insert_pagination_headers

View file

@ -44,6 +44,6 @@ class Api::V1::ListsController < Api::BaseController
end end
def list_params def list_params
params.permit(:title, :replies_policy) params.permit(:title, :replies_policy, :exclusive)
end end
end end

View file

@ -11,15 +11,15 @@ class BackupsController < ApplicationController
def download def download
case Paperclip::Attachment.default_options[:storage] case Paperclip::Attachment.default_options[:storage]
when :s3 when :s3
redirect_to @backup.dump.expiring_url(10) redirect_to @backup.dump.expiring_url(10), allow_other_host: true
when :fog when :fog
if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present? if Paperclip::Attachment.default_options.dig(:fog_credentials, :openstack_temp_url_key).present?
redirect_to @backup.dump.expiring_url(Time.now.utc + 10) redirect_to @backup.dump.expiring_url(Time.now.utc + 10), allow_other_host: true
else else
redirect_to full_asset_url(@backup.dump.url) redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
end end
when :filesystem when :filesystem
redirect_to full_asset_url(@backup.dump.url) redirect_to full_asset_url(@backup.dump.url), allow_other_host: true
end end
end end

View file

@ -12,6 +12,7 @@ class Settings::ImportsController < Settings::BaseController
muting: 'muted_accounts_failures.csv', muting: 'muted_accounts_failures.csv',
domain_blocking: 'blocked_domains_failures.csv', domain_blocking: 'blocked_domains_failures.csv',
bookmarks: 'bookmarks_failures.csv', bookmarks: 'bookmarks_failures.csv',
lists: 'lists_failures.csv',
}.freeze }.freeze
TYPE_TO_HEADERS_MAP = { TYPE_TO_HEADERS_MAP = {
@ -20,6 +21,7 @@ class Settings::ImportsController < Settings::BaseController
muting: ['Account address', 'Hide notifications'], muting: ['Account address', 'Hide notifications'],
domain_blocking: false, domain_blocking: false,
bookmarks: false, bookmarks: false,
lists: false,
}.freeze }.freeze
def index def index
@ -49,6 +51,8 @@ class Settings::ImportsController < Settings::BaseController
csv << [row.data['domain']] csv << [row.data['domain']]
when :bookmarks when :bookmarks
csv << [row.data['uri']] csv << [row.data['uri']]
when :lists
csv << [row.data['list_name'], row.data['acct']]
end end
end end
end end

View file

@ -173,11 +173,11 @@ module ApplicationHelper
end end
def storage_host def storage_host
URI::HTTPS.build(host: storage_host_name).to_s "https://#{storage_host_var}"
end end
def storage_host? def storage_host?
storage_host_name.present? storage_host_var.present?
end end
def quote_wrap(text, line_width: 80, break_sequence: "\n") def quote_wrap(text, line_width: 80, break_sequence: "\n")
@ -248,7 +248,7 @@ module ApplicationHelper
private private
def storage_host_name def storage_host_var
ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil) ENV.fetch('S3_ALIAS_HOST', nil) || ENV.fetch('S3_CLOUDFRONT_HOST', nil)
end end
end end

View file

@ -1,7 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
# rubocop:disable Metrics/ModuleLength
module LanguagesHelper module LanguagesHelper
ISO_639_1 = { ISO_639_1 = {
aa: ['Afar', 'Afaraf'].freeze, aa: ['Afar', 'Afaraf'].freeze,

View file

@ -11,7 +11,7 @@ module ReactComponentHelper
end end
def react_admin_component(name, props = {}) def react_admin_component(name, props = {})
data = { 'admin-component': name.to_s.camelcase, props: Oj.dump({ locale: I18n.locale }.merge(props)) } data = { 'admin-component': name.to_s.camelcase, props: Oj.dump(props) }
div_tag_with_data(data) div_tag_with_data(data)
end end

View file

@ -5,10 +5,6 @@ module SettingsHelper
LanguagesHelper::SUPPORTED_LOCALES.keys LanguagesHelper::SUPPORTED_LOCALES.keys
end end
def hash_to_object(hash)
HashObject.new(hash)
end
def session_device_icon(session) def session_device_icon(session)
device = session.detection.device device = session.detection.device

View file

@ -6,7 +6,7 @@ import { unescapeHTML } from '../../utils/html';
const domParser = new DOMParser(); const domParser = new DOMParser();
const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => { const makeEmojiMap = emojis => emojis.reduce((obj, emoji) => {
obj[`:${emoji.shortcode}:`] = emoji; obj[`:${emoji.shortcode}:`] = emoji;
return obj; return obj;
}, {}); }, {});
@ -20,7 +20,7 @@ export function searchTextFromRawStatus (status) {
export function normalizeAccount(account) { export function normalizeAccount(account) {
account = { ...account }; account = { ...account };
const emojiMap = makeEmojiMap(account); const emojiMap = makeEmojiMap(account.emojis);
const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name; const displayName = account.display_name.trim().length === 0 ? account.username : account.display_name;
account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap); account.display_name_html = emojify(escapeTextContentForBrowser(displayName), emojiMap);
@ -98,7 +98,7 @@ export function normalizeStatus(status, normalOldStatus) {
const spoilerText = normalStatus.spoiler_text || ''; const spoilerText = normalStatus.spoiler_text || '';
const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n'); const searchContent = ([spoilerText, status.content].concat((status.poll && status.poll.options) ? status.poll.options.map(option => option.title) : [])).concat(status.media_attachments.map(att => att.description)).join('\n\n').replace(/<br\s*\/?>/g, '\n').replace(/<\/p><p>/g, '\n\n');
const emojiMap = makeEmojiMap(normalStatus); const emojiMap = makeEmojiMap(normalStatus.emojis);
normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
@ -120,22 +120,48 @@ export function normalizeEmojiReactions(emoji_reactions) {
return converted; return converted;
} }
export function normalizeStatusTranslation(translation, status) {
const emojiMap = makeEmojiMap(status.get('emojis').toJS());
const normalTranslation = {
detected_source_language: translation.detected_source_language,
language: translation.language,
provider: translation.provider,
contentHtml: emojify(translation.content, emojiMap),
spoilerHtml: emojify(escapeTextContentForBrowser(translation.spoiler_text), emojiMap),
spoiler_text: translation.spoiler_text,
};
return normalTranslation;
}
export function normalizePoll(poll) { export function normalizePoll(poll) {
const normalPoll = { ...poll }; const normalPoll = { ...poll };
const emojiMap = makeEmojiMap(normalPoll); const emojiMap = makeEmojiMap(poll.emojis);
normalPoll.options = poll.options.map((option, index) => ({ normalPoll.options = poll.options.map((option, index) => ({
...option, ...option,
voted: poll.own_votes && poll.own_votes.includes(index), voted: poll.own_votes && poll.own_votes.includes(index),
title_emojified: emojify(escapeTextContentForBrowser(option.title), emojiMap), titleHtml: emojify(escapeTextContentForBrowser(option.title), emojiMap),
})); }));
return normalPoll; return normalPoll;
} }
export function normalizePollOptionTranslation(translation, poll) {
const emojiMap = makeEmojiMap(poll.get('emojis').toJS());
const normalTranslation = {
...translation,
titleHtml: emojify(escapeTextContentForBrowser(translation.title), emojiMap),
};
return normalTranslation;
}
export function normalizeAnnouncement(announcement) { export function normalizeAnnouncement(announcement) {
const normalAnnouncement = { ...announcement }; const normalAnnouncement = { ...announcement };
const emojiMap = makeEmojiMap(normalAnnouncement); const emojiMap = makeEmojiMap(normalAnnouncement.emojis);
normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap); normalAnnouncement.contentHtml = emojify(normalAnnouncement.content, emojiMap);

View file

@ -151,10 +151,10 @@ export const createListFail = error => ({
error, error,
}); });
export const updateList = (id, title, shouldReset, replies_policy) => (dispatch, getState) => { export const updateList = (id, title, shouldReset, isExclusive, replies_policy) => (dispatch, getState) => {
dispatch(updateListRequest(id)); dispatch(updateListRequest(id));
api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy }).then(({ data }) => { api(getState).put(`/api/v1/lists/${id}`, { title, replies_policy, exclusive: typeof isExclusive === 'undefined' ? undefined : !!isExclusive }).then(({ data }) => {
dispatch(updateListSuccess(data)); dispatch(updateListSuccess(data));
if (shouldReset) { if (shouldReset) {

View file

@ -1,4 +1,4 @@
import IntlMessageFormat from 'intl-messageformat'; import { IntlMessageFormat } from 'intl-messageformat';
import { defineMessages } from 'react-intl'; import { defineMessages } from 'react-intl';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';

View file

@ -345,9 +345,10 @@ export const translateStatusFail = (id, error) => ({
error, error,
}); });
export const undoStatusTranslation = id => ({ export const undoStatusTranslation = (id, pollId) => ({
type: STATUS_TRANSLATE_UNDO, type: STATUS_TRANSLATE_UNDO,
id, id,
pollId,
}); });
export const updateEmojiReaction = (emoji_reaction) => ({ export const updateEmojiReaction = (emoji_reaction) => ({

View file

@ -24,8 +24,6 @@ import {
fillListTimelineGaps, fillListTimelineGaps,
} from './timelines'; } from './timelines';
const { messages } = getLocale();
/** /**
* @param {number} max * @param {number} max
* @returns {number} * @returns {number}
@ -43,8 +41,10 @@ const randomUpTo = max =>
* @param {function(object): boolean} [options.accept] * @param {function(object): boolean} [options.accept]
* @returns {function(): void} * @returns {function(): void}
*/ */
export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => export const connectTimelineStream = (timelineId, channelName, params = {}, options = {}) => {
connectStream(channelName, params, (dispatch, getState) => { const { messages } = getLocale();
return connectStream(channelName, params, (dispatch, getState) => {
const locale = getState().getIn(['meta', 'locale']); const locale = getState().getIn(['meta', 'locale']);
// @ts-expect-error // @ts-expect-error
@ -125,6 +125,7 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
}, },
}; };
}); });
};
/** /**
* @param {Function} dispatch * @param {Function} dispatch

View file

@ -16,6 +16,7 @@ import { VerifiedBadge } from 'mastodon/components/verified_badge';
import { me } from '../initial_state'; import { me } from '../initial_state';
import { Avatar } from './avatar'; import { Avatar } from './avatar';
import Button from './button';
import { DisplayName } from './display_name'; import { DisplayName } from './display_name';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp'; import { RelativeTimestamp } from './relative_timestamp';
@ -23,13 +24,13 @@ import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({ const messages = defineMessages({
follow: { id: 'account.follow', defaultMessage: 'Follow' }, follow: { id: 'account.follow', defaultMessage: 'Follow' },
unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' }, unfollow: { id: 'account.unfollow', defaultMessage: 'Unfollow' },
requested: { id: 'account.requested', defaultMessage: 'Awaiting approval. Click to cancel follow request' }, cancel_follow_request: { id: 'account.cancel_follow_request', defaultMessage: 'Withdraw follow request' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock_short', defaultMessage: 'Unblock' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unmute: { id: 'account.unmute_short', defaultMessage: 'Unmute' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, mute_notifications: { id: 'account.mute_notifications_short', defaultMessage: 'Mute notifications' },
unmute_notifications: { id: 'account.unmute_notifications', defaultMessage: 'Unmute notifications from @{name}' }, unmute_notifications: { id: 'account.unmute_notifications_short', defaultMessage: 'Unmute notifications' },
mute: { id: 'account.mute', defaultMessage: 'Mute @{name}' }, mute: { id: 'account.mute_short', defaultMessage: 'Mute' },
block: { id: 'account.block', defaultMessage: 'Block @{name}' }, block: { id: 'account.block_short', defaultMessage: 'Block' },
}); });
class Account extends ImmutablePureComponent { class Account extends ImmutablePureComponent {
@ -43,6 +44,7 @@ class Account extends ImmutablePureComponent {
onMuteNotifications: PropTypes.func.isRequired, onMuteNotifications: PropTypes.func.isRequired,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
hidden: PropTypes.bool, hidden: PropTypes.bool,
hideButtons: PropTypes.bool,
minimal: PropTypes.bool, minimal: PropTypes.bool,
actionIcon: PropTypes.string, actionIcon: PropTypes.string,
actionTitle: PropTypes.string, actionTitle: PropTypes.string,
@ -80,7 +82,7 @@ class Account extends ImmutablePureComponent {
}; };
render () { render () {
const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal, children } = this.props; const { account, intl, hidden, hideButtons, onActionClick, actionIcon, actionTitle, defaultAction, size, minimal, children } = this.props;
if (!account) { if (!account) {
return <EmptyAccount size={size} minimal={minimal} />; return <EmptyAccount size={size} minimal={minimal} />;
@ -97,39 +99,39 @@ class Account extends ImmutablePureComponent {
let buttons; let buttons;
if (actionIcon) { if (actionIcon && onActionClick) {
if (onActionClick) { buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />; } else if (!hideButtons && !actionIcon && account.get('id') !== me && account.get('relationship', null) !== null) {
}
} else if (account.get('id') !== me && account.get('relationship', null) !== null) {
const following = account.getIn(['relationship', 'following']); const following = account.getIn(['relationship', 'following']);
const requested = account.getIn(['relationship', 'requested']); const requested = account.getIn(['relationship', 'requested']);
const blocking = account.getIn(['relationship', 'blocking']); const blocking = account.getIn(['relationship', 'blocking']);
const muting = account.getIn(['relationship', 'muting']); const muting = account.getIn(['relationship', 'muting']);
if (requested) { if (requested) {
buttons = <IconButton disabled icon='hourglass' title={intl.formatMessage(messages.requested)} />; buttons = <Button text={intl.formatMessage(messages.cancel_follow_request)} onClick={this.handleFollow} />;
} else if (blocking) { } else if (blocking) {
buttons = <IconButton active icon='unlock' title={intl.formatMessage(messages.unblock, { name: account.get('username') })} onClick={this.handleBlock} />; buttons = <Button text={intl.formatMessage(messages.unblock)} onClick={this.handleBlock} />;
} else if (muting) { } else if (muting) {
let hidingNotificationsButton; let hidingNotificationsButton;
if (account.getIn(['relationship', 'muting_notifications'])) { if (account.getIn(['relationship', 'muting_notifications'])) {
hidingNotificationsButton = <IconButton active icon='bell' title={intl.formatMessage(messages.unmute_notifications, { name: account.get('username') })} onClick={this.handleUnmuteNotifications} />; hidingNotificationsButton = <Button text={intl.formatMessage(messages.unmute_notifications)} onClick={this.handleUnmuteNotifications} />;
} else { } else {
hidingNotificationsButton = <IconButton active icon='bell-slash' title={intl.formatMessage(messages.mute_notifications, { name: account.get('username') })} onClick={this.handleMuteNotifications} />; hidingNotificationsButton = <Button text={intl.formatMessage(messages.mute_notifications)} onClick={this.handleMuteNotifications} />;
} }
buttons = ( buttons = (
<> <>
<IconButton active icon='volume-up' title={intl.formatMessage(messages.unmute, { name: account.get('username') })} onClick={this.handleMute} /> <Button text={intl.formatMessage(messages.unmute)} onClick={this.handleMute} />
{hidingNotificationsButton} {hidingNotificationsButton}
</> </>
); );
} else if (defaultAction === 'mute') { } else if (defaultAction === 'mute') {
buttons = <IconButton icon='volume-off' title={intl.formatMessage(messages.mute, { name: account.get('username') })} onClick={this.handleMute} />; buttons = <Button title={intl.formatMessage(messages.mute)} onClick={this.handleMute} />;
} else if (defaultAction === 'block') { } else if (defaultAction === 'block') {
buttons = <IconButton icon='lock' title={intl.formatMessage(messages.block, { name: account.get('username') })} onClick={this.handleBlock} />; buttons = <Button text={intl.formatMessage(messages.block)} onClick={this.handleBlock} />;
} else if (!account.get('moved') || following) { } else if (!account.get('moved') || following) {
buttons = <IconButton icon={following ? 'user-times' : 'user-plus'} title={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} active={following} />; buttons = <Button text={intl.formatMessage(following ? messages.unfollow : messages.follow)} onClick={this.handleFollow} />;
} }
} }
@ -144,7 +146,7 @@ class Account extends ImmutablePureComponent {
const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at')); const firstVerifiedField = account.get('fields').find(item => !!item.get('verified_at'));
if (firstVerifiedField) { if (firstVerifiedField) {
verification = <>· <VerifiedBadge link={firstVerifiedField.get('value')} /></>; verification = <VerifiedBadge link={firstVerifiedField.get('value')} />;
} }
return ( return (
@ -155,9 +157,13 @@ class Account extends ImmutablePureComponent {
<Avatar account={account} size={size} /> <Avatar account={account} size={size} />
</div> </div>
<div> <div className='account__contents'>
<DisplayName account={account} /> <DisplayName account={account} />
{!minimal && <><ShortNumber value={account.get('followers_count')} isHide={account.getIn(['other_settings', 'hide_followers_count']) || false} renderer={counterRenderer('followers')} /> {verification} {muteTimeRemaining}</>} {!minimal && (
<div className='account__details'>
<ShortNumber value={account.get('followers_count')} isHide={account.getIn(['other_settings', 'hide_followers_count']) || false} renderer={counterRenderer('followers')} /> {verification} {muteTimeRemaining}
</div>
)}
</div> </div>
</Link> </Link>

View file

@ -0,0 +1,91 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { FormattedNumber, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import api from 'mastodon/api';
import { Skeleton } from 'mastodon/components/skeleton';
export default class ImpactReport extends PureComponent {
static propTypes = {
domain: PropTypes.string.isRequired,
};
state = {
loading: true,
data: null,
};
componentDidMount () {
const { domain } = this.props;
const params = {
domain: domain,
include_subdomains: true,
};
api().post('/api/v1/admin/measures', {
keys: ['instance_accounts', 'instance_follows', 'instance_followers'],
start_at: null,
end_at: null,
instance_accounts: params,
instance_follows: params,
instance_followers: params,
}).then(res => {
this.setState({
loading: false,
data: res.data,
});
}).catch(err => {
console.error(err);
});
}
render () {
const { loading, data } = this.state;
return (
<div className='dimension'>
<h4><FormattedMessage id='admin.impact_report.title' defaultMessage='Impact summary' /></h4>
<table>
<tbody>
<tr className='dimension__item'>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_accounts' defaultMessage='Accounts profiles this would delete' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[0].total} />}
</td>
</tr>
<tr className={classNames('dimension__item', { negative: !loading && data[1].total > 0 })}>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_follows' defaultMessage='Followers their users would lose' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[1].total} />}
</td>
</tr>
<tr className={classNames('dimension__item', { negative: !loading && data[2].total > 0 })}>
<td className='dimension__item__key'>
<FormattedMessage id='admin.impact_report.instance_followers' defaultMessage='Followers our users would lose' />
</td>
<td className='dimension__item__value'>
{loading ? <Skeleton width={60} /> : <FormattedNumber value={data[2].total} />}
</td>
</tr>
</tbody>
</table>
</div>
);
}
}

View file

@ -1,7 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { InjectedIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';
import { defineMessages, injectIntl } from 'react-intl';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
@ -15,9 +14,11 @@ const messages = defineMessages({
interface Props { interface Props {
domain: string; domain: string;
onUnblockDomain: (domain: string) => void; onUnblockDomain: (domain: string) => void;
intl: InjectedIntl;
} }
const _Domain: React.FC<Props> = ({ domain, onUnblockDomain, intl }) => {
export const Domain: React.FC<Props> = ({ domain, onUnblockDomain }) => {
const intl = useIntl();
const handleDomainUnblock = useCallback(() => { const handleDomainUnblock = useCallback(() => {
onUnblockDomain(domain); onUnblockDomain(domain);
}, [domain, onUnblockDomain]); }, [domain, onUnblockDomain]);
@ -41,5 +42,3 @@ const _Domain: React.FC<Props> = ({ domain, onUnblockDomain, intl }) => {
</div> </div>
); );
}; };
export const Domain = injectIntl(_Domain);

View file

@ -1,7 +1,6 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import type { InjectedIntl } from 'react-intl'; import { useIntl, defineMessages } from 'react-intl';
import { injectIntl, defineMessages } from 'react-intl';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
@ -13,10 +12,11 @@ interface Props {
disabled: boolean; disabled: boolean;
maxId: string; maxId: string;
onClick: (maxId: string) => void; onClick: (maxId: string) => void;
intl: InjectedIntl;
} }
const _LoadGap: React.FC<Props> = ({ disabled, maxId, onClick, intl }) => { export const LoadGap: React.FC<Props> = ({ disabled, maxId, onClick }) => {
const intl = useIntl();
const handleClick = useCallback(() => { const handleClick = useCallback(() => {
onClick(maxId); onClick(maxId);
}, [maxId, onClick]); }, [maxId, onClick]);
@ -32,5 +32,3 @@ const _LoadGap: React.FC<Props> = ({ disabled, maxId, onClick, intl }) => {
</button> </button>
); );
}; };
export const LoadGap = injectIntl(_LoadGap);

View file

@ -51,8 +51,9 @@ export default class MediaAttachments extends ImmutablePureComponent {
}; };
render () { render () {
const { status, lang, width, height } = this.props; const { status, width, height } = this.props;
const mediaAttachments = status.get('media_attachments'); const mediaAttachments = status.get('media_attachments');
const language = status.getIn(['language', 'translation']) || status.get('language') || this.props.lang;
if (mediaAttachments.size === 0) { if (mediaAttachments.size === 0) {
return null; return null;
@ -60,14 +61,15 @@ export default class MediaAttachments extends ImmutablePureComponent {
if (mediaAttachments.getIn([0, 'type']) === 'audio') { if (mediaAttachments.getIn([0, 'type']) === 'audio') {
const audio = mediaAttachments.get(0); const audio = mediaAttachments.get(0);
const description = audio.getIn(['translation', 'description']) || audio.get('description');
return ( return (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => ( {Component => (
<Component <Component
src={audio.get('url')} src={audio.get('url')}
alt={audio.get('description')} alt={description}
lang={lang || status.get('language')} lang={language}
width={width} width={width}
height={height} height={height}
poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])} poster={audio.get('preview_url') || status.getIn(['account', 'avatar_static'])}
@ -81,6 +83,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
); );
} else if (mediaAttachments.getIn([0, 'type']) === 'video') { } else if (mediaAttachments.getIn([0, 'type']) === 'video') {
const video = mediaAttachments.get(0); const video = mediaAttachments.get(0);
const description = video.getIn(['translation', 'description']) || video.get('description');
return ( return (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
@ -90,8 +93,8 @@ export default class MediaAttachments extends ImmutablePureComponent {
frameRate={video.getIn(['meta', 'original', 'frame_rate'])} frameRate={video.getIn(['meta', 'original', 'frame_rate'])}
blurhash={video.get('blurhash')} blurhash={video.get('blurhash')}
src={video.get('url')} src={video.get('url')}
alt={video.get('description')} alt={description}
lang={lang || status.get('language')} lang={language}
width={width} width={width}
height={height} height={height}
inline inline
@ -107,7 +110,7 @@ export default class MediaAttachments extends ImmutablePureComponent {
{Component => ( {Component => (
<Component <Component
media={mediaAttachments} media={mediaAttachments}
lang={lang || status.get('language')} lang={language}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
defaultWidth={width} defaultWidth={width}
height={height} height={height}

View file

@ -121,10 +121,12 @@ class Item extends PureComponent {
badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>); badges.push(<span key='alt' className='media-gallery__gifv__label'>ALT</span>);
} }
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
if (attachment.get('type') === 'unknown') { if (attachment.get('type') === 'unknown') {
return ( return (
<div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}> <div className={classNames('media-gallery__item', { standalone, 'media-gallery__item--tall': height === 100, 'media-gallery__item--wide': width === 100 })} key={attachment.get('id')}>
<a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={attachment.get('description')} lang={lang} target='_blank' rel='noopener noreferrer'> <a className='media-gallery__item-thumbnail' href={attachment.get('remote_url') || attachment.get('url')} style={{ cursor: 'pointer' }} title={description} lang={lang} target='_blank' rel='noopener noreferrer'>
<Blurhash <Blurhash
hash={attachment.get('blurhash')} hash={attachment.get('blurhash')}
className='media-gallery__preview' className='media-gallery__preview'
@ -162,8 +164,8 @@ class Item extends PureComponent {
src={previewUrl} src={previewUrl}
srcSet={srcSet} srcSet={srcSet}
sizes={sizes} sizes={sizes}
alt={attachment.get('description')} alt={description}
title={attachment.get('description')} title={description}
lang={lang} lang={lang}
style={{ objectPosition: `${x}% ${y}%` }} style={{ objectPosition: `${x}% ${y}%` }}
onLoad={this.handleImageLoad} onLoad={this.handleImageLoad}
@ -179,8 +181,8 @@ class Item extends PureComponent {
<div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}> <div className={classNames('media-gallery__gifv', { autoplay: autoPlay })}>
<video <video
className='media-gallery__item-gifv-thumbnail' className='media-gallery__item-gifv-thumbnail'
aria-label={attachment.get('description')} aria-label={description}
title={attachment.get('description')} title={description}
lang={lang} lang={lang}
role='application' role='application'
src={attachment.get('url')} src={attachment.get('url')}

View file

@ -57,9 +57,9 @@ class Poll extends ImmutablePureComponent {
}; };
static getDerivedStateFromProps (props, state) { static getDerivedStateFromProps (props, state) {
const { poll, intl } = props; const { poll } = props;
const expires_at = poll.get('expires_at'); const expires_at = poll.get('expires_at');
const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < intl.now(); const expired = poll.get('expired') || expires_at !== null && (new Date(expires_at)).getTime() < Date.now();
return (expired === state.expired) ? null : { expired }; return (expired === state.expired) ? null : { expired };
} }
@ -76,10 +76,10 @@ class Poll extends ImmutablePureComponent {
} }
_setupTimer () { _setupTimer () {
const { poll, intl } = this.props; const { poll } = this.props;
clearTimeout(this._timer); clearTimeout(this._timer);
if (!this.state.expired) { if (!this.state.expired) {
const delay = (new Date(poll.get('expires_at'))).getTime() - intl.now(); const delay = (new Date(poll.get('expires_at'))).getTime() - Date.now();
this._timer = setTimeout(() => { this._timer = setTimeout(() => {
this.setState({ expired: true }); this.setState({ expired: true });
}, delay); }, delay);
@ -138,10 +138,12 @@ class Poll extends ImmutablePureComponent {
const active = !!this.state.selected[`${optionIndex}`]; const active = !!this.state.selected[`${optionIndex}`];
const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex)); const voted = option.get('voted') || (poll.get('own_votes') && poll.get('own_votes').includes(optionIndex));
let titleEmojified = option.get('title_emojified'); const title = option.getIn(['translation', 'title']) || option.get('title');
if (!titleEmojified) { let titleHtml = option.getIn(['translation', 'titleHtml']) || option.get('titleHtml');
if (!titleHtml) {
const emojiMap = makeEmojiMap(poll); const emojiMap = makeEmojiMap(poll);
titleEmojified = emojify(escapeTextContentForBrowser(option.get('title')), emojiMap); titleHtml = emojify(escapeTextContentForBrowser(title), emojiMap);
} }
return ( return (
@ -163,7 +165,7 @@ class Poll extends ImmutablePureComponent {
role={poll.get('multiple') ? 'checkbox' : 'radio'} role={poll.get('multiple') ? 'checkbox' : 'radio'}
onKeyPress={this.handleOptionKeyPress} onKeyPress={this.handleOptionKeyPress}
aria-checked={active} aria-checked={active}
aria-label={option.get('title')} aria-label={title}
lang={lang} lang={lang}
data-index={optionIndex} data-index={optionIndex}
/> />
@ -182,7 +184,7 @@ class Poll extends ImmutablePureComponent {
<span <span
className='poll__option__text translate' className='poll__option__text translate'
lang={lang} lang={lang}
dangerouslySetInnerHTML={{ __html: titleEmojified }} dangerouslySetInnerHTML={{ __html: titleHtml }}
/> />
{!!voted && <span className='poll__voted'> {!!voted && <span className='poll__voted'>

View file

@ -1,6 +1,6 @@
import { Component } from 'react'; import { Component } from 'react';
import type { InjectedIntl } from 'react-intl'; import type { IntlShape } from 'react-intl';
import { injectIntl, defineMessages } from 'react-intl'; import { injectIntl, defineMessages } from 'react-intl';
const messages = defineMessages({ const messages = defineMessages({
@ -103,7 +103,7 @@ const getUnitDelay = (units: string) => {
}; };
export const timeAgoString = ( export const timeAgoString = (
intl: InjectedIntl, intl: IntlShape,
date: Date, date: Date,
now: number, now: number,
year: number, year: number,
@ -155,7 +155,7 @@ export const timeAgoString = (
}; };
const timeRemainingString = ( const timeRemainingString = (
intl: InjectedIntl, intl: IntlShape,
date: Date, date: Date,
now: number, now: number,
timeGiven = true timeGiven = true
@ -190,7 +190,7 @@ const timeRemainingString = (
}; };
interface Props { interface Props {
intl: InjectedIntl; intl: IntlShape;
timestamp: string; timestamp: string;
year: number; year: number;
futureDate?: boolean; futureDate?: boolean;
@ -201,7 +201,7 @@ interface States {
} }
class RelativeTimestamp extends Component<Props, States> { class RelativeTimestamp extends Component<Props, States> {
state = { state = {
now: this.props.intl.now(), now: Date.now(),
}; };
static defaultProps = { static defaultProps = {
@ -223,7 +223,7 @@ class RelativeTimestamp extends Component<Props, States> {
UNSAFE_componentWillReceiveProps(nextProps: Props) { UNSAFE_componentWillReceiveProps(nextProps: Props) {
if (this.props.timestamp !== nextProps.timestamp) { if (this.props.timestamp !== nextProps.timestamp) {
this.setState({ now: this.props.intl.now() }); this.setState({ now: Date.now() });
} }
} }
@ -253,7 +253,7 @@ class RelativeTimestamp extends Component<Props, States> {
: Math.max(updateInterval, unitRemainder); : Math.max(updateInterval, unitRemainder);
this._timer = window.setTimeout(() => { this._timer = window.setTimeout(() => {
this.setState({ now: this.props.intl.now() }); this.setState({ now: Date.now() });
}, delay); }, delay);
} }

View file

@ -28,12 +28,18 @@ import StatusActionBar from './status_action_bar';
import StatusContent from './status_content'; import StatusContent from './status_content';
import StatusEmojiReactionsBar from './status_emoji_reactions_bar'; import StatusEmojiReactionsBar from './status_emoji_reactions_bar';
const domParser = new DOMParser();
export const textForScreenReader = (intl, status, rebloggedByText = false) => { export const textForScreenReader = (intl, status, rebloggedByText = false) => {
const displayName = status.getIn(['account', 'display_name']); const displayName = status.getIn(['account', 'display_name']);
const spoilerText = status.getIn(['translation', 'spoiler_text']) || status.get('spoiler_text');
const contentHtml = status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
const contentText = domParser.parseFromString(contentHtml, 'text/html').documentElement.textContent;
const values = [ const values = [
displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName, displayName.length === 0 ? status.getIn(['account', 'acct']).split('@')[0] : displayName,
status.get('spoiler_text') && status.get('hidden') ? status.get('spoiler_text') : status.get('search_index').slice(status.get('spoiler_text').length), spoilerText && status.get('hidden') ? spoilerText : contentText,
intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }), intl.formatDate(status.get('created_at'), { hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }),
status.getIn(['account', 'acct']), status.getIn(['account', 'acct']),
]; ];
@ -206,12 +212,14 @@ class Status extends ImmutablePureComponent {
handleOpenVideo = (options) => { handleOpenVideo = (options) => {
const status = this._properStatus(); const status = this._properStatus();
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), status.get('language'), options); const lang = status.getIn(['translation', 'language']) || status.get('language');
this.props.onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, options);
}; };
handleOpenMedia = (media, index) => { handleOpenMedia = (media, index) => {
const status = this._properStatus(); const status = this._properStatus();
this.props.onOpenMedia(status.get('id'), media, index, status.get('language')); const lang = status.getIn(['translation', 'language']) || status.get('language');
this.props.onOpenMedia(status.get('id'), media, index, lang);
}; };
handleHotkeyOpenMedia = e => { handleHotkeyOpenMedia = e => {
@ -221,7 +229,7 @@ class Status extends ImmutablePureComponent {
e.preventDefault(); e.preventDefault();
if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').size > 0) {
const lang = status.get('language'); const lang = status.getIn(['translation', 'language']) || status.get('language');
if (status.getIn(['media_attachments', 0, 'type']) === 'video') { if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 }); onOpenVideo(status.get('id'), status.getIn(['media_attachments', 0]), lang, { startTime: 0 });
} else { } else {
@ -439,6 +447,8 @@ class Status extends ImmutablePureComponent {
if (pictureInPicture.get('inUse')) { if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder />; media = <PictureInPicturePlaceholder />;
} else if (status.get('media_attachments').size > 0) { } else if (status.get('media_attachments').size > 0) {
const language = status.getIn(['translation', 'language']) || status.get('language');
if (this.props.muted) { if (this.props.muted) {
media = ( media = (
<AttachmentList <AttachmentList
@ -448,14 +458,15 @@ class Status extends ImmutablePureComponent {
); );
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = ( media = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} > <Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => ( {Component => (
<Component <Component
src={attachment.get('url')} src={attachment.get('url')}
alt={attachment.get('description')} alt={description}
lang={status.get('language')} lang={language}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])} foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
@ -475,6 +486,7 @@ class Status extends ImmutablePureComponent {
); );
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = ( media = (
<Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} > <Bundle fetchComponent={Video} loading={this.renderLoadingVideoPlayer} >
@ -484,8 +496,8 @@ class Status extends ImmutablePureComponent {
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
blurhash={attachment.get('blurhash')} blurhash={attachment.get('blurhash')}
src={attachment.get('url')} src={attachment.get('url')}
alt={attachment.get('description')} alt={description}
lang={status.get('language')} lang={language}
inline inline
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
onOpenVideo={this.handleOpenVideo} onOpenVideo={this.handleOpenVideo}
@ -502,7 +514,7 @@ class Status extends ImmutablePureComponent {
{Component => ( {Component => (
<Component <Component
media={status.get('media_attachments')} media={status.get('media_attachments')}
lang={status.get('language')} lang={language}
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
height={110} height={110}
onOpenMedia={this.handleOpenMedia} onOpenMedia={this.handleOpenMedia}

View file

@ -231,11 +231,11 @@ class StatusContent extends PureComponent {
const renderReadMore = this.props.onClick && status.get('collapsed'); const renderReadMore = this.props.onClick && status.get('collapsed');
const contentLocale = intl.locale.replace(/[_-].*/, ''); const contentLocale = intl.locale.replace(/[_-].*/, '');
const targetLanguages = this.props.languages?.get(status.get('language') || 'und'); const targetLanguages = this.props.languages?.get(status.get('language') || 'und');
const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('contentHtml').length > 0 && targetLanguages?.includes(contentLocale); const renderTranslate = this.props.onTranslate && this.context.identity.signedIn && ['public', 'unlisted'].includes(status.get('visibility')) && status.get('search_index').trim().length > 0 && targetLanguages?.includes(contentLocale);
const content = { __html: status.get('translation') ? status.getIn(['translation', 'content']) : status.get('contentHtml') }; const content = { __html: status.getIn(['translation', 'contentHtml']) || status.get('contentHtml') };
const spoilerContent = { __html: status.get('spoilerHtml') }; const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
const lang = status.get('translation') ? intl.locale : status.get('language'); const language = status.getIn(['translation', 'language']) || status.get('language');
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
'status__content--with-action': this.props.onClick && this.context.router, 'status__content--with-action': this.props.onClick && this.context.router,
'status__content--with-spoiler': status.get('spoiler_text').length > 0, 'status__content--with-spoiler': status.get('spoiler_text').length > 0,
@ -253,7 +253,7 @@ class StatusContent extends PureComponent {
); );
const poll = !!status.get('poll') && ( const poll = !!status.get('poll') && (
<PollContainer pollId={status.get('poll')} lang={status.get('language')} /> <PollContainer pollId={status.get('poll')} lang={language} />
); );
if (status.get('spoiler_text').length > 0) { if (status.get('spoiler_text').length > 0) {
@ -274,24 +274,24 @@ class StatusContent extends PureComponent {
return ( return (
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}> <p style={{ marginBottom: hidden && status.get('mentions').isEmpty() ? '0px' : null }}>
<span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={lang} /> <span dangerouslySetInnerHTML={spoilerContent} className='translate' lang={language} />
{' '} {' '}
<button type='button' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick} aria-expanded={!hidden}>{toggleText}</button> <button type='button' className={`status__content__spoiler-link ${hidden ? 'status__content__spoiler-link--show-more' : 'status__content__spoiler-link--show-less'}`} onClick={this.handleSpoilerClick} aria-expanded={!hidden}>{toggleText}</button>
</p> </p>
{mentionsPlaceholder} {mentionsPlaceholder}
<div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={lang} dangerouslySetInnerHTML={content} /> <div tabIndex={!hidden ? 0 : null} className={`status__content__text ${!hidden ? 'status__content__text--visible' : ''} translate`} lang={language} dangerouslySetInnerHTML={content} />
{!hidden && poll} {!hidden && poll}
{!hidden && translateButton} {translateButton}
</div> </div>
); );
} else if (this.props.onClick) { } else if (this.props.onClick) {
return ( return (
<> <>
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef} tabIndex={0} onMouseDown={this.handleMouseDown} onMouseUp={this.handleMouseUp} key='status-content' onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> <div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
{poll} {poll}
{translateButton} {translateButton}
@ -303,7 +303,7 @@ class StatusContent extends PureComponent {
} else { } else {
return ( return (
<div className={classNames} ref={this.setRef} tabIndex={0} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}> <div className={classNames} ref={this.setRef} tabIndex={0} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
<div className='status__content__text status__content__text--visible translate' lang={lang} dangerouslySetInnerHTML={content} /> <div className='status__content__text status__content__text--visible translate' lang={language} dangerouslySetInnerHTML={content} />
{poll} {poll}
{translateButton} {translateButton}

View file

@ -1,25 +1,19 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { IntlProvider, addLocaleData } from 'react-intl'; import { IntlProvider } from 'mastodon/locales';
import { getLocale } from '../locales';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
export default class AdminComponent extends PureComponent { export default class AdminComponent extends PureComponent {
static propTypes = { static propTypes = {
locale: PropTypes.string.isRequired,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
}; };
render () { render () {
const { locale, children } = this.props; const { children } = this.props;
return ( return (
<IntlProvider locale={locale} messages={messages}> <IntlProvider>
{children} {children}
</IntlProvider> </IntlProvider>
); );

View file

@ -1,19 +1,14 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { IntlProvider, addLocaleData } from 'react-intl';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { fetchCustomEmojis } from '../actions/custom_emojis'; import { fetchCustomEmojis } from '../actions/custom_emojis';
import { hydrateStore } from '../actions/store'; import { hydrateStore } from '../actions/store';
import Compose from '../features/standalone/compose'; import Compose from '../features/standalone/compose';
import initialState from '../initial_state'; import initialState from '../initial_state';
import { getLocale } from '../locales'; import { IntlProvider } from '../locales';
import { store } from '../store'; import { store } from '../store';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
if (initialState) { if (initialState) {
store.dispatch(hydrateStore(initialState)); store.dispatch(hydrateStore(initialState));
@ -21,17 +16,11 @@ if (initialState) {
store.dispatch(fetchCustomEmojis()); store.dispatch(fetchCustomEmojis());
export default class TimelineContainer extends PureComponent { export default class ComposeContainer extends PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
};
render () { render () {
const { locale } = this.props;
return ( return (
<IntlProvider locale={locale} messages={messages}> <IntlProvider>
<Provider store={store}> <Provider store={store}>
<Compose /> <Compose />
</Provider> </Provider>

View file

@ -1,8 +1,6 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { IntlProvider, addLocaleData } from 'react-intl';
import { Helmet } from 'react-helmet'; import { Helmet } from 'react-helmet';
import { BrowserRouter, Route } from 'react-router-dom'; import { BrowserRouter, Route } from 'react-router-dom';
@ -17,12 +15,9 @@ import { connectUserStream } from 'mastodon/actions/streaming';
import ErrorBoundary from 'mastodon/components/error_boundary'; import ErrorBoundary from 'mastodon/components/error_boundary';
import UI from 'mastodon/features/ui'; import UI from 'mastodon/features/ui';
import initialState, { title as siteTitle } from 'mastodon/initial_state'; import initialState, { title as siteTitle } from 'mastodon/initial_state';
import { getLocale } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { store } from 'mastodon/store'; import { store } from 'mastodon/store';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`; const title = process.env.NODE_ENV === 'production' ? siteTitle : `${siteTitle} (Dev)`;
const hydrateAction = hydrateStore(initialState); const hydrateAction = hydrateStore(initialState);
@ -43,10 +38,6 @@ const createIdentityContext = state => ({
export default class Mastodon extends PureComponent { export default class Mastodon extends PureComponent {
static propTypes = {
locale: PropTypes.string.isRequired,
};
static childContextTypes = { static childContextTypes = {
identity: PropTypes.shape({ identity: PropTypes.shape({
signedIn: PropTypes.bool.isRequired, signedIn: PropTypes.bool.isRequired,
@ -82,10 +73,8 @@ export default class Mastodon extends PureComponent {
} }
render () { render () {
const { locale } = this.props;
return ( return (
<IntlProvider locale={locale} messages={messages}> <IntlProvider>
<ReduxProvider store={store}> <ReduxProvider store={store}>
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter> <BrowserRouter>

View file

@ -2,8 +2,6 @@ import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { IntlProvider, addLocaleData } from 'react-intl';
import { fromJS } from 'immutable'; import { fromJS } from 'immutable';
import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag'; import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
@ -14,18 +12,14 @@ import Audio from 'mastodon/features/audio';
import Card from 'mastodon/features/status/components/card'; import Card from 'mastodon/features/status/components/card';
import MediaModal from 'mastodon/features/ui/components/media_modal'; import MediaModal from 'mastodon/features/ui/components/media_modal';
import Video from 'mastodon/features/video'; import Video from 'mastodon/features/video';
import { getLocale } from 'mastodon/locales'; import { IntlProvider } from 'mastodon/locales';
import { getScrollbarWidth } from 'mastodon/utils/scrollbar'; import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
const { localeData, messages } = getLocale();
addLocaleData(localeData);
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio }; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
export default class MediaContainer extends PureComponent { export default class MediaContainer extends PureComponent {
static propTypes = { static propTypes = {
locale: PropTypes.string.isRequired,
components: PropTypes.object.isRequired, components: PropTypes.object.isRequired,
}; };
@ -74,7 +68,7 @@ export default class MediaContainer extends PureComponent {
}; };
render () { render () {
const { locale, components } = this.props; const { components } = this.props;
let handleOpenVideo; let handleOpenVideo;
@ -84,7 +78,7 @@ export default class MediaContainer extends PureComponent {
} }
return ( return (
<IntlProvider locale={locale} messages={messages}> <IntlProvider>
<> <>
{[].map.call(components, (component, i) => { {[].map.call(components, (component, i) => {
const componentName = component.getAttribute('data-component'); const componentName = component.getAttribute('data-component');

View file

@ -194,7 +194,7 @@ const mapDispatchToProps = (dispatch, { intl, contextType }) => ({
onTranslate (status) { onTranslate (status) {
if (status.get('translation')) { if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id'))); dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
} else { } else {
dispatch(translateStatus(status.get('id'))); dispatch(translateStatus(status.get('id')));
} }

View file

@ -1,16 +1,22 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import ImmutablePureComponent from 'react-immutable-pure-component';
import ImmutablePropTypes from 'react-immutable-proptypes';
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { fetchEmojiReactions } from 'mastodon/actions/interactions';
import ColumnHeader from 'mastodon/components/column_header'; import ColumnHeader from 'mastodon/components/column_header';
import { Icon } from 'mastodon/components/icon'; import { Icon } from 'mastodon/components/icon';
import { fetchEmojiReactions } from 'mastodon/actions/interactions';
import LoadingIndicator from 'mastodon/components/loading_indicator'; import LoadingIndicator from 'mastodon/components/loading_indicator';
import ScrollableList from 'mastodon/components/scrollable_list'; import ScrollableList from 'mastodon/components/scrollable_list';
import AccountContainer from 'mastodon/containers/account_container'; import AccountContainer from 'mastodon/containers/account_container';
import Column from 'mastodon/features/ui/components/column'; import Column from 'mastodon/features/ui/components/column';
import { Helmet } from 'react-helmet';
import EmojiView from '../../components/emoji_view'; import EmojiView from '../../components/emoji_view';
const messages = defineMessages({ const messages = defineMessages({
@ -86,7 +92,7 @@ class EmojiReactions extends ImmutablePureComponent {
bindToDocument={!multiColumn} bindToDocument={!multiColumn}
> >
{Object.keys(groups).map((key) =>( {Object.keys(groups).map((key) =>(
<AccountContainer key={key} id={key} withNote={false}> <AccountContainer key={key} id={key} withNote={false} hideButtons>
<div style={{ 'maxWidth': '100px' }}> <div style={{ 'maxWidth': '100px' }}>
{groups[key].map((value, index2) => <EmojiView key={index2} name={value.name} url={value.url} staticUrl={value.static_url} />)} {groups[key].map((value, index2) => <EmojiView key={index2} name={value.name} url={value.url} staticUrl={value.static_url} />)}
</div> </div>

View file

@ -67,7 +67,7 @@ class Explore extends PureComponent {
<Search /> <Search />
</div> </div>
<div className='scrollable scrollable--flex'> <div className='scrollable scrollable--flex' data-nosnippet>
{isSearching ? ( {isSearching ? (
<SearchResults /> <SearchResults />
) : ( ) : (

View file

@ -8,6 +8,8 @@ import { Helmet } from 'react-helmet';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import Toggle from 'react-toggle';
import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns'; import { addColumn, removeColumn, moveColumn } from 'mastodon/actions/columns';
import { fetchList, deleteList, updateList } from 'mastodon/actions/lists'; import { fetchList, deleteList, updateList } from 'mastodon/actions/lists';
import { openModal } from 'mastodon/actions/modal'; import { openModal } from 'mastodon/actions/modal';
@ -142,10 +144,21 @@ class ListTimeline extends PureComponent {
})); }));
}; };
handleEditAntennaClick = (e) => {
const id = e.currentTarget.getAttribute('data-id');
window.open(`/antennas/${id}/edit`, '_blank');
}
handleRepliesPolicyChange = ({ target }) => { handleRepliesPolicyChange = ({ target }) => {
const { dispatch } = this.props; const { dispatch } = this.props;
const { id } = this.props.params; const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.value)); dispatch(updateList(id, undefined, false, undefined, target.value));
};
onExclusiveToggle = ({ target }) => {
const { dispatch } = this.props;
const { id } = this.props.params;
dispatch(updateList(id, undefined, false, target.checked, undefined));
}; };
render () { render () {
@ -154,6 +167,8 @@ class ListTimeline extends PureComponent {
const pinned = !!columnId; const pinned = !!columnId;
const title = list ? list.get('title') : id; const title = list ? list.get('title') : id;
const replies_policy = list ? list.get('replies_policy') : undefined; const replies_policy = list ? list.get('replies_policy') : undefined;
const isExclusive = list ? list.get('exclusive') : undefined;
const antennas = list ? (list.get('antennas')?.toArray() || []) : [];
if (typeof list === 'undefined') { if (typeof list === 'undefined') {
return ( return (
@ -191,6 +206,13 @@ class ListTimeline extends PureComponent {
</button> </button>
</div> </div>
<div className='setting-toggle'>
<Toggle id={`list-${id}-exclusive`} defaultChecked={isExclusive} onChange={this.onExclusiveToggle} />
<label htmlFor={`list-${id}-exclusive`} className='setting-toggle__label'>
<FormattedMessage id='lists.exclusive' defaultMessage='Hide these posts from home' />
</label>
</div>
{ replies_policy !== undefined && ( { replies_policy !== undefined && (
<div role='group' aria-labelledby={`list-${id}-replies-policy`}> <div role='group' aria-labelledby={`list-${id}-replies-policy`}>
<span id={`list-${id}-replies-policy`} className='column-settings__section'> <span id={`list-${id}-replies-policy`} className='column-settings__section'>
@ -203,6 +225,23 @@ class ListTimeline extends PureComponent {
</div> </div>
</div> </div>
)} )}
{ antennas.length > 0 && (
<div>
<span className='column-settings__section column-settings__section--with-margin'>
<FormattedMessage id='lists.antennas' defaultMessage='Related antennas:' />
</span>
<ul className='column-settings__row'>
{ antennas.map(antenna => (
<li key={antenna.get('id')} className='column-settings__row__antenna'>
<button type='button' className='text-btn column-header__setting-btn' data-id={antenna.get('id')} onClick={this.handleEditAntennaClick}>
{antenna.get('title')}{antenna.get('stl') && ' [STL]'}
</button>
</li>
))}
</ul>
</div>
)}
</ColumnHeader> </ColumnHeader>
<StatusListContainer <StatusListContainer

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { FormattedMessage, FormattedHTMLMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -77,7 +77,7 @@ class Follows extends PureComponent {
{loadedContent} {loadedContent}
</div> </div>
<p className='onboarding__lead'><FormattedHTMLMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' /></p> <p className='onboarding__lead'><FormattedMessage id='onboarding.tips.accounts_from_other_servers' defaultMessage='<strong>Did you know?</strong> Since Mastodon is decentralized, some profiles you come across will be hosted on servers other than yours. And yet you can interact with them seamlessly! Their server is in the second half of their username!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p>
<div className='onboarding__footer'> <div className='onboarding__footer'>
<button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></button> <button className='link-button' onClick={onBack}><FormattedMessage id='onboarding.actions.back' defaultMessage='Take me back' /></button>

View file

@ -120,7 +120,7 @@ class Onboarding extends ImmutablePureComponent {
<div className='onboarding__steps'> <div className='onboarding__steps'>
<Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} /> <Step onClick={this.handleProfileClick} href='/settings/profile' completed={(!account.get('avatar').endsWith('missing.png')) || (account.get('display_name').length > 0 && account.get('note').length > 0)} icon='address-book-o' label={<FormattedMessage id='onboarding.steps.setup_profile.title' defaultMessage='Customize your profile' />} description={<FormattedMessage id='onboarding.steps.setup_profile.body' defaultMessage='Others are more likely to interact with you with a filled out profile.' />} />
<Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Follow {count, plural, one {one person} other {# people}}' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own feed. Let's fill it with interesting people." />} /> <Step onClick={this.handleFollowClick} completed={(account.get('following_count') * 1) >= 7} icon='user-plus' label={<FormattedMessage id='onboarding.steps.follow_people.title' defaultMessage='Find at least {count, plural, one {one person} other {# people}} to follow' values={{ count: 7 }} />} description={<FormattedMessage id='onboarding.steps.follow_people.body' defaultMessage="You curate your own home feed. Let's fill it with interesting people." />} />
<Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} /> <Step onClick={this.handleComposeClick} completed={(account.get('statuses_count') * 1) >= 1} icon='pencil-square-o' label={<FormattedMessage id='onboarding.steps.publish_status.title' defaultMessage='Make your first post' />} description={<FormattedMessage id='onboarding.steps.publish_status.body' defaultMessage='Say hello to the world.' />} />
<Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} /> <Step onClick={this.handleShareClick} completed={shareClicked} icon='copy' label={<FormattedMessage id='onboarding.steps.share_profile.title' defaultMessage='Share your profile' />} description={<FormattedMessage id='onboarding.steps.share_profile.body' defaultMessage='Let your friends know how to find you on Mastodon!' />} />
</div> </div>

View file

@ -1,7 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { defineMessages, injectIntl, FormattedMessage, FormattedHTMLMessage } from 'react-intl'; import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
@ -168,9 +168,9 @@ class Share extends PureComponent {
<CopyPasteText value={intl.formatMessage(messages.shareableMessage, { username: `@${account.get('username')}@${domain}`, url })} /> <CopyPasteText value={intl.formatMessage(messages.shareableMessage, { username: `@${account.get('username')}@${domain}`, url })} />
<TipCarousel> <TipCarousel>
<div><p className='onboarding__lead'><FormattedHTMLMessage id='onboarding.tips.verification' defaultMessage='<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!' /></p></div> <div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.verification' defaultMessage='<strong>Did you know?</strong> You can verify your account by putting a link to your Mastodon profile on your own website and adding the website to your profile. No fees or documents necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
<div><p className='onboarding__lead'><FormattedHTMLMessage id='onboarding.tips.migration' defaultMessage='<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!' values={{ domain }} /></p></div> <div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.migration' defaultMessage='<strong>Did you know?</strong> If you feel like {domain} is not a great server choice for you in the future, you can move to another Mastodon server without losing your followers. You can even host your own server!' values={{ domain, strong: chunks => <strong>{chunks}</strong> }} /></p></div>
<div><p className='onboarding__lead'><FormattedHTMLMessage id='onboarding.tips.2fa' defaultMessage='<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!' /></p></div> <div><p className='onboarding__lead'><FormattedMessage id='onboarding.tips.2fa' defaultMessage='<strong>Did you know?</strong> You can secure your account by setting up two-factor authentication in your account settings. It works with any TOTP app of your choice, no phone number necessary!' values={{ strong: chunks => <strong>{chunks}</strong> }} /></p></div>
</TipCarousel> </TipCarousel>
<p className='onboarding__lead'><FormattedMessage id='onboarding.share.next_steps' defaultMessage='Possible next steps:' /></p> <p className='onboarding__lead'><FormattedMessage id='onboarding.share.next_steps' defaultMessage='Possible next steps:' /></p>

View file

@ -143,17 +143,20 @@ class DetailedStatus extends ImmutablePureComponent {
outerStyle.height = `${this.state.height}px`; outerStyle.height = `${this.state.height}px`;
} }
const language = status.getIn(['translation', 'language']) || status.get('language');
if (pictureInPicture.get('inUse')) { if (pictureInPicture.get('inUse')) {
media = <PictureInPicturePlaceholder />; media = <PictureInPicturePlaceholder />;
} else if (status.get('media_attachments').size > 0) { } else if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') { if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = ( media = (
<Audio <Audio
src={attachment.get('url')} src={attachment.get('url')}
alt={attachment.get('description')} alt={description}
lang={status.get('language')} lang={language}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)} duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])} poster={attachment.get('preview_url') || status.getIn(['account', 'avatar_static'])}
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])} backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
@ -168,6 +171,7 @@ class DetailedStatus extends ImmutablePureComponent {
); );
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') { } else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
const description = attachment.getIn(['translation', 'description']) || attachment.get('description');
media = ( media = (
<Video <Video
@ -175,8 +179,8 @@ class DetailedStatus extends ImmutablePureComponent {
frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])} frameRate={attachment.getIn(['meta', 'original', 'frame_rate'])}
blurhash={attachment.get('blurhash')} blurhash={attachment.get('blurhash')}
src={attachment.get('url')} src={attachment.get('url')}
alt={attachment.get('description')} alt={description}
lang={status.get('language')} lang={language}
width={300} width={300}
height={150} height={150}
inline inline
@ -192,7 +196,7 @@ class DetailedStatus extends ImmutablePureComponent {
standalone standalone
sensitive={status.get('sensitive')} sensitive={status.get('sensitive')}
media={status.get('media_attachments')} media={status.get('media_attachments')}
lang={status.get('language')} lang={language}
height={300} height={300}
onOpenMedia={this.props.onOpenMedia} onOpenMedia={this.props.onOpenMedia}
visible={this.props.showMedia} visible={this.props.showMedia}
@ -243,7 +247,7 @@ class DetailedStatus extends ImmutablePureComponent {
} else if (this.context.router) { } else if (this.context.router) {
reblogLink = ( reblogLink = (
<> <>
· {' · '}
<Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'> <Link to={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}/reblogs`} className='detailed-status__link'>
<Icon id={reblogIcon} /> <Icon id={reblogIcon} />
<span className='detailed-status__reblogs'> <span className='detailed-status__reblogs'>
@ -255,7 +259,7 @@ class DetailedStatus extends ImmutablePureComponent {
} else { } else {
reblogLink = ( reblogLink = (
<> <>
· {' · '}
<a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}> <a href={`/interact/${status.get('id')}?type=reblog`} className='detailed-status__link' onClick={this.handleModalLink}>
<Icon id={reblogIcon} /> <Icon id={reblogIcon} />
<span className='detailed-status__reblogs'> <span className='detailed-status__reblogs'>
@ -309,7 +313,7 @@ class DetailedStatus extends ImmutablePureComponent {
if (status.get('edited_at')) { if (status.get('edited_at')) {
edited = ( edited = (
<> <>
· {' · '}
<EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} /> <EditedTimestamp statusId={status.get('id')} timestamp={status.get('edited_at')} />
</> </>
); );

View file

@ -456,7 +456,7 @@ class Status extends ImmutablePureComponent {
const { dispatch } = this.props; const { dispatch } = this.props;
if (status.get('translation')) { if (status.get('translation')) {
dispatch(undoStatusTranslation(status.get('id'))); dispatch(undoStatusTranslation(status.get('id'), status.get('poll')));
} else { } else {
dispatch(translateStatus(status.get('id'))); dispatch(translateStatus(status.get('id')));
} }

View file

@ -8,7 +8,7 @@ import Audio from 'mastodon/features/audio';
import Footer from 'mastodon/features/picture_in_picture/components/footer'; import Footer from 'mastodon/features/picture_in_picture/components/footer';
const mapStateToProps = (state, { statusId }) => ({ const mapStateToProps = (state, { statusId }) => ({
language: state.getIn(['statuses', statusId, 'language']), status: state.getIn(['statuses', statusId]),
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']), accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
}); });
@ -17,7 +17,7 @@ class AudioModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
statusId: PropTypes.string.isRequired, statusId: PropTypes.string.isRequired,
language: PropTypes.string, status: ImmutablePropTypes.map.isRequired,
accountStaticAvatar: PropTypes.string.isRequired, accountStaticAvatar: PropTypes.string.isRequired,
options: PropTypes.shape({ options: PropTypes.shape({
autoPlay: PropTypes.bool, autoPlay: PropTypes.bool,
@ -27,15 +27,17 @@ class AudioModal extends ImmutablePureComponent {
}; };
render () { render () {
const { media, language, accountStaticAvatar, statusId, onClose } = this.props; const { media, status, accountStaticAvatar, onClose } = this.props;
const options = this.props.options || {}; const options = this.props.options || {};
const language = status.getIn(['translation', 'language']) || status.get('language');
const description = media.getIn(['translation', 'description']) || media.get('description');
return ( return (
<div className='modal-root__modal audio-modal'> <div className='modal-root__modal audio-modal'>
<div className='audio-modal__container'> <div className='audio-modal__container'>
<Audio <Audio
src={media.get('url')} src={media.get('url')}
alt={media.get('description')} alt={description}
lang={language} lang={language}
duration={media.getIn(['meta', 'original', 'duration'], 0)} duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150} height={150}
@ -48,7 +50,7 @@ class AudioModal extends ImmutablePureComponent {
</div> </div>
<div className='media-modal__overlay'> <div className='media-modal__overlay'>
{statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} {status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />}
</div> </div>
</div> </div>
); );

View file

@ -145,6 +145,7 @@ class MediaModal extends ImmutablePureComponent {
const content = media.map((image) => { const content = media.map((image) => {
const width = image.getIn(['meta', 'original', 'width']) || null; const width = image.getIn(['meta', 'original', 'width']) || null;
const height = image.getIn(['meta', 'original', 'height']) || null; const height = image.getIn(['meta', 'original', 'height']) || null;
const description = image.getIn(['translation', 'description']) || image.get('description');
if (image.get('type') === 'image') { if (image.get('type') === 'image') {
return ( return (
@ -153,7 +154,7 @@ class MediaModal extends ImmutablePureComponent {
src={image.get('url')} src={image.get('url')}
width={width} width={width}
height={height} height={height}
alt={image.get('description')} alt={description}
lang={lang} lang={lang}
key={image.get('url')} key={image.get('url')}
onClick={this.toggleNavigation} onClick={this.toggleNavigation}
@ -176,7 +177,7 @@ class MediaModal extends ImmutablePureComponent {
volume={volume || 1} volume={volume || 1}
onCloseVideo={onClose} onCloseVideo={onClose}
detailed detailed
alt={image.get('description')} alt={description}
lang={lang} lang={lang}
key={image.get('url')} key={image.get('url')}
/> />
@ -188,7 +189,7 @@ class MediaModal extends ImmutablePureComponent {
width={width} width={width}
height={height} height={height}
key={image.get('url')} key={image.get('url')}
alt={image.get('description')} alt={description}
lang={lang} lang={lang}
onClick={this.toggleNavigation} onClick={this.toggleNavigation}
/> />

View file

@ -73,6 +73,7 @@ class NavigationPanel extends Component {
<> <>
<ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} /> <ColumnLink transparent to='/public/local' icon='users' text={intl.formatMessage(messages.local)} />
<ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} /> <ColumnLink transparent exact to='/public' icon='globe' text={intl.formatMessage(messages.federated)} />
<ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
</> </>
)} )}
@ -96,7 +97,6 @@ class NavigationPanel extends Component {
<> <>
<ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} /> <ColumnLink transparent to='/bookmarks' icon='bookmark' text={intl.formatMessage(messages.bookmarks)} />
<ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} /> <ColumnLink transparent to='/favourites' icon='star' text={intl.formatMessage(messages.favourites)} />
<ColumnLink transparent to='/lists' icon='list-ul' text={intl.formatMessage(messages.lists)} />
<hr /> <hr />
<ColumnLink transparent href='/settings/preferences' icon='cog' text={intl.formatMessage(messages.preferences)} /> <ColumnLink transparent href='/settings/preferences' icon='cog' text={intl.formatMessage(messages.preferences)} />

View file

@ -9,7 +9,7 @@ import Footer from 'mastodon/features/picture_in_picture/components/footer';
import Video from 'mastodon/features/video'; import Video from 'mastodon/features/video';
const mapStateToProps = (state, { statusId }) => ({ const mapStateToProps = (state, { statusId }) => ({
language: state.getIn(['statuses', statusId, 'language']), status: state.getIn(['statuses', statusId]),
}); });
class VideoModal extends ImmutablePureComponent { class VideoModal extends ImmutablePureComponent {
@ -17,7 +17,7 @@ class VideoModal extends ImmutablePureComponent {
static propTypes = { static propTypes = {
media: ImmutablePropTypes.map.isRequired, media: ImmutablePropTypes.map.isRequired,
statusId: PropTypes.string, statusId: PropTypes.string,
language: PropTypes.string, status: ImmutablePropTypes.map,
options: PropTypes.shape({ options: PropTypes.shape({
startTime: PropTypes.number, startTime: PropTypes.number,
autoPlay: PropTypes.bool, autoPlay: PropTypes.bool,
@ -38,8 +38,10 @@ class VideoModal extends ImmutablePureComponent {
} }
render () { render () {
const { media, statusId, language, onClose } = this.props; const { media, status, onClose } = this.props;
const options = this.props.options || {}; const options = this.props.options || {};
const language = status.getIn(['translation', 'language']) || status.get('language');
const description = media.getIn(['translation', 'description']) || media.get('description');
return ( return (
<div className='modal-root__modal video-modal'> <div className='modal-root__modal video-modal'>
@ -55,13 +57,13 @@ class VideoModal extends ImmutablePureComponent {
onCloseVideo={onClose} onCloseVideo={onClose}
autoFocus autoFocus
detailed detailed
alt={media.get('description')} alt={description}
lang={language} lang={language}
/> />
</div> </div>
<div className='media-modal__overlay'> <div className='media-modal__overlay'>
{statusId && <Footer statusId={statusId} withOpenButton onClose={onClose} />} {status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />}
</div> </div>
</div> </div>
); );

View file

@ -79,7 +79,7 @@ export function Favourites () {
} }
export function EmojiReactions () { export function EmojiReactions () {
return import(/* webpackChunkName: "features/favourites" */'../../emoji_reactions'); return import(/* webpackChunkName: "features/emoji_reactions" */'../../emoji_reactions');
} }
export function FollowRequests () { export function FollowRequests () {

File diff suppressed because it is too large Load diff

View file

@ -19,9 +19,10 @@
"account.badges.group": "Group", "account.badges.group": "Group",
"account.block": "Block @{name}", "account.block": "Block @{name}",
"account.block_domain": "Block domain {domain}", "account.block_domain": "Block domain {domain}",
"account.block_short": "Block",
"account.blocked": "Blocked", "account.blocked": "Blocked",
"account.browse_more_on_origin_server": "Browse more on the original profile", "account.browse_more_on_origin_server": "Browse more on the original profile",
"account.cancel_follow_request": "Withdraw follow request", "account.cancel_follow_request": "Cancel follow",
"account.direct": "Privately mention @{name}", "account.direct": "Privately mention @{name}",
"account.disable_notifications": "Stop notifying me when @{name} posts", "account.disable_notifications": "Stop notifying me when @{name} posts",
"account.domain_blocked": "Domain blocked", "account.domain_blocked": "Domain blocked",
@ -50,7 +51,8 @@
"account.mention": "Mention @{name}", "account.mention": "Mention @{name}",
"account.moved_to": "{name} has indicated that their new account is now:", "account.moved_to": "{name} has indicated that their new account is now:",
"account.mute": "Mute @{name}", "account.mute": "Mute @{name}",
"account.mute_notifications": "Mute notifications from @{name}", "account.mute_notifications_short": "Mute notifications",
"account.mute_short": "Mute",
"account.muted": "Muted", "account.muted": "Muted",
"account.open_original_page": "Open original page", "account.open_original_page": "Open original page",
"account.posts": "Posts", "account.posts": "Posts",
@ -67,7 +69,7 @@
"account.unendorse": "Don't feature on profile", "account.unendorse": "Don't feature on profile",
"account.unfollow": "Unfollow", "account.unfollow": "Unfollow",
"account.unmute": "Unmute @{name}", "account.unmute": "Unmute @{name}",
"account.unmute_notifications": "Unmute notifications from @{name}", "account.unmute_notifications_short": "Unmute notifications",
"account.unmute_short": "Unmute", "account.unmute_short": "Unmute",
"account_note.placeholder": "Click to add note", "account_note.placeholder": "Click to add note",
"admin.dashboard.daily_retention": "User retention rate by day after sign-up", "admin.dashboard.daily_retention": "User retention rate by day after sign-up",
@ -75,6 +77,10 @@
"admin.dashboard.retention.average": "Average", "admin.dashboard.retention.average": "Average",
"admin.dashboard.retention.cohort": "Sign-up month", "admin.dashboard.retention.cohort": "Sign-up month",
"admin.dashboard.retention.cohort_size": "New users", "admin.dashboard.retention.cohort_size": "New users",
"admin.impact_report.instance_accounts": "Accounts profiles this would delete",
"admin.impact_report.instance_followers": "Followers our users would lose",
"admin.impact_report.instance_follows": "Followers their users would lose",
"admin.impact_report.title": "Impact summary",
"alert.rate_limited.message": "Please retry after {retry_time, time, medium}.", "alert.rate_limited.message": "Please retry after {retry_time, time, medium}.",
"alert.rate_limited.title": "Rate limited", "alert.rate_limited.title": "Rate limited",
"alert.unexpected.message": "An unexpected error occurred.", "alert.unexpected.message": "An unexpected error occurred.",
@ -355,9 +361,11 @@
"limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.", "limited_account_hint.title": "This profile has been hidden by the moderators of {domain}.",
"lists.account.add": "Add to list", "lists.account.add": "Add to list",
"lists.account.remove": "Remove from list", "lists.account.remove": "Remove from list",
"lists.antennas": "Related antennas",
"lists.delete": "Delete list", "lists.delete": "Delete list",
"lists.edit": "Edit list", "lists.edit": "Edit list",
"lists.edit.submit": "Change title", "lists.edit.submit": "Change title",
"lists.exclusive": "Hide these posts from home",
"lists.new.create": "Add list", "lists.new.create": "Add list",
"lists.new.title_placeholder": "New list title", "lists.new.title_placeholder": "New list title",
"lists.replies_policy.followed": "Any followed user", "lists.replies_policy.followed": "Any followed user",
@ -465,8 +473,8 @@
"onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:", "onboarding.start.lead": "Your new Mastodon account is ready to go. Here's how you can make the most of it:",
"onboarding.start.skip": "Want to skip right ahead?", "onboarding.start.skip": "Want to skip right ahead?",
"onboarding.start.title": "You've made it!", "onboarding.start.title": "You've made it!",
"onboarding.steps.follow_people.body": "You curate your own feed. Lets fill it with interesting people.", "onboarding.steps.follow_people.body": "You curate your own home feed. Let's fill it with interesting people.",
"onboarding.steps.follow_people.title": "Follow {count, plural, one {one person} other {# people}}", "onboarding.steps.follow_people.title": "Find at least {count, plural, one {one person} other {# people}} to follow",
"onboarding.steps.publish_status.body": "Say hello to the world.", "onboarding.steps.publish_status.body": "Say hello to the world.",
"onboarding.steps.publish_status.title": "Make your first post", "onboarding.steps.publish_status.title": "Make your first post",
"onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.", "onboarding.steps.setup_profile.body": "Others are more likely to interact with you with a filled out profile.",

View file

@ -0,0 +1,22 @@
export interface LocaleData {
locale: string;
messages: Record<string, string>;
}
let loadedLocale: LocaleData;
export function setLocale(locale: LocaleData) {
loadedLocale = locale;
}
export function getLocale() {
if (!loadedLocale && process.env.NODE_ENV === 'development') {
throw new Error('getLocale() called before any locale has been set');
}
return loadedLocale;
}
export function isLocaleLoaded() {
return !!loadedLocale;
}

View file

@ -1,9 +0,0 @@
let theLocale;
export function setLocale(locale) {
theLocale = locale;
}
export function getLocale() {
return theLocale;
}

View file

@ -0,0 +1,5 @@
export type { LocaleData } from './global_locale';
export { setLocale, getLocale, isLocaleLoaded } from './global_locale';
export { loadLocale } from './load_locale';
export { IntlProvider } from './intl_provider';

View file

@ -0,0 +1,57 @@
import { useEffect, useState } from 'react';
import { IntlProvider as BaseIntlProvider } from 'react-intl';
import { getLocale, isLocaleLoaded } from './global_locale';
import { loadLocale } from './load_locale';
function onProviderError(error: unknown) {
// Silent the error, like upstream does
if (process.env.NODE_ENV === 'production') return;
// This browser does not advertise Intl support for this locale, we only print a warning
// As-per the spec, the browser should select the best matching locale
if (
error &&
typeof error === 'object' &&
error instanceof Error &&
error.message.match('MISSING_DATA')
) {
console.warn(error.message);
}
console.error(error);
}
export const IntlProvider: React.FC<
Omit<React.ComponentProps<typeof BaseIntlProvider>, 'locale' | 'messages'>
> = ({ children, ...props }) => {
const [localeLoaded, setLocaleLoaded] = useState(false);
useEffect(() => {
async function loadLocaleData() {
if (!isLocaleLoaded()) {
await loadLocale();
}
setLocaleLoaded(true);
}
void loadLocaleData();
}, []);
if (!localeLoaded) return null;
const { locale, messages } = getLocale();
return (
<BaseIntlProvider
locale={locale}
messages={messages}
onError={onProviderError}
textComponent='span'
{...props}
>
{children}
</BaseIntlProvider>
);
};

View file

@ -357,6 +357,7 @@
"limited_account_hint.title": "このプロフィールは{domain}のモデレーターによって非表示にされています。", "limited_account_hint.title": "このプロフィールは{domain}のモデレーターによって非表示にされています。",
"lists.account.add": "リストに追加", "lists.account.add": "リストに追加",
"lists.account.remove": "リストから外す", "lists.account.remove": "リストから外す",
"lists.antennas": "関連付けられたアンテナ",
"lists.delete": "リストを削除", "lists.delete": "リストを削除",
"lists.edit": "リストを編集", "lists.edit": "リストを編集",
"lists.edit.submit": "タイトルを変更", "lists.edit.submit": "タイトルを変更",

View file

@ -0,0 +1,29 @@
import { Semaphore } from 'async-mutex';
import type { LocaleData } from './global_locale';
import { isLocaleLoaded, setLocale } from './global_locale';
const localeLoadingSemaphore = new Semaphore(1);
export async function loadLocale() {
const locale = document.querySelector<HTMLElement>('html')?.lang || 'en';
// We use a Semaphore here so only one thing can try to load the locales at
// the same time. If one tries to do it while its in progress, it will wait
// for the initial load to finish before it is resumed (and will see that locale
// data is already loaded)
await localeLoadingSemaphore.runExclusive(async () => {
// if the locale is already set, then do nothing
if (isLocaleLoaded()) return;
const localeData = (await import(
/* webpackMode: "lazy" */
/* webpackChunkName: "locale/[request]" */
/* webpackInclude: /\.json$/ */
/* webpackPreload: true */
`mastodon/locales/${locale}.json`
)) as LocaleData['messages'];
setLocale({ messages: localeData, locale });
});
}

View file

@ -1,221 +0,0 @@
# Custom Locale Data
This folder is used to store custom locale data. These custom locale data are
not yet provided by [Unicode Common Locale Data Repository](http://cldr.unicode.org/development/new-cldr-developers)
and hence not provided in [react-intl/locale-data/*](https://github.com/yahoo/react-intl).
The locale data should support [Locale Data APIs](https://github.com/yahoo/react-intl/wiki/API#locale-data-apis)
of the react-intl library.
It is recommended to start your custom locale data from this sample English
locale data ([*](#plural-rules)):
```javascript
/*eslint eqeqeq: "off"*/
/*eslint no-nested-ternary: "off"*/
export default [
{
locale: "en",
pluralRuleFunction: function(e, a) {
var n = String(e).split("."),
l = !n[1],
o = Number(n[0]) == e,
t = o && n[0].slice(-1),
r = o && n[0].slice(-2);
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
},
fields: {
year: {
displayName: "year",
relative: {
0: "this year",
1: "next year",
"-1": "last year"
},
relativeTime: {
future: {
one: "in {0} year",
other: "in {0} years"
},
past: {
one: "{0} year ago",
other: "{0} years ago"
}
}
},
month: {
displayName: "month",
relative: {
0: "this month",
1: "next month",
"-1": "last month"
},
relativeTime: {
future: {
one: "in {0} month",
other: "in {0} months"
},
past: {
one: "{0} month ago",
other: "{0} months ago"
}
}
},
day: {
displayName: "day",
relative: {
0: "today",
1: "tomorrow",
"-1": "yesterday"
},
relativeTime: {
future: {
one: "in {0} day",
other: "in {0} days"
},
past: {
one: "{0} day ago",
other: "{0} days ago"
}
}
},
hour: {
displayName: "hour",
relativeTime: {
future: {
one: "in {0} hour",
other: "in {0} hours"
},
past: {
one: "{0} hour ago",
other: "{0} hours ago"
}
}
},
minute: {
displayName: "minute",
relativeTime: {
future: {
one: "in {0} minute",
other: "in {0} minutes"
},
past: {
one: "{0} minute ago",
other: "{0} minutes ago"
}
}
},
second: {
displayName: "second",
relative: {
0: "now"
},
relativeTime: {
future: {
one: "in {0} second",
other: "in {0} seconds"
},
past: {
one: "{0} second ago",
other: "{0} seconds ago"
}
}
}
}
}
]
```
## Notes
### Plural Rules
The function `pluralRuleFunction()` should return the key to proper string of
a plural form(s). The purpose of the function is to provide key of translate
strings of correct plural form according. The different forms are described in
[CLDR's Plural Rules][cldr-plural-rules],
[cldr-plural-rules]: http://cldr.unicode.org/index/cldr-spec/plural-rules
#### Quick Overview on CLDR Rules
Let's take English as an example.
When you describe a number, you can be either describe it as:
* Cardinals: 1st, 2nd, 3rd ... 11th, 12th ... 21st, 22nd, 23nd ....
* Ordinals: 1, 2, 3 ...
In any of these cases, the nouns will reflect the number with singular or plural
form. For example:
* in 0 days
* in 1 day
* in 2 days
The `pluralRuleFunction` receives 2 parameters:
* `e`: a string representation of the number. Such as, "`1`", "`2`", "`2.1`".
* `a`: `true` if this is "cardinal" type of description. `false` for ordinal and other case.
#### How you should write `pluralRuleFunction`
The first rule to write pluralRuleFunction is never translate the output string
into your language. [Plural Rules][cldr-plural-rules] specified you should use
these as the return values:
* "`zero`"
* "`one`" (singular)
* "`two`" (dual)
* "`few`" (paucal)
* "`many`" (also used for fractions if they have a separate class)
* "`other`" (required—general plural form—also used if the language only has a single form)
Again, we'll use English as the example here.
Let's read the `return` statement in the pluralRuleFunction above:
```javascript
return a ? 1 == t && 11 != r ? "one" : 2 == t && 12 != r ? "two" : 3 == t && 13 != r ? "few" : "other" : 1 == e && l ? "one" : "other"
```
This nested ternary is hard to read. It basically means:
```javascript
// e: the number variable to examine
// a: "true" if cardinals
// l: "true" if the variable e has nothin after decimal mark (e.g. "1.0" would be false)
// o: "true" if the variable e is an integer
// t: the "ones" of the number. e.g. "3" for number "9123"
// r: the "ones" and "tens" of the number. e.g. "23" for number "9123"
if (a == true) {
if (t == 1 && r != 11) {
return "one"; // i.e. 1st, 21st, 101st, 121st ...
} else if (t == 2 && r != 12) {
return "two"; // i.e. 2nd, 22nd, 102nd, 122nd ...
} else if (t == 3 && r != 13) {
return "few"; // i.e. 3rd, 23rd, 103rd, 123rd ...
} else {
return "other"; // i.e. 4th, 11th, 12th, 24th ...
}
} else {
if (e == 1 && l) {
return "one"; // i.e. 1 day
} else {
return "other"; // i.e. 0 days, 2 days, 3 days
}
}
```
If your language, like French, do not have complicated cardinal rules, you may
use the French's version of it:
```javascript
function (e, a) {
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
}
```
If your language, like Chinese, do not have any pluralization rule at all you
may use the Chinese's version of it:
```javascript
function (e, a) {
return "other";
}
```

View file

@ -1,110 +0,0 @@
/*eslint eqeqeq: "off"*/
/*eslint no-nested-ternary: "off"*/
/*eslint quotes: "off"*/
const rules = [{
locale: "co",
pluralRuleFunction: function (e, a) {
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
},
fields: {
year: {
displayName: "annu",
relative: {
0: "quist'annu",
1: "l'annu chì vene",
"-1": "l'annu passatu",
},
relativeTime: {
future: {
one: "in {0} annu",
other: "in {0} anni",
},
past: {
one: "{0} annu fà",
other: "{0} anni fà",
},
},
},
month: {
displayName: "mese",
relative: {
0: "Questu mese",
1: "u mese chì vene",
"-1": "u mese passatu",
},
relativeTime: {
future: {
one: "in {0} mese",
other: "in {0} mesi",
},
past: {
one: "{0} mese fà",
other: "{0} mesi fà",
},
},
},
day: {
displayName: "ghjornu",
relative: {
0: "oghje",
1: "dumane",
"-1": "eri",
},
relativeTime: {
future: {
one: "in {0} ghjornu",
other: "in {0} ghjornu",
},
past: {
one: "{0} ghjornu fà",
other: "{0} ghjorni fà",
},
},
},
hour: {
displayName: "ora",
relativeTime: {
future: {
one: "in {0} ora",
other: "in {0} ore",
},
past: {
one: "{0} ora fà",
other: "{0} ore fà",
},
},
},
minute: {
displayName: "minuta",
relativeTime: {
future: {
one: "in {0} minuta",
other: "in {0} minute",
},
past: {
one: "{0} minuta fà",
other: "{0} minute fà",
},
},
},
second: {
displayName: "siconda",
relative: {
0: "avà",
},
relativeTime: {
future: {
one: "in {0} siconda",
other: "in {0} siconde",
},
past: {
one: "{0} siconda fà",
other: "{0} siconde fà",
},
},
},
},
}];
export default rules;

View file

@ -1,110 +0,0 @@
/*eslint eqeqeq: "off"*/
/*eslint no-nested-ternary: "off"*/
/*eslint quotes: "off"*/
const rules = [{
locale: "oc",
pluralRuleFunction: function (e, a) {
return a ? 1 == e ? "one" : "other" : e >= 0 && e < 2 ? "one" : "other";
},
fields: {
year: {
displayName: "an",
relative: {
0: "ongan",
1: "l'an que ven",
"-1": "l'an passat",
},
relativeTime: {
future: {
one: "daquí {0} an",
other: "daquí {0} ans",
},
past: {
one: "fa {0} an",
other: "fa {0} ans",
},
},
},
month: {
displayName: "mes",
relative: {
0: "aqueste mes",
1: "lo mes que ven",
"-1": "lo mes passat",
},
relativeTime: {
future: {
one: "daquí {0} mes",
other: "daquí {0} meses",
},
past: {
one: "fa {0} mes",
other: "fa {0} meses",
},
},
},
day: {
displayName: "jorn",
relative: {
0: "uèi",
1: "deman",
"-1": "ièr",
},
relativeTime: {
future: {
one: "daquí {0} jorn",
other: "daquí {0} jorns",
},
past: {
one: "fa {0} jorn",
other: "fa {0} jorns",
},
},
},
hour: {
displayName: "ora",
relativeTime: {
future: {
one: "daquí {0} ora",
other: "daquí {0} oras",
},
past: {
one: "fa {0} ora",
other: "fa {0} oras",
},
},
},
minute: {
displayName: "minuta",
relativeTime: {
future: {
one: "daquí {0} minuta",
other: "daquí {0} minutas",
},
past: {
one: "fa {0} minuta",
other: "fa {0} minutas",
},
},
},
second: {
displayName: "segonda",
relative: {
0: "ara",
},
relativeTime: {
future: {
one: "daquí {0} segonda",
other: "daquí {0} segondas",
},
past: {
one: "fa {0} segonda",
other: "fa {0} segondas",
},
},
},
},
}];
export default rules;

View file

@ -1,98 +0,0 @@
/*eslint eqeqeq: "off"*/
/*eslint no-nested-ternary: "off"*/
/*eslint quotes: "off"*/
/*eslint comma-dangle: "off"*/
const rules = [
{
locale: "sa",
fields: {
year: {
displayName: "year",
relative: {
0: "this year",
1: "next year",
"-1": "last year"
},
relativeTime: {
future: {
other: "+{0} y"
},
past: {
other: "-{0} y"
}
}
},
month: {
displayName: "month",
relative: {
0: "this month",
1: "next month",
"-1": "last month"
},
relativeTime: {
future: {
other: "+{0} m"
},
past: {
other: "-{0} m"
}
}
},
day: {
displayName: "day",
relative: {
0: "अद्य",
1: "श्वः",
"-1": "गतदिनम्"
},
relativeTime: {
future: {
other: "+{0} d"
},
past: {
other: "-{0} d"
}
}
},
hour: {
displayName: "hour",
relativeTime: {
future: {
other: "+{0} h"
},
past: {
other: "-{0} h"
}
}
},
minute: {
displayName: "minute",
relativeTime: {
future: {
other: "+{0} min"
},
past: {
other: "-{0} min"
}
}
},
second: {
displayName: "second",
relative: {
0: "now"
},
relativeTime: {
future: {
other: "+{0} s"
},
past: {
other: "-{0} s"
}
}
}
}
}
];
export default rules;

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,5 +0,0 @@
[
"account.badges.bot",
"compose_form.publish_loud",
"search_results.hashtags"
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

View file

@ -1,2 +0,0 @@
[
]

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