Merge remote-tracking branch 'glitch/main' (a21fe8687e)
ci/woodpecker/push/woodpecker Pipeline was successful Details

streaming-builds
kouhai dev 2023-11-27 22:59:37 -08:00
commit 78a9906dfe
591 changed files with 5242 additions and 6013 deletions

View File

@ -236,7 +236,7 @@ module.exports = {
}, },
// Common React utilities // Common React utilities
{ {
pattern: '{classnames,react-helmet,react-router-dom}', pattern: '{classnames,react-helmet,react-router,react-router-dom}',
group: 'external', group: 'external',
position: 'before', position: 'before',
}, },

View File

@ -0,0 +1,19 @@
name: 'Setup Javascript'
description: 'Setup a Javascript environment ready to run the Mastodon code'
inputs:
onlyProduction:
description: Only install production dependencies
default: 'false'
runs:
using: 'composite'
steps:
- name: Set up Node.js
uses: actions/setup-node@v3
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages
shell: bash
run: yarn --frozen-lockfile ${{ inputs.onlyProduction != 'false' && '--production' || '' }}

23
.github/actions/setup-ruby/action.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: 'Setup RUby'
description: 'Setup a Ruby environment ready to run the Mastodon code'
inputs:
ruby-version:
description: The Ruby version to install
default: '.ruby-version'
additional-system-dependencies:
description: 'Additional packages to install'
runs:
using: 'composite'
steps:
- name: Install system dependencies
shell: bash
run: |
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev ${{ inputs.additional-system-dependencies }}
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ inputs.ruby-version }}
bundler-cache: true

View File

@ -3,7 +3,6 @@
extends: [ extends: [
'config:recommended', 'config:recommended',
':labels(dependencies)', ':labels(dependencies)',
':maintainLockFilesMonthly', // update non-direct dependencies monthly
':prConcurrentLimitNone', // Remove limit for open PRs at any time. ':prConcurrentLimitNone', // Remove limit for open PRs at any time.
':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour. ':prHourlyLimit2', // Rate limit PR creation to a maximum of two per hour.
], ],

View File

@ -27,14 +27,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Set up Ruby environment
run: sudo apt-get install -y libicu-dev libidn11-dev uses: ./.github/actions/setup-ruby
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Run bundler-audit - name: Run bundler-audit
run: bundle exec bundler-audit run: bundle exec bundler-audit

View File

@ -19,25 +19,11 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install system dependencies - name: Set up Ruby environment
run: | uses: ./.github/actions/setup-ruby
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev
- name: Set up Ruby - name: Set up Javascript environment
uses: ruby/setup-ruby@v1 uses: ./.github/actions/setup-javascript
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Set up Node.js
uses: actions/setup-node@v3
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages
run: yarn --frozen-lockfile
- name: Check for missing strings in English JSON - name: Check for missing strings in English JSON
run: | run: |

View File

@ -45,14 +45,8 @@ jobs:
run: sudo chown -R runner:docker . run: sudo chown -R runner:docker .
# This is needed to run the normalize step # This is needed to run the normalize step
- name: Install native Ruby dependencies - name: Set up Ruby environment
run: sudo apt-get install -y libicu-dev libidn11-dev uses: ./.github/actions/setup-ruby
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Run i18n normalize task - name: Run i18n normalize task
run: bundle exec i18n-tasks normalize run: bundle exec i18n-tasks normalize

View File

@ -35,14 +35,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Javascript environment
uses: actions/setup-node@v3 uses: ./.github/actions/setup-javascript
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages
run: yarn --frozen-lockfile
- uses: xt0rted/stylelint-problem-matcher@v1 - uses: xt0rted/stylelint-problem-matcher@v1

View File

@ -30,16 +30,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Set up Ruby environment
run: | uses: ./.github/actions/setup-ruby
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Run haml-lint - name: Run haml-lint
run: | run: |

View File

@ -39,14 +39,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Javascript environment
uses: actions/setup-node@v3 uses: ./.github/actions/setup-javascript
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages
run: yarn --frozen-lockfile
- name: ESLint - name: ESLint
run: yarn lint:js --max-warnings 0 run: yarn lint:js --max-warnings 0

View File

@ -31,14 +31,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Javascript environment
uses: actions/setup-node@v3 uses: ./.github/actions/setup-javascript
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages
run: yarn --frozen-lockfile
- name: Prettier - name: Prettier
run: yarn lint:json run: yarn lint:json

View File

@ -31,14 +31,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Javascript environment
uses: actions/setup-node@v3 uses: ./.github/actions/setup-javascript
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages
run: yarn --frozen-lockfile
- name: Prettier - name: Prettier
run: yarn lint:md run: yarn lint:md

View File

@ -31,14 +31,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Set up Ruby environment
run: sudo apt-get install -y libicu-dev libidn11-dev uses: ./.github/actions/setup-ruby
- name: Set up Ruby
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Set-up RuboCop Problem Matcher - name: Set-up RuboCop Problem Matcher
uses: r7kamura/rubocop-problem-matchers-action@v1 uses: r7kamura/rubocop-problem-matchers-action@v1

View File

@ -33,14 +33,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Javascript environment
uses: actions/setup-node@v3 uses: ./.github/actions/setup-javascript
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages
run: yarn --frozen-lockfile
- name: Prettier - name: Prettier
run: yarn lint:yml run: yarn lint:yml

View File

@ -35,14 +35,8 @@ jobs:
- name: Clone repository - name: Clone repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Javascript environment
uses: actions/setup-node@v3 uses: ./.github/actions/setup-javascript
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install all yarn packages
run: yarn --frozen-lockfile
- name: Jest testing - name: Jest testing
run: yarn jest --reporters github-actions summary run: yarn jest --reporters github-actions summary

View File

@ -72,16 +72,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Set up Ruby environment
run: | uses: ./.github/actions/setup-ruby
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev
- name: Set up bundler cache
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Create database - name: Create database
run: './bin/rails db:create' run: './bin/rails db:create'

View File

@ -71,16 +71,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install native Ruby dependencies - name: Set up Ruby environment
run: | uses: ./.github/actions/setup-ruby
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev
- name: Set up bundler cache
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- name: Create database - name: Create database
run: './bin/rails db:create' run: './bin/rails db:create'

View File

@ -34,24 +34,14 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Node.js - name: Set up Ruby environment
uses: actions/setup-node@v3 uses: ./.github/actions/setup-ruby
- name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
with: with:
cache: yarn onlyProduction: 'true'
node-version-file: '.nvmrc'
- name: Install native Ruby dependencies
run: |
sudo apt-get update
sudo apt-get install -y libicu-dev libidn11-dev
- name: Set up bundler cache
uses: ruby/setup-ruby@v1
with:
ruby-version: .ruby-version
bundler-cache: true
- run: yarn --frozen-lockfile --production
- name: Precompile assets - name: Precompile assets
# Previously had set this, but it's not supported # Previously had set this, but it's not supported
# export NODE_OPTIONS=--openssl-legacy-provider # export NODE_OPTIONS=--openssl-legacy-provider
@ -135,20 +125,11 @@ jobs:
path: './public' path: './public'
name: ${{ github.sha }} name: ${{ github.sha }}
- name: Update package index - name: Set up Ruby environment
run: sudo apt-get update uses: ./.github/actions/setup-ruby
- name: Install native Ruby dependencies
run: sudo apt-get install -y libicu-dev libidn11-dev
- name: Install additional system dependencies
run: sudo apt-get install -y ffmpeg imagemagick libpam-dev
- name: Set up bundler cache
uses: ruby/setup-ruby@v1
with: with:
ruby-version: ${{ matrix.ruby-version}} ruby-version: ${{ matrix.ruby-version}}
bundler-cache: true additional-system-dependencies: ffmpeg imagemagick libpam-dev
- name: Load database schema - name: Load database schema
run: './bin/rails db:create db:schema:load db:seed' run: './bin/rails db:create db:schema:load db:seed'
@ -210,28 +191,14 @@ jobs:
path: './public' path: './public'
name: ${{ github.sha }} name: ${{ github.sha }}
- name: Update package index - name: Set up Ruby environment
run: sudo apt-get update uses: ./.github/actions/setup-ruby
- name: Set up Node.js
uses: actions/setup-node@v3
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install native Ruby dependencies
run: sudo apt-get install -y libicu-dev libidn11-dev
- name: Install additional system dependencies
run: sudo apt-get install -y ffmpeg imagemagick
- name: Set up bundler cache
uses: ruby/setup-ruby@v1
with: with:
ruby-version: ${{ matrix.ruby-version}} ruby-version: ${{ matrix.ruby-version}}
bundler-cache: true additional-system-dependencies: ffmpeg imagemagick
- run: yarn --frozen-lockfile - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
- name: Load database schema - name: Load database schema
run: './bin/rails db:create db:schema:load db:seed' run: './bin/rails db:create db:schema:load db:seed'
@ -328,28 +295,14 @@ jobs:
path: './public' path: './public'
name: ${{ github.sha }} name: ${{ github.sha }}
- name: Update package index - name: Set up Ruby environment
run: sudo apt-get update uses: ./.github/actions/setup-ruby
- name: Set up Node.js
uses: actions/setup-node@v3
with:
cache: yarn
node-version-file: '.nvmrc'
- name: Install native Ruby dependencies
run: sudo apt-get install -y libicu-dev libidn11-dev
- name: Install additional system dependencies
run: sudo apt-get install -y ffmpeg imagemagick
- name: Set up bundler cache
uses: ruby/setup-ruby@v1
with: with:
ruby-version: ${{ matrix.ruby-version}} ruby-version: ${{ matrix.ruby-version}}
bundler-cache: true additional-system-dependencies: ffmpeg imagemagick
- run: yarn --frozen-lockfile - name: Set up Javascript environment
uses: ./.github/actions/setup-javascript
- name: Load database schema - name: Load database schema
run: './bin/rails db:create db:schema:load db:seed' run: './bin/rails db:create db:schema:load db:seed'

View File

@ -1,33 +1,21 @@
# This configuration was generated by # This configuration was generated by
# `haml-lint --auto-gen-config` # `haml-lint --auto-gen-config`
# on 2023-10-11 11:31:24 -0400 using Haml-Lint version 0.51.0. # on 2023-10-25 08:29:48 -0400 using Haml-Lint version 0.51.0.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the lints are removed from the code base. # one by one as the lints are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
# versions of Haml-Lint, may require this file to be generated again. # versions of Haml-Lint, may require this file to be generated again.
linters: linters:
# Offense count: 946 # Offense count: 945
LineLength: LineLength:
enabled: false enabled: false
# Offense count: 22 # Offense count: 10
UnnecessaryStringOutput:
enabled: false
# Offense count: 44
RuboCop: RuboCop:
enabled: false
# Offense count: 3
ViewLength:
exclude: exclude:
- 'app/views/admin/accounts/show.html.haml' - 'app/views/admin/accounts/_buttons.html.haml'
- 'app/views/admin/reports/show.html.haml' - 'app/views/admin/accounts/_local_account.html.haml'
- 'app/views/disputes/strikes/show.html.haml' - 'app/views/admin/accounts/index.html.haml'
- 'app/views/admin/roles/_form.html.haml'
# Offense count: 2 - 'app/views/layouts/application.html.haml'
IdNames:
exclude:
- 'app/views/oauth/authorizations/error.html.haml'
- 'app/views/shared/_error_messages.html.haml'

2
.nvmrc
View File

@ -1 +1 @@
20.8 20.9

View File

@ -1,6 +1,6 @@
# This configuration was generated by # This configuration was generated by
# `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp` # `rubocop --auto-gen-config --auto-gen-only-exclude --no-exclude-limit --no-offense-counts --no-auto-gen-timestamp`
# using RuboCop version 1.56.1. # using RuboCop version 1.57.1.
# The point is for the user to remove these configuration records # The point is for the user to remove these configuration records
# one by one as the offenses are removed from the code base. # one by one as the offenses are removed from the code base.
# Note that changes in the inspected code, or installation of new # Note that changes in the inspected code, or installation of new
@ -80,7 +80,6 @@ RSpec/AnyInstance:
- 'spec/controllers/admin/accounts_controller_spec.rb' - 'spec/controllers/admin/accounts_controller_spec.rb'
- 'spec/controllers/admin/resets_controller_spec.rb' - 'spec/controllers/admin/resets_controller_spec.rb'
- 'spec/controllers/admin/settings/branding_controller_spec.rb' - 'spec/controllers/admin/settings/branding_controller_spec.rb'
- 'spec/controllers/api/v1/media_controller_spec.rb'
- 'spec/controllers/auth/sessions_controller_spec.rb' - 'spec/controllers/auth/sessions_controller_spec.rb'
- 'spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb' - 'spec/controllers/settings/two_factor_authentication/confirmations_controller_spec.rb'
- 'spec/controllers/settings/two_factor_authentication/recovery_codes_controller_spec.rb' - 'spec/controllers/settings/two_factor_authentication/recovery_codes_controller_spec.rb'
@ -180,7 +179,6 @@ RSpec/LetSetup:
RSpec/MessageChain: RSpec/MessageChain:
Exclude: Exclude:
- 'spec/controllers/api/v1/media_controller_spec.rb'
- 'spec/models/concerns/remotable_spec.rb' - 'spec/models/concerns/remotable_spec.rb'
- 'spec/models/session_activation_spec.rb' - 'spec/models/session_activation_spec.rb'
- 'spec/models/setting_spec.rb' - 'spec/models/setting_spec.rb'
@ -219,19 +217,6 @@ Rails/ApplicationController:
Exclude: Exclude:
- 'app/controllers/health_controller.rb' - 'app/controllers/health_controller.rb'
# Configuration parameters: Include.
# Include: db/**/*.rb
Rails/CreateTableWithTimestamps:
Exclude:
- 'db/migrate/20170508230434_create_conversation_mutes.rb'
- 'db/migrate/20170823162448_create_status_pins.rb'
- 'db/migrate/20171116161857_create_list_accounts.rb'
- 'db/migrate/20180929222014_create_account_conversations.rb'
- 'db/migrate/20181007025445_create_pghero_space_stats.rb'
- 'db/migrate/20190103124649_create_scheduled_statuses.rb'
- 'db/migrate/20220824233535_create_status_trends.rb'
- 'db/migrate/20221006061337_create_preview_card_trends.rb'
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: Severity. # Configuration parameters: Severity.
Rails/DuplicateAssociation: Rails/DuplicateAssociation:
@ -273,7 +258,6 @@ Rails/LexicallyScopedActionFilter:
Exclude: Exclude:
- 'app/controllers/auth/passwords_controller.rb' - 'app/controllers/auth/passwords_controller.rb'
- 'app/controllers/auth/registrations_controller.rb' - 'app/controllers/auth/registrations_controller.rb'
- 'app/controllers/auth/sessions_controller.rb'
# This cop supports unsafe autocorrection (--autocorrect-all). # This cop supports unsafe autocorrection (--autocorrect-all).
Rails/NegateInclude: Rails/NegateInclude:
@ -289,7 +273,6 @@ Rails/NegateInclude:
- 'app/models/custom_filter.rb' - 'app/models/custom_filter.rb'
- 'app/services/activitypub/process_status_update_service.rb' - 'app/services/activitypub/process_status_update_service.rb'
- 'app/services/fetch_link_card_service.rb' - 'app/services/fetch_link_card_service.rb'
- 'app/services/search_service.rb'
- 'app/workers/web/push_notification_worker.rb' - 'app/workers/web/push_notification_worker.rb'
- 'lib/paperclip/color_extractor.rb' - 'lib/paperclip/color_extractor.rb'
@ -309,24 +292,6 @@ Rails/RakeEnvironment:
- 'lib/tasks/repo.rake' - 'lib/tasks/repo.rake'
- 'lib/tasks/statistics.rake' - 'lib/tasks/statistics.rake'
# Configuration parameters: Include.
# Include: db/**/*.rb
Rails/ReversibleMigration:
Exclude:
- 'db/migrate/20160223164502_make_uris_nullable_in_statuses.rb'
- 'db/migrate/20161122163057_remove_unneeded_indexes.rb'
- 'db/migrate/20170205175257_remove_devices.rb'
- 'db/migrate/20170322143850_change_primary_key_to_bigint_on_statuses.rb'
- 'db/migrate/20170520145338_change_language_filter_to_opt_out.rb'
- 'db/migrate/20170609145826_remove_default_language_from_statuses.rb'
- 'db/migrate/20170711225116_fix_null_booleans.rb'
- 'db/migrate/20171129172043_add_index_on_stream_entries.rb'
- 'db/migrate/20171212195226_remove_duplicate_indexes_in_lists.rb'
- 'db/migrate/20171226094803_more_faster_index_on_notifications.rb'
- 'db/migrate/20180106000232_add_index_on_statuses_for_api_v1_accounts_account_id_statuses.rb'
- 'db/migrate/20180617162849_remove_unused_indexes.rb'
- 'db/migrate/20220827195229_change_canonical_email_blocks_nullable.rb'
# Configuration parameters: ForbiddenMethods, AllowedMethods. # Configuration parameters: ForbiddenMethods, AllowedMethods.
# ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all # ForbiddenMethods: decrement!, decrement_counter, increment!, increment_counter, insert, insert!, insert_all, insert_all!, toggle!, touch, touch_all, update_all, update_attribute, update_column, update_columns, update_counters, upsert, upsert_all
Rails/SkipsModelValidations: Rails/SkipsModelValidations:
@ -379,31 +344,6 @@ Rails/SkipsModelValidations:
- 'spec/services/follow_service_spec.rb' - 'spec/services/follow_service_spec.rb'
- 'spec/services/update_account_service_spec.rb' - 'spec/services/update_account_service_spec.rb'
# Configuration parameters: Include.
# Include: db/**/*.rb
Rails/ThreeStateBooleanColumn:
Exclude:
- 'db/migrate/20160325130944_add_admin_to_users.rb'
- 'db/migrate/20161123093447_add_sensitive_to_statuses.rb'
- 'db/migrate/20170123203248_add_reject_media_to_domain_blocks.rb'
- 'db/migrate/20170127165745_add_devise_two_factor_to_users.rb'
- 'db/migrate/20170209184350_add_reply_to_statuses.rb'
- 'db/migrate/20170330163835_create_imports.rb'
- 'db/migrate/20170905165803_add_local_to_statuses.rb'
- 'db/migrate/20171210213213_add_local_only_flag_to_statuses.rb'
- 'db/migrate/20181203021853_add_discoverable_to_accounts.rb'
- 'db/migrate/20190509164208_add_by_moderator_to_tombstone.rb'
- 'db/migrate/20190805123746_add_capabilities_to_tags.rb'
- 'db/migrate/20191212163405_add_hide_collections_to_accounts.rb'
- 'db/migrate/20200309150742_add_forwarded_to_reports.rb'
- 'db/migrate/20210609202149_create_login_activities.rb'
- 'db/migrate/20210621221010_add_skip_sign_in_token_to_users.rb'
- 'db/migrate/20211031031021_create_preview_card_providers.rb'
- 'db/migrate/20211115032527_add_trendable_to_preview_cards.rb'
- 'db/migrate/20220202200743_add_trendable_to_accounts.rb'
- 'db/migrate/20220202200926_add_trendable_to_statuses.rb'
- 'db/migrate/20220303000827_add_ordered_media_attachment_ids_to_status_edits.rb'
# Configuration parameters: Include. # Configuration parameters: Include.
# Include: app/models/**/*.rb # Include: app/models/**/*.rb
Rails/UniqueValidationWithoutIndex: Rails/UniqueValidationWithoutIndex:
@ -467,7 +407,7 @@ Style/CaseEquality:
Exclude: Exclude:
- 'config/initializers/trusted_proxies.rb' - 'config/initializers/trusted_proxies.rb'
# This cop supports safe autocorrection (--autocorrect). # This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: AllowedMethods, AllowedPatterns. # Configuration parameters: AllowedMethods, AllowedPatterns.
# AllowedMethods: ==, equal?, eql? # AllowedMethods: ==, equal?, eql?
Style/ClassEqualityComparison: Style/ClassEqualityComparison:
@ -675,7 +615,6 @@ Style/RedundantReturn:
Style/SafeNavigation: Style/SafeNavigation:
Exclude: Exclude:
- 'app/models/concerns/account_finder_concern.rb' - 'app/models/concerns/account_finder_concern.rb'
- 'app/models/status.rb'
# This cop supports safe autocorrection (--autocorrect). # This cop supports safe autocorrection (--autocorrect).
# Configuration parameters: EnforcedStyle. # Configuration parameters: EnforcedStyle.

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@ RUN --mount=type=cache,id=apt,target=/var/cache/apt,sharing=private \
libpq-dev \ libpq-dev \
libreadline8 \ libreadline8 \
libssl-dev \ libssl-dev \
libyaml-0-2 \ libyaml-dev \
python3 \ python3 \
shared-mime-info \ shared-mime-info \
zlib1g-dev zlib1g-dev

View File

@ -4,7 +4,7 @@ source 'https://rubygems.org'
ruby '>= 3.0.0' ruby '>= 3.0.0'
gem 'puma', '~> 6.3' gem 'puma', '~> 6.3'
gem 'rails', '~> 7.0' gem 'rails', '~> 7.1.1'
gem 'sprockets', '~> 3.7.2' gem 'sprockets', '~> 3.7.2'
gem 'thor', '~> 1.2' gem 'thor', '~> 1.2'
gem 'rack', '~> 2.2.7' gem 'rack', '~> 2.2.7'

View File

@ -39,75 +39,83 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.8) actioncable (7.1.1)
actionpack (= 7.0.8) actionpack (= 7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.8) zeitwerk (~> 2.6)
actionpack (= 7.0.8) actionmailbox (7.1.1)
activejob (= 7.0.8) actionpack (= 7.1.1)
activerecord (= 7.0.8) activejob (= 7.1.1)
activestorage (= 7.0.8) activerecord (= 7.1.1)
activesupport (= 7.0.8) activestorage (= 7.1.1)
activesupport (= 7.1.1)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.8) actionmailer (7.1.1)
actionpack (= 7.0.8) actionpack (= 7.1.1)
actionview (= 7.0.8) actionview (= 7.1.1)
activejob (= 7.0.8) activejob (= 7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
actionpack (7.0.8) actionpack (7.1.1)
actionview (= 7.0.8) actionview (= 7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
rack (~> 2.0, >= 2.2.4) nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.6)
actiontext (7.0.8) actiontext (7.1.1)
actionpack (= 7.0.8) actionpack (= 7.1.1)
activerecord (= 7.0.8) activerecord (= 7.1.1)
activestorage (= 7.0.8) activestorage (= 7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.8) actionview (7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.11)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.6)
active_model_serializers (0.10.14) active_model_serializers (0.10.14)
actionpack (>= 4.1) actionpack (>= 4.1)
activemodel (>= 4.1) activemodel (>= 4.1)
case_transform (>= 0.2) case_transform (>= 0.2)
jsonapi-renderer (>= 0.1.1.beta1, < 0.3) jsonapi-renderer (>= 0.1.1.beta1, < 0.3)
activejob (7.0.8) activejob (7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.8) activemodel (7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
activerecord (7.0.8) activerecord (7.1.1)
activemodel (= 7.0.8) activemodel (= 7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
activestorage (7.0.8) timeout (>= 0.4.0)
actionpack (= 7.0.8) activestorage (7.1.1)
activejob (= 7.0.8) actionpack (= 7.1.1)
activerecord (= 7.0.8) activejob (= 7.1.1)
activesupport (= 7.0.8) activerecord (= 7.1.1)
activesupport (= 7.1.1)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) activesupport (7.1.1)
activesupport (7.0.8) base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0) tzinfo (~> 2.0)
addressable (2.8.5) addressable (2.8.5)
public_suffix (>= 2.0.2, < 6.0) public_suffix (>= 2.0.2, < 6.0)
@ -158,6 +166,7 @@ GEM
erubi (~> 1.4) erubi (~> 1.4)
parser (>= 2.4) parser (>= 2.4)
smart_properties smart_properties
bigdecimal (3.1.4)
bindata (2.4.15) bindata (2.4.15)
binding_of_caller (1.0.0) binding_of_caller (1.0.0)
debug_inspector (>= 0.0.1) debug_inspector (>= 0.0.1)
@ -237,6 +246,8 @@ GEM
dotenv-rails (2.8.1) dotenv-rails (2.8.1)
dotenv (= 2.8.1) dotenv (= 2.8.1)
railties (>= 3.2) railties (>= 3.2)
drb (2.1.1)
ruby2_keywords
ed25519 (1.3.0) ed25519 (1.3.0)
elasticsearch (7.13.3) elasticsearch (7.13.3)
elasticsearch-api (= 7.13.3) elasticsearch-api (= 7.13.3)
@ -306,8 +317,8 @@ GEM
fuubar (2.5.1) fuubar (2.5.1)
rspec-core (~> 3.0) rspec-core (~> 3.0)
ruby-progressbar (~> 1.4) ruby-progressbar (~> 1.4)
globalid (1.1.0) globalid (1.2.1)
activesupport (>= 5.0) activesupport (>= 6.1)
haml (6.2.0) haml (6.2.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
@ -358,7 +369,11 @@ GEM
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1) terminal-table (>= 1.5.1)
idn-ruby (0.1.5) idn-ruby (0.1.5)
io-console (0.6.0)
ipaddress (0.8.3) ipaddress (0.8.3)
irb (1.8.1)
rdoc
reline (>= 0.3.8)
jmespath (1.6.2) jmespath (1.6.2)
json (2.6.3) json (2.6.3)
json-canonicalization (0.3.2) json-canonicalization (0.3.2)
@ -435,7 +450,6 @@ GEM
azure-storage-blob (~> 2.0.1) azure-storage-blob (~> 2.0.1)
hashie (~> 5.0) hashie (~> 5.0)
memory_profiler (1.0.1) memory_profiler (1.0.1)
method_source (1.0.0)
mime-types (3.5.1) mime-types (3.5.1)
mime-types-data (~> 3.2015) mime-types-data (~> 3.2015)
mime-types-data (3.2023.0808) mime-types-data (3.2023.0808)
@ -445,11 +459,12 @@ GEM
msgpack (1.7.1) msgpack (1.7.1)
multi_json (1.15.0) multi_json (1.15.0)
multipart-post (2.3.0) multipart-post (2.3.0)
mutex_m (0.1.2)
net-http (0.3.2) net-http (0.3.2)
uri uri
net-http-persistent (4.0.2) net-http-persistent (4.0.2)
connection_pool (~> 2.2) connection_pool (~> 2.2)
net-imap (0.3.7) net-imap (0.4.1)
date date
net-protocol net-protocol
net-ldap (0.18.0) net-ldap (0.18.0)
@ -457,7 +472,7 @@ GEM
net-protocol net-protocol
net-protocol (0.2.1) net-protocol (0.2.1)
timeout timeout
net-smtp (0.3.3) net-smtp (0.4.0)
net-protocol net-protocol
nio4r (2.5.9) nio4r (2.5.9)
nokogiri (1.15.4) nokogiri (1.15.4)
@ -513,6 +528,8 @@ GEM
net-smtp net-smtp
premailer (~> 1.7, >= 1.7.9) premailer (~> 1.7, >= 1.7.9)
private_address_check (0.5.0) private_address_check (0.5.0)
psych (5.1.1)
stringio
public_suffix (5.0.3) public_suffix (5.0.3)
puma (6.4.0) puma (6.4.0)
nio4r (~> 2.0) nio4r (~> 2.0)
@ -535,22 +552,27 @@ GEM
rack rack
rack-proxy (0.7.6) rack-proxy (0.7.6)
rack rack
rack-session (1.0.1)
rack (< 3)
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rails (7.0.8) rackup (1.0.0)
actioncable (= 7.0.8) rack (< 3)
actionmailbox (= 7.0.8) webrick
actionmailer (= 7.0.8) rails (7.1.1)
actionpack (= 7.0.8) actioncable (= 7.1.1)
actiontext (= 7.0.8) actionmailbox (= 7.1.1)
actionview (= 7.0.8) actionmailer (= 7.1.1)
activejob (= 7.0.8) actionpack (= 7.1.1)
activemodel (= 7.0.8) actiontext (= 7.1.1)
activerecord (= 7.0.8) actionview (= 7.1.1)
activestorage (= 7.0.8) activejob (= 7.1.1)
activesupport (= 7.0.8) activemodel (= 7.1.1)
activerecord (= 7.1.1)
activestorage (= 7.1.1)
activesupport (= 7.1.1)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.8) railties (= 7.1.1)
rails-controller-testing (1.0.5) rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1) actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1)
@ -565,19 +587,22 @@ GEM
rails-i18n (7.0.8) rails-i18n (7.0.8)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8) railties (>= 6.0.0, < 8)
railties (7.0.8) railties (7.1.1)
actionpack (= 7.0.8) actionpack (= 7.1.1)
activesupport (= 7.0.8) activesupport (= 7.1.1)
method_source irb
rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.5) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.0.6) rake (13.0.6)
rdf (3.2.11) rdf (3.2.11)
link_header (~> 0.0, >= 0.0.8) link_header (~> 0.0, >= 0.0.8)
rdf-normalize (0.6.1) rdf-normalize (0.6.1)
rdf (~> 3.2) rdf (~> 3.2)
rdoc (6.5.0)
psych (>= 4.0.0)
redcarpet (3.6.0) redcarpet (3.6.0)
redis (4.8.1) redis (4.8.1)
redis-namespace (1.11.0) redis-namespace (1.11.0)
@ -585,13 +610,15 @@ GEM
redlock (1.3.2) redlock (1.3.2)
redis (>= 3.0.0, < 6.0) redis (>= 3.0.0, < 6.0)
regexp_parser (2.8.2) regexp_parser (2.8.2)
reline (0.3.9)
io-console (~> 0.5)
request_store (1.5.1) request_store (1.5.1)
rack (>= 1.4) rack (>= 1.4)
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
rexml (3.2.6) rexml (3.2.6)
rotp (6.2.2) rotp (6.3.0)
rouge (4.1.2) rouge (4.1.2)
rpam2 (4.0.2) rpam2 (4.0.2)
rqrcode (2.2.0) rqrcode (2.2.0)
@ -713,6 +740,7 @@ GEM
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stoplight (3.0.2) stoplight (3.0.2)
redlock (~> 1.0) redlock (~> 1.0)
stringio (3.0.8)
strong_migrations (0.8.0) strong_migrations (0.8.0)
activerecord (>= 5.2) activerecord (>= 5.2)
swd (1.3.0) swd (1.3.0)
@ -784,6 +812,7 @@ GEM
rack-proxy (>= 0.6.1) rack-proxy (>= 0.6.1)
railties (>= 5.2) railties (>= 5.2)
semantic_range (>= 2.3.0) semantic_range (>= 2.3.0)
webrick (1.8.1)
websocket (1.2.10) websocket (1.2.10)
websocket-driver (0.7.6) websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
@ -880,7 +909,7 @@ DEPENDENCIES
rack-attack (~> 6.6) rack-attack (~> 6.6)
rack-cors (~> 2.0) rack-cors (~> 2.0)
rack-test (~> 2.1) rack-test (~> 2.1)
rails (~> 7.0) rails (~> 7.1.1)
rails-controller-testing (~> 1.0) rails-controller-testing (~> 1.0)
rails-i18n (~> 7.0) rails-i18n (~> 7.0)
rails-settings-cached (~> 0.6)! rails-settings-cached (~> 0.6)!
@ -931,4 +960,4 @@ RUBY VERSION
ruby 3.2.2p53 ruby 3.2.2p53
BUNDLED WITH BUNDLED WITH
2.4.13 2.4.20

View File

@ -49,7 +49,7 @@ module Admin
private private
def set_instance def set_instance
@instance = Instance.find(TagManager.instance.normalize_domain(params[:id]&.strip)) @instance = Instance.find_or_initialize_by(domain: TagManager.instance.normalize_domain(params[:id]&.strip))
end end
def set_instances def set_instances

View File

@ -13,6 +13,7 @@ class ApplicationController < ActionController::Base
include ThemingConcern include ThemingConcern
include DatabaseHelper include DatabaseHelper
include AuthorizedFetchHelper include AuthorizedFetchHelper
include SelfDestructHelper
helper_method :current_account helper_method :current_account
helper_method :current_session helper_method :current_session
@ -41,6 +42,8 @@ class ApplicationController < ActionController::Base
service_unavailable service_unavailable
end end
before_action :check_self_destruct!
before_action :store_referrer, except: :raise_not_found, if: :devise_controller? before_action :store_referrer, except: :raise_not_found, if: :devise_controller?
before_action :require_functional!, if: :user_signed_in? before_action :require_functional!, if: :user_signed_in?
@ -169,6 +172,15 @@ class ApplicationController < ActionController::Base
end end
end end
def check_self_destruct!
return unless self_destruct?
respond_to do |format|
format.any { render 'errors/self_destruct', layout: 'auth', status: 410, formats: [:html] }
format.json { render json: { error: Rack::Utils::HTTP_STATUS_CODES[410] }, status: code }
end
end
def set_cache_control_defaults def set_cache_control_defaults
response.cache_control.replace(private: true, no_store: true) response.cache_control.replace(private: true, no_store: true)
end end

View File

@ -8,6 +8,7 @@ class Auth::ChallengesController < ApplicationController
before_action :set_pack before_action :set_pack
before_action :authenticate_user! before_action :authenticate_user!
skip_before_action :check_self_destruct!
skip_before_action :require_functional! skip_before_action :require_functional!
def create def create

View File

@ -13,6 +13,7 @@ class Auth::ConfirmationsController < Devise::ConfirmationsController
before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha] before_action :extend_csp_for_captcha!, only: [:show, :confirm_captcha]
before_action :require_captcha_if_needed!, only: [:show] before_action :require_captcha_if_needed!, only: [:show]
skip_before_action :check_self_destruct!
skip_before_action :require_functional! skip_before_action :require_functional!
def show def show

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
skip_before_action :check_self_destruct!
skip_before_action :verify_authenticity_token skip_before_action :verify_authenticity_token
def self.provides_callback_for(provider) def self.provides_callback_for(provider)

View File

@ -1,6 +1,7 @@
# frozen_string_literal: true # frozen_string_literal: true
class Auth::PasswordsController < Devise::PasswordsController class Auth::PasswordsController < Devise::PasswordsController
skip_before_action :check_self_destruct!
before_action :check_validity_of_reset_password_token, only: :edit before_action :check_validity_of_reset_password_token, only: :edit
before_action :set_pack before_action :set_pack
before_action :set_body_classes before_action :set_body_classes

View File

@ -18,6 +18,7 @@ class Auth::RegistrationsController < Devise::RegistrationsController
before_action :require_rules_acceptance!, only: :new before_action :require_rules_acceptance!, only: :new
before_action :set_registration_form_time, only: :new before_action :set_registration_form_time, only: :new
skip_before_action :check_self_destruct!, only: [:edit, :update]
skip_before_action :require_functional!, only: [:edit, :update] skip_before_action :require_functional!, only: [:edit, :update]
def new def new

View File

@ -3,6 +3,7 @@
class Auth::SessionsController < Devise::SessionsController class Auth::SessionsController < Devise::SessionsController
layout 'auth' layout 'auth'
skip_before_action :check_self_destruct!
skip_before_action :require_no_authentication, only: [:create] skip_before_action :require_no_authentication, only: [:create]
skip_before_action :require_functional! skip_before_action :require_functional!
skip_before_action :update_user_sign_in skip_before_action :update_user_sign_in

View File

@ -3,6 +3,7 @@
class BackupsController < ApplicationController class BackupsController < ApplicationController
include RoutingHelper include RoutingHelper
skip_before_action :check_self_destruct!
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :authenticate_user! before_action :authenticate_user!

View File

@ -7,6 +7,7 @@ module ExportControllerConcern
before_action :authenticate_user! before_action :authenticate_user!
before_action :load_export before_action :load_export
skip_before_action :check_self_destruct!
skip_before_action :require_functional! skip_before_action :require_functional!
end end

View File

@ -5,6 +5,7 @@ class Settings::ExportsController < Settings::BaseController
include Redisable include Redisable
include Lockable include Lockable
skip_before_action :check_self_destruct!
skip_before_action :require_functional! skip_before_action :require_functional!
def show def show

View File

@ -1,6 +1,9 @@
# frozen_string_literal: true # frozen_string_literal: true
class Settings::LoginActivitiesController < Settings::BaseController class Settings::LoginActivitiesController < Settings::BaseController
skip_before_action :check_self_destruct!
skip_before_action :require_functional!
def index def index
@login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page]) @login_activities = LoginActivity.where(user: current_user).order(id: :desc).page(params[:page])
end end

View File

@ -3,6 +3,7 @@
module Settings module Settings
module TwoFactorAuthentication module TwoFactorAuthentication
class WebauthnCredentialsController < BaseController class WebauthnCredentialsController < BaseController
skip_before_action :check_self_destruct!
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_otp_enabled before_action :require_otp_enabled

View File

@ -4,6 +4,7 @@ module Settings
class TwoFactorAuthenticationMethodsController < BaseController class TwoFactorAuthenticationMethodsController < BaseController
include ChallengableConcern include ChallengableConcern
skip_before_action :check_self_destruct!
skip_before_action :require_functional! skip_before_action :require_functional!
before_action :require_challenge!, only: :disable before_action :require_challenge!, only: :disable

View File

@ -9,6 +9,10 @@ module FormattingHelper
TextFormatter.new(text, options).to_s TextFormatter.new(text, options).to_s
end end
def url_for_preview_card(preview_card)
preview_card.url
end
def extract_status_plain_text(status) def extract_status_plain_text(status)
PlainTextFormatter.new(status.text, status.local?).to_s PlainTextFormatter.new(status.text, status.local?).to_s
end end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
module SelfDestructHelper
def self.self_destruct?
value = ENV.fetch('SELF_DESTRUCT', nil)
value.present? && Rails.application.message_verifier('self-destruct').verify(value) == ENV['LOCAL_DOMAIN']
rescue ActiveSupport::MessageVerifier::InvalidSignature
false
end
def self_destruct?
SelfDestructHelper.self_destruct?
end
end

View File

@ -0,0 +1,3 @@
// eslint-disable-next-line import/no-anonymous-default-export
export default 'SvgrURL';
export const ReactComponent = 'div';

View File

@ -1,7 +1,7 @@
// This file will be loaded on admin pages, regardless of theme. // This file will be loaded on admin pages, regardless of theme.
import 'packs/public-path'; import 'packs/public-path';
import { delegate } from '@rails/ujs'; import Rails from '@rails/ujs';
import ready from '../mastodon/ready'; import ready from '../mastodon/ready';
@ -19,7 +19,7 @@ const setAnnouncementEndsAttributes = (target) => {
} }
}; };
delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => { Rails.delegate(document, 'input[type="datetime-local"]#announcement_starts_at', 'change', ({ target }) => {
setAnnouncementEndsAttributes(target); setAnnouncementEndsAttributes(target);
}); });
@ -42,7 +42,7 @@ const hideSelectAll = () => {
hiddenField.value = '0'; hiddenField.value = '0';
}; };
delegate(document, '#batch_checkbox_all', 'change', ({ target }) => { Rails.delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
[].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => { [].forEach.call(document.querySelectorAll(batchCheckboxClassName), (content) => {
@ -58,7 +58,7 @@ delegate(document, '#batch_checkbox_all', 'change', ({ target }) => {
} }
}); });
delegate(document, '.batch-table__select-all button', 'click', () => { Rails.delegate(document, '.batch-table__select-all button', 'click', () => {
const hiddenField = document.querySelector('#select_all_matching'); const hiddenField = document.querySelector('#select_all_matching');
const active = hiddenField.value === '1'; const active = hiddenField.value === '1';
const selectedMsg = document.querySelector('.batch-table__select-all .selected'); const selectedMsg = document.querySelector('.batch-table__select-all .selected');
@ -75,7 +75,7 @@ delegate(document, '.batch-table__select-all button', 'click', () => {
} }
}); });
delegate(document, batchCheckboxClassName, 'change', () => { Rails.delegate(document, batchCheckboxClassName, 'change', () => {
const checkAllElement = document.querySelector('#batch_checkbox_all'); const checkAllElement = document.querySelector('#batch_checkbox_all');
const selectAllMatchingElement = document.querySelector('.batch-table__select-all'); const selectAllMatchingElement = document.querySelector('.batch-table__select-all');
@ -93,19 +93,19 @@ delegate(document, batchCheckboxClassName, 'change', () => {
} }
}); });
delegate(document, '.media-spoiler-show-button', 'click', () => { Rails.delegate(document, '.media-spoiler-show-button', 'click', () => {
[].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => { [].forEach.call(document.querySelectorAll('button.media-spoiler'), (element) => {
element.click(); element.click();
}); });
}); });
delegate(document, '.media-spoiler-hide-button', 'click', () => { Rails.delegate(document, '.media-spoiler-hide-button', 'click', () => {
[].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => { [].forEach.call(document.querySelectorAll('.spoiler-button.spoiler-button--visible button'), (element) => {
element.click(); element.click();
}); });
}); });
delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => { Rails.delegate(document, '.filter-subset--with-select select', 'change', ({ target }) => {
target.form.submit(); target.form.submit();
}); });
@ -122,7 +122,7 @@ const onDomainBlockSeverityChange = (target) => {
} }
}; };
delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target)); Rails.delegate(document, '#domain_block_severity', 'change', ({ target }) => onDomainBlockSeverityChange(target));
const onEnableBootstrapTimelineAccountsChange = (target) => { const onEnableBootstrapTimelineAccountsChange = (target) => {
const bootstrapTimelineAccountsField = document.querySelector('#form_admin_settings_bootstrap_timeline_accounts'); const bootstrapTimelineAccountsField = document.querySelector('#form_admin_settings_bootstrap_timeline_accounts');
@ -139,7 +139,7 @@ const onEnableBootstrapTimelineAccountsChange = (target) => {
} }
}; };
delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target)); Rails.delegate(document, '#form_admin_settings_enable_bootstrap_timeline_accounts', 'change', ({ target }) => onEnableBootstrapTimelineAccountsChange(target));
const onChangeRegistrationMode = (target) => { const onChangeRegistrationMode = (target) => {
const enabled = target.value === 'approved'; const enabled = target.value === 'approved';
@ -176,7 +176,7 @@ const convertLocalDatetimeToUTC = (value) => {
return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6); return fullISO8601.slice(0, fullISO8601.indexOf('T') + 6);
}; };
delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target)); Rails.delegate(document, '#form_admin_settings_registrations_mode', 'change', ({ target }) => onChangeRegistrationMode(target));
ready(() => { ready(() => {
const domainBlockSeverityInput = document.getElementById('domain_block_severity'); const domainBlockSeverityInput = document.getElementById('domain_block_severity');
@ -213,7 +213,7 @@ ready(() => {
} }
}); });
delegate(document, 'form', 'submit', ({ target }) => { Rails.delegate(document, 'form', 'submit', ({ target }) => {
[].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => { [].forEach.call(target.querySelectorAll('input[type="datetime-local"]'), element => {
if (element.value && element.validity.valid) { if (element.value && element.validity.valid) {
element.value = convertLocalDatetimeToUTC(element.value); element.value = convertLocalDatetimeToUTC(element.value);

View File

@ -1,9 +1,9 @@
// This file will be loaded on settings pages, regardless of theme. // This file will be loaded on settings pages, regardless of theme.
import 'packs/public-path'; import 'packs/public-path';
import { delegate } from '@rails/ujs'; import Rails from '@rails/ujs';
delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => { Rails.delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
const avatar = document.getElementById(target.id + '-preview'); const avatar = document.getElementById(target.id + '-preview');
const [file] = target.files || []; const [file] = target.files || [];
const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc; const url = file ? URL.createObjectURL(file) : avatar.dataset.originalSrc;
@ -11,13 +11,13 @@ delegate(document, '#edit_profile input[type=file]', 'change', ({ target }) => {
avatar.src = url; avatar.src = url;
}); });
delegate(document, '.input-copy input', 'click', ({ target }) => { Rails.delegate(document, '.input-copy input', 'click', ({ target }) => {
target.focus(); target.focus();
target.select(); target.select();
target.setSelectionRange(0, target.value.length); target.setSelectionRange(0, target.value.length);
}); });
delegate(document, '.input-copy button', 'click', ({ target }) => { Rails.delegate(document, '.input-copy button', 'click', ({ target }) => {
const input = target.parentNode.querySelector('.input-copy__wrapper input'); const input = target.parentNode.querySelector('.input-copy__wrapper input');
const oldReadOnly = input.readonly; const oldReadOnly = input.readonly;

View File

@ -106,7 +106,6 @@ export function fetchAccount(id) {
api(getState).get(`/api/v1/accounts/${id}`).then(response => { api(getState).get(`/api/v1/accounts/${id}`).then(response => {
dispatch(importFetchedAccount(response.data)); dispatch(importFetchedAccount(response.data));
}).then(() => {
dispatch(fetchAccountSuccess()); dispatch(fetchAccountSuccess());
}).catch(error => { }).catch(error => {
dispatch(fetchAccountFail(id, error)); dispatch(fetchAccountFail(id, error));

View File

@ -1,31 +0,0 @@
import api from '../api';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST = 'IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS = 'IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS';
export const IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL = 'IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL';
export const fetchAccountIdentityProofs = accountId => (dispatch, getState) => {
dispatch(fetchAccountIdentityProofsRequest(accountId));
api(getState).get(`/api/v1/accounts/${accountId}/identity_proofs`)
.then(({ data }) => dispatch(fetchAccountIdentityProofsSuccess(accountId, data)))
.catch(err => dispatch(fetchAccountIdentityProofsFail(accountId, err)));
};
export const fetchAccountIdentityProofsRequest = id => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_REQUEST,
id,
});
export const fetchAccountIdentityProofsSuccess = (accountId, identity_proofs) => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_SUCCESS,
accountId,
identity_proofs,
});
export const fetchAccountIdentityProofsFail = (accountId, err) => ({
type: IDENTITY_PROOFS_ACCOUNT_FETCH_FAIL,
accountId,
err,
skipNotFound: true,
});

View File

@ -1,8 +1,8 @@
import escapeTextContentForBrowser from 'escape-html'; import escapeTextContentForBrowser from 'escape-html';
import emojify from 'flavours/glitch/features/emoji/emoji'; import emojify from '../../features/emoji/emoji';
import { autoHideCW } from 'flavours/glitch/utils/content_warning'; import { autoHideCW } from '../../utils/content_warning';
import { unescapeHTML } from 'flavours/glitch/utils/html'; import { unescapeHTML } from '../../utils/html';
const domParser = new DOMParser(); const domParser = new DOMParser();

View File

@ -83,6 +83,7 @@ export function reblogRequest(status) {
return { return {
type: REBLOG_REQUEST, type: REBLOG_REQUEST,
status: status, status: status,
skipLoading: true,
}; };
} }
@ -90,6 +91,7 @@ export function reblogSuccess(status) {
return { return {
type: REBLOG_SUCCESS, type: REBLOG_SUCCESS,
status: status, status: status,
skipLoading: true,
}; };
} }
@ -98,6 +100,7 @@ export function reblogFail(status, error) {
type: REBLOG_FAIL, type: REBLOG_FAIL,
status: status, status: status,
error: error, error: error,
skipLoading: true,
}; };
} }
@ -105,6 +108,7 @@ export function unreblogRequest(status) {
return { return {
type: UNREBLOG_REQUEST, type: UNREBLOG_REQUEST,
status: status, status: status,
skipLoading: true,
}; };
} }
@ -112,6 +116,7 @@ export function unreblogSuccess(status) {
return { return {
type: UNREBLOG_SUCCESS, type: UNREBLOG_SUCCESS,
status: status, status: status,
skipLoading: true,
}; };
} }
@ -120,6 +125,7 @@ export function unreblogFail(status, error) {
type: UNREBLOG_FAIL, type: UNREBLOG_FAIL,
status: status, status: status,
error: error, error: error,
skipLoading: true,
}; };
} }
@ -153,6 +159,7 @@ export function favouriteRequest(status) {
return { return {
type: FAVOURITE_REQUEST, type: FAVOURITE_REQUEST,
status: status, status: status,
skipLoading: true,
}; };
} }
@ -160,6 +167,7 @@ export function favouriteSuccess(status) {
return { return {
type: FAVOURITE_SUCCESS, type: FAVOURITE_SUCCESS,
status: status, status: status,
skipLoading: true,
}; };
} }
@ -168,6 +176,7 @@ export function favouriteFail(status, error) {
type: FAVOURITE_FAIL, type: FAVOURITE_FAIL,
status: status, status: status,
error: error, error: error,
skipLoading: true,
}; };
} }
@ -175,6 +184,7 @@ export function unfavouriteRequest(status) {
return { return {
type: UNFAVOURITE_REQUEST, type: UNFAVOURITE_REQUEST,
status: status, status: status,
skipLoading: true,
}; };
} }
@ -182,6 +192,7 @@ export function unfavouriteSuccess(status) {
return { return {
type: UNFAVOURITE_SUCCESS, type: UNFAVOURITE_SUCCESS,
status: status, status: status,
skipLoading: true,
}; };
} }
@ -190,6 +201,7 @@ export function unfavouriteFail(status, error) {
type: UNFAVOURITE_FAIL, type: UNFAVOURITE_FAIL,
status: status, status: status,
error: error, error: error,
skipLoading: true,
}; };
} }
@ -199,7 +211,7 @@ export function bookmark(status) {
api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) { api(getState).post(`/api/v1/statuses/${status.get('id')}/bookmark`).then(function (response) {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(bookmarkSuccess(status)); dispatch(bookmarkSuccess(status, response.data));
}).catch(function (error) { }).catch(function (error) {
dispatch(bookmarkFail(status, error)); dispatch(bookmarkFail(status, error));
}); });
@ -212,7 +224,7 @@ export function unbookmark(status) {
api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => { api(getState).post(`/api/v1/statuses/${status.get('id')}/unbookmark`).then(response => {
dispatch(importFetchedStatus(response.data)); dispatch(importFetchedStatus(response.data));
dispatch(unbookmarkSuccess(status)); dispatch(unbookmarkSuccess(status, response.data));
}).catch(error => { }).catch(error => {
dispatch(unbookmarkFail(status, error)); dispatch(unbookmarkFail(status, error));
}); });
@ -226,10 +238,11 @@ export function bookmarkRequest(status) {
}; };
} }
export function bookmarkSuccess(status) { export function bookmarkSuccess(status, response) {
return { return {
type: BOOKMARK_SUCCESS, type: BOOKMARK_SUCCESS,
status: status, status: status,
response: response,
}; };
} }
@ -248,10 +261,11 @@ export function unbookmarkRequest(status) {
}; };
} }
export function unbookmarkSuccess(status) { export function unbookmarkSuccess(status, response) {
return { return {
type: UNBOOKMARK_SUCCESS, type: UNBOOKMARK_SUCCESS,
status: status, status: status,
response: response,
}; };
} }
@ -444,6 +458,7 @@ export function pinRequest(status) {
return { return {
type: PIN_REQUEST, type: PIN_REQUEST,
status, status,
skipLoading: true,
}; };
} }
@ -451,6 +466,7 @@ export function pinSuccess(status) {
return { return {
type: PIN_SUCCESS, type: PIN_SUCCESS,
status, status,
skipLoading: true,
}; };
} }
@ -459,6 +475,7 @@ export function pinFail(status, error) {
type: PIN_FAIL, type: PIN_FAIL,
status, status,
error, error,
skipLoading: true,
}; };
} }
@ -479,6 +496,7 @@ export function unpinRequest(status) {
return { return {
type: UNPIN_REQUEST, type: UNPIN_REQUEST,
status, status,
skipLoading: true,
}; };
} }
@ -486,6 +504,7 @@ export function unpinSuccess(status) {
return { return {
type: UNPIN_SUCCESS, type: UNPIN_SUCCESS,
status, status,
skipLoading: true,
}; };
} }
@ -494,5 +513,6 @@ export function unpinFail(status, error) {
type: UNPIN_FAIL, type: UNPIN_FAIL,
status, status,
error, error,
skipLoading: true,
}; };
} }

View File

@ -1,9 +1,8 @@
import { openModal } from 'flavours/glitch/actions/modal';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { fetchRelationships } from './accounts'; import { fetchRelationships } from './accounts';
import { importFetchedAccounts } from './importer'; import { importFetchedAccounts } from './importer';
import { openModal } from './modal';
export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST'; export const MUTES_FETCH_REQUEST = 'MUTES_FETCH_REQUEST';
export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS'; export const MUTES_FETCH_SUCCESS = 'MUTES_FETCH_SUCCESS';

View File

@ -5,10 +5,10 @@ import { List as ImmutableList } from 'immutable';
import { compareId } from 'flavours/glitch/compare_id'; import { compareId } from 'flavours/glitch/compare_id';
import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state'; import { usePendingItems as preferPendingItems } from 'flavours/glitch/initial_state';
import { unescapeHTML } from 'flavours/glitch/utils/html';
import { requestNotificationPermission } from 'flavours/glitch/utils/notifications';
import api, { getLinks } from '../api'; import api, { getLinks } from '../api';
import { unescapeHTML } from '../utils/html';
import { requestNotificationPermission } from '../utils/notifications';
import { fetchFollowRequests, fetchRelationships } from './accounts'; import { fetchFollowRequests, fetchRelationships } from './accounts';
import { import {
@ -21,9 +21,6 @@ import { submitMarkers } from './markers';
import { register as registerPushNotifications } from './push_notifications'; import { register as registerPushNotifications } from './push_notifications';
import { saveSettings } from './settings'; import { saveSettings } from './settings';
export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE'; export const NOTIFICATIONS_UPDATE = 'NOTIFICATIONS_UPDATE';
export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP'; export const NOTIFICATIONS_UPDATE_NOOP = 'NOTIFICATIONS_UPDATE_NOOP';
@ -65,7 +62,7 @@ defineMessages({
const fetchRelatedRelationships = (dispatch, notifications) => { const fetchRelatedRelationships = (dispatch, notifications) => {
const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id); const accountIds = notifications.filter(item => ['follow', 'follow_request', 'admin.sign_up'].indexOf(item.type) !== -1).map(item => item.account.id);
if (accountIds > 0) { if (accountIds.length > 0) {
dispatch(fetchRelationships(accountIds)); dispatch(fetchRelationships(accountIds));
} }
}; };
@ -131,6 +128,7 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : ''); const body = (notification.status && notification.status.spoiler_text.length > 0) ? notification.status.spoiler_text : unescapeHTML(notification.status ? notification.status.content : '');
const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id }); const notify = new Notification(title, { body, icon: notification.account.avatar, tag: notification.id });
notify.addEventListener('click', () => { notify.addEventListener('click', () => {
window.focus(); window.focus();
notify.close(); notify.close();
@ -141,7 +139,6 @@ export function updateNotifications(notification, intlMessages, intlLocale) {
const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS(); const excludeTypesFromSettings = state => state.getIn(['settings', 'notifications', 'shows']).filter(enabled => !enabled).keySeq().toJS();
const excludeTypesFromFilter = filter => { const excludeTypesFromFilter = filter => {
const allTypes = ImmutableList([ const allTypes = ImmutableList([
'follow', 'follow',

View File

@ -1,10 +1,8 @@
import { me } from 'flavours/glitch/initial_state';
import api from '../api'; import api from '../api';
import { me } from '../initial_state';
import { importFetchedStatuses } from './importer'; import { importFetchedStatuses } from './importer';
export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST'; export const PINNED_STATUSES_FETCH_REQUEST = 'PINNED_STATUSES_FETCH_REQUEST';
export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS'; export const PINNED_STATUSES_FETCH_SUCCESS = 'PINNED_STATUSES_FETCH_SUCCESS';
export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL'; export const PINNED_STATUSES_FETCH_FAIL = 'PINNED_STATUSES_FETCH_FAIL';

View File

@ -1,5 +1,7 @@
import api from '../../api'; import api from '../../api';
import { me } from '../../initial_state';
import { pushNotificationsSetting } from '../../settings'; import { pushNotificationsSetting } from '../../settings';
import { decode as decodeBase64 } from '../../utils/base64';
import { setBrowserSupport, setSubscription, clearSubscription } from './setter'; import { setBrowserSupport, setSubscription, clearSubscription } from './setter';
@ -10,13 +12,7 @@ const urlBase64ToUint8Array = (base64String) => {
.replace(/-/g, '+') .replace(/-/g, '+')
.replace(/_/g, '/'); .replace(/_/g, '/');
const rawData = window.atob(base64); return decodeBase64(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}; };
const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content');
@ -36,7 +32,7 @@ const subscribe = (registration) =>
const unsubscribe = ({ registration, subscription }) => const unsubscribe = ({ registration, subscription }) =>
subscription ? subscription.unsubscribe().then(() => registration) : registration; subscription ? subscription.unsubscribe().then(() => registration) : registration;
const sendSubscriptionToBackend = (getState, subscription, me) => { const sendSubscriptionToBackend = (subscription) => {
const params = { subscription }; const params = { subscription };
if (me) { if (me) {
@ -46,7 +42,7 @@ const sendSubscriptionToBackend = (getState, subscription, me) => {
} }
} }
return api(getState).post('/api/web/push_subscriptions', params).then(response => response.data); return api().post('/api/web/push_subscriptions', params).then(response => response.data);
}; };
// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload // Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload
@ -55,7 +51,6 @@ const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager'
export function register () { export function register () {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(setBrowserSupport(supportsPushNotifications)); dispatch(setBrowserSupport(supportsPushNotifications));
const me = getState().getIn(['meta', 'me']);
if (supportsPushNotifications) { if (supportsPushNotifications) {
if (!getApplicationServerKey()) { if (!getApplicationServerKey()) {
@ -79,13 +74,13 @@ export function register () {
} else { } else {
// Something went wrong, try to subscribe again // Something went wrong, try to subscribe again
return unsubscribe({ registration, subscription }).then(subscribe).then( return unsubscribe({ registration, subscription }).then(subscribe).then(
subscription => sendSubscriptionToBackend(getState, subscription, me)); subscription => sendSubscriptionToBackend(subscription));
} }
} }
// No subscription, try to subscribe // No subscription, try to subscribe
return subscribe(registration).then( return subscribe(registration).then(
subscription => sendSubscriptionToBackend(getState, subscription, me)); subscription => sendSubscriptionToBackend(subscription));
}) })
.then(subscription => { .then(subscription => {
// If we got a PushSubscription (and not a subscription object from the backend) // If we got a PushSubscription (and not a subscription object from the backend)
@ -128,10 +123,9 @@ export function saveSettings() {
const alerts = state.get('alerts'); const alerts = state.get('alerts');
const data = { alerts }; const data = { alerts };
api(getState).put(`/api/web/push_subscriptions/${subscription.get('id')}`, { api().put(`/api/web/push_subscriptions/${subscription.get('id')}`, {
data, data,
}).then(() => { }).then(() => {
const me = getState().getIn(['meta', 'me']);
if (me) { if (me) {
pushNotificationsSetting.set(me, data); pushNotificationsSetting.set(me, data);
} }

View File

@ -26,7 +26,7 @@ const debouncedSave = debounce((dispatch, getState) => {
const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS(); const data = getState().get('settings').filter((_, path) => path !== 'saved').toJS();
api(getState).put('/api/web/settings', { data }) api().put('/api/web/settings', { data })
.then(() => dispatch({ type: SETTING_SAVE })) .then(() => dispatch({ type: SETTING_SAVE }))
.catch(error => dispatch(showAlertForError(error))); .catch(error => dispatch(showAlertForError(error)));
}, 5000, { trailing: true }); }, 5000, { trailing: true });

View File

@ -1,7 +1,6 @@
// @ts-check // @ts-check
import { getLocale } from 'flavours/glitch/locales'; import { getLocale } from '../locales';
import { connectStream } from '../stream'; import { connectStream } from '../stream';
import { import {
@ -68,8 +67,8 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
// @ts-expect-error // @ts-expect-error
if (pollingId) { if (pollingId) {
clearTimeout(pollingId); // @ts-ignore
pollingId = null; clearTimeout(pollingId); pollingId = null;
} }
if (options.fillGaps) { if (options.fillGaps) {
@ -86,8 +85,8 @@ export const connectTimelineStream = (timelineId, channelName, params = {}, opti
} }
}, },
onReceive (data) { onReceive(data) {
switch(data.event) { switch (data.event) {
case 'update': case 'update':
// @ts-expect-error // @ts-expect-error
dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept)); dispatch(updateTimeline(timelineId, JSON.parse(data.payload), options.accept));

View File

@ -0,0 +1,214 @@
import { fromJS } from 'immutable';
import type { StatusLike } from '../hashtag_bar';
import { computeHashtagBarForStatus } from '../hashtag_bar';
function createStatus(
content: string,
hashtags: string[],
hasMedia = false,
spoilerText?: string,
) {
return fromJS({
tags: hashtags.map((name) => ({ name })),
contentHtml: content,
media_attachments: hasMedia ? ['fakeMedia'] : [],
spoiler_text: spoilerText,
}) as unknown as StatusLike; // need to force the type here, as it is not properly defined
}
describe('computeHashtagBarForStatus', () => {
it('does nothing when there are no tags', () => {
const status = createStatus('<p>Simple text</p>', []);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Simple text</p>"`,
);
});
it('displays out of band hashtags in the bar', () => {
const status = createStatus(
'<p>Simple text <a href="test">#hashtag</a></p>',
['hashtag', 'test'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['test']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Simple text <a href="test">#hashtag</a></p>"`,
);
});
it('does not truncate the contents when the last child is a text node', () => {
const status = createStatus(
'this is a #<a class="zrl" href="https://example.com/search?tag=test">test</a>. Some more text',
['test'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"this is a #<a class="zrl" href="https://example.com/search?tag=test">test</a>. Some more text"`,
);
});
it('extract tags from the last line', () => {
const status = createStatus(
'<p>Simple text</p><p><a href="test">#hashtag</a></p>',
['hashtag'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['hashtag']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Simple text</p>"`,
);
});
it('does not include tags from content', () => {
const status = createStatus(
'<p>Simple text with a <a href="test">#hashtag</a></p><p><a href="test">#hashtag</a></p>',
['hashtag'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Simple text with a <a href="test">#hashtag</a></p>"`,
);
});
it('works with one line status and hashtags', () => {
const status = createStatus(
'<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>',
['hashtag', 'test'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p><a href="test">#test</a>. And another <a href="test">#hashtag</a></p>"`,
);
});
it('de-duplicate accentuated characters with case differences', () => {
const status = createStatus(
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
['éaa'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['Éaa']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Text</p>"`,
);
});
it('handles server-side normalized tags with accentuated characters', () => {
const status = createStatus(
'<p>Text</p><p><a href="test">#éaa</a> <a href="test">#Éaa</a></p>',
['eaa'], // The server may normalize the hashtags in the `tags` attribute
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['Éaa']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Text</p>"`,
);
});
it('does not display in bar a hashtag in content with a case difference', () => {
const status = createStatus(
'<p>Text <a href="test">#Éaa</a></p><p><a href="test">#éaa</a></p>',
['éaa'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>Text <a href="test">#Éaa</a></p>"`,
);
});
it('does not modify a status with a line of hashtags only', () => {
const status = createStatus(
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
['test', 'hashtag'],
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`,
);
});
it('puts the hashtags in the bar if a status content has hashtags in the only line and has a media', () => {
const status = createStatus(
'<p>This is my content! <a href="test">#hashtag</a></p>',
['hashtag'],
true,
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p>This is my content! <a href="test">#hashtag</a></p>"`,
);
});
it('puts the hashtags in the bar if a status content is only hashtags and has a media', () => {
const status = createStatus(
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
['test', 'hashtag'],
true,
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual(['test', 'hashtag']);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(`""`);
});
it('does not use the hashtag bar if the status content is only hashtags, has a CW and a media', () => {
const status = createStatus(
'<p><a href="test">#test</a> <a href="test">#hashtag</a></p>',
['test', 'hashtag'],
true,
'My CW text',
);
const { hashtagsInBar, statusContentProps } =
computeHashtagBarForStatus(status);
expect(hashtagsInBar).toEqual([]);
expect(statusContentProps.statusContent).toMatchInlineSnapshot(
`"<p><a href="test">#test</a> <a href="test">#hashtag</a></p>"`,
);
});
});

View File

@ -6,7 +6,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { Skeleton } from 'flavours/glitch/components/skeleton'; import { Skeleton } from 'flavours/glitch/components/skeleton';
import { me } from 'flavours/glitch/initial_state';
import { me } from '../initial_state';
import { Avatar } from './avatar'; import { Avatar } from './avatar';
import { DisplayName } from './display_name'; import { DisplayName } from './display_name';
@ -14,11 +15,10 @@ import { IconButton } from './icon_button';
import Permalink from './permalink'; import Permalink from './permalink';
import { RelativeTimestamp } from './relative_timestamp'; 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' }, requested: { id: 'account.requested', defaultMessage: 'Awaiting approval' },
unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' }, unblock: { id: 'account.unblock', defaultMessage: 'Unblock @{name}' },
unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' }, unmute: { id: 'account.unmute', defaultMessage: 'Unmute @{name}' },
mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' }, mute_notifications: { id: 'account.mute_notifications', defaultMessage: 'Mute notifications from @{name}' },
@ -38,7 +38,6 @@ 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,
small: PropTypes.bool,
actionIcon: PropTypes.string, actionIcon: PropTypes.string,
actionTitle: PropTypes.string, actionTitle: PropTypes.string,
defaultAction: PropTypes.string, defaultAction: PropTypes.string,
@ -74,17 +73,7 @@ class Account extends ImmutablePureComponent {
}; };
render () { render () {
const { const { account, intl, hidden, onActionClick, actionIcon, actionTitle, defaultAction, size } = this.props;
account,
hidden,
intl,
small,
onActionClick,
actionIcon,
actionTitle,
defaultAction,
size,
} = this.props;
if (!account) { if (!account) {
return ( return (
@ -114,7 +103,7 @@ class Account extends ImmutablePureComponent {
if (actionIcon) { if (actionIcon) {
buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />; buttons = <IconButton icon={actionIcon} title={actionTitle} onClick={this.handleAction} />;
} }
} else if (account.get('id') !== me && !small && 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']);
@ -151,24 +140,7 @@ class Account extends ImmutablePureComponent {
mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>; mute_expires_at = <div><RelativeTimestamp timestamp={account.get('mute_expires_at')} futureDate /></div>;
} }
return small ? ( return (
<Permalink
className='account small'
href={account.get('url')}
to={`/@${account.get('acct')}`}
>
<div className='account__avatar-wrapper'>
<Avatar
account={account}
size={24}
/>
</div>
<DisplayName
account={account}
inline
/>
</Permalink>
) : (
<div className='account'> <div className='account'>
<div className='account__wrapper'> <div className='account__wrapper'>
<Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}> <Permalink key={account.get('id')} className='account__display-name' title={account.get('acct')} href={account.get('url')} to={`/@${account.get('acct')}`}>

View File

@ -1,5 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import * as React from 'react';
import { TransitionMotion, spring } from 'react-motion'; import { TransitionMotion, spring } from 'react-motion';

View File

@ -1,9 +1,10 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import unicodeMapping from 'flavours/glitch/features/emoji/emoji_unicode_mapping_light';
import { assetHost } from 'flavours/glitch/utils/config'; import { assetHost } from 'flavours/glitch/utils/config';
import { unicodeMapping } from '../features/emoji/emoji_unicode_mapping_light';
export default class AutosuggestEmoji extends PureComponent { export default class AutosuggestEmoji extends PureComponent {
static propTypes = { static propTypes = {
@ -27,7 +28,7 @@ export default class AutosuggestEmoji extends PureComponent {
} }
return ( return (
<div className='emoji'> <div className='autosuggest-emoji'>
<img <img
className='emojione' className='emojione'
src={url} src={url}

View File

@ -5,7 +5,7 @@ import classNames from 'classnames';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag'; import { AutosuggestHashtag } from './autosuggest_hashtag';

View File

@ -7,7 +7,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import AutosuggestAccountContainer from 'flavours/glitch/features/compose/containers/autosuggest_account_container'; import AutosuggestAccountContainer from '../features/compose/containers/autosuggest_account_container';
import AutosuggestEmoji from './autosuggest_emoji'; import AutosuggestEmoji from './autosuggest_emoji';
import { AutosuggestHashtag } from './autosuggest_hashtag'; import { AutosuggestHashtag } from './autosuggest_hashtag';

View File

@ -1,10 +1,8 @@
import * as React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useHovering } from 'flavours/glitch/hooks/useHovering'; import { useHovering } from '../hooks/useHovering';
import { autoPlayGif } from 'flavours/glitch/initial_state'; import { autoPlayGif } from '../initial_state';
import type { Account } from 'flavours/glitch/types/resources'; import type { Account } from '../types/resources';
interface Props { interface Props {
account: Account | undefined; account: Account | undefined;

View File

@ -3,7 +3,7 @@ import { PureComponent } from 'react';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { autoPlayGif } from 'flavours/glitch/initial_state'; import { autoPlayGif } from '../initial_state';
export default class AvatarComposite extends PureComponent { export default class AvatarComposite extends PureComponent {

View File

@ -1,5 +1,4 @@
import { useRef, useEffect } from 'react'; import { memo, useRef, useEffect } from 'react';
import * as React from 'react';
import { decode } from 'blurhash'; import { decode } from 'blurhash';
@ -44,6 +43,6 @@ const Blurhash: React.FC<Props> = ({
); );
}; };
const MemoizedBlurhash = React.memo(Blurhash); const MemoizedBlurhash = memo(Blurhash);
export { MemoizedBlurhash as Blurhash }; export { MemoizedBlurhash as Blurhash };

View File

@ -1,53 +0,0 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import classNames from 'classnames';
export default class Button extends PureComponent {
static propTypes = {
text: PropTypes.node,
onClick: PropTypes.func,
disabled: PropTypes.bool,
block: PropTypes.bool,
secondary: PropTypes.bool,
className: PropTypes.string,
title: PropTypes.string,
children: PropTypes.node,
};
handleClick = (e) => {
if (!this.props.disabled) {
this.props.onClick(e);
}
};
setRef = (c) => {
this.node = c;
};
focus() {
this.node.focus();
}
render () {
let attrs = {
className: classNames('button', this.props.className, {
'button-secondary': this.props.secondary,
'button--block': this.props.block,
}),
disabled: this.props.disabled,
onClick: this.handleClick,
ref: this.setRef,
};
if (this.props.title) attrs.title = this.props.title;
return (
<button {...attrs}>
{this.props.text || this.props.children}
</button>
);
}
}

View File

@ -0,0 +1,58 @@
import { useCallback } from 'react';
import classNames from 'classnames';
interface BaseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
block?: boolean;
secondary?: boolean;
text?: JSX.Element;
}
interface PropsWithChildren extends BaseProps {
text?: never;
}
interface PropsWithText extends BaseProps {
text: JSX.Element;
children: never;
}
type Props = PropsWithText | PropsWithChildren;
export const Button: React.FC<Props> = ({
text,
type = 'button',
onClick,
disabled,
block,
secondary,
className,
title,
children,
...props
}) => {
const handleClick = useCallback<React.MouseEventHandler<HTMLButtonElement>>(
(e) => {
if (!disabled && onClick) {
onClick(e);
}
},
[disabled, onClick],
);
return (
<button
className={classNames('button', className, {
'button-secondary': secondary,
'button--block': block,
})}
disabled={disabled}
onClick={handleClick}
title={title}
type={type}
{...props}
>
{text ?? children}
</button>
);
};

View File

@ -24,12 +24,6 @@ export default class Column extends PureComponent {
scrollable = document.scrollingElement; scrollable = document.scrollingElement;
} else { } else {
scrollable = this.node.querySelector('.scrollable'); scrollable = this.node.querySelector('.scrollable');
// Some columns have nested `.scrollable` containers, with the outer one
// being a wrapper while the actual scrollable content is deeper.
if (scrollable.classList.contains('scrollable--flex')) {
scrollable = scrollable?.querySelector('.scrollable') || scrollable;
}
} }
if (!scrollable) { if (!scrollable) {

View File

@ -4,26 +4,25 @@ import { createPortal } from 'react-dom';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
export class ColumnBackButton extends PureComponent {
export default class ColumnBackButton extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
multiColumn: PropTypes.bool, multiColumn: PropTypes.bool,
...WithRouterPropTypes,
}; };
handleClick = () => { handleClick = () => {
const { router } = this.context; const { history } = this.props;
if (router.history.location?.state?.fromMastodon) { if (history.location?.state?.fromMastodon) {
router.history.goBack(); history.goBack();
} else { } else {
router.history.push('/'); history.push('/');
} }
}; };
@ -57,3 +56,5 @@ export default class ColumnBackButton extends PureComponent {
} }
} }
export default withRouter(ColumnBackButton);

View File

@ -1,25 +1,27 @@
import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
import { withRouter } from 'react-router-dom';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
export default class ColumnBackButtonSlim extends PureComponent { class ColumnBackButtonSlim extends PureComponent {
static contextTypes = { static propTypes = {
router: PropTypes.object, ...WithRouterPropTypes,
}; };
handleClick = () => { handleClick = () => {
const { router } = this.context; const { location, history } = this.props;
// Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201 // Check if there is a previous page in the app to go back to per https://stackoverflow.com/a/70532858/9703201
// When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location // When upgrading to V6, check `location.key !== 'default'` instead per https://github.com/remix-run/history/blob/main/docs/api-reference.md#location
if (router.route.location.key) { if (location.key) {
router.history.goBack(); history.goBack();
} else { } else {
router.history.push('/'); history.push('/');
} }
}; };
@ -33,5 +35,6 @@ export default class ColumnBackButtonSlim extends PureComponent {
</div> </div>
); );
} }
} }
export default withRouter(ColumnBackButtonSlim);

View File

@ -5,8 +5,10 @@ import { createPortal } from 'react-dom';
import { FormattedMessage, injectIntl, defineMessages } from 'react-intl'; import { FormattedMessage, injectIntl, defineMessages } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import { Icon } from 'flavours/glitch/components/icon'; import { Icon } from 'flavours/glitch/components/icon';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
const messages = defineMessages({ const messages = defineMessages({
show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' }, show: { id: 'column_header.show_settings', defaultMessage: 'Show settings' },
@ -18,7 +20,6 @@ const messages = defineMessages({
class ColumnHeader extends PureComponent { class ColumnHeader extends PureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object, identity: PropTypes.object,
}; };
@ -38,6 +39,7 @@ class ColumnHeader extends PureComponent {
onClick: PropTypes.func, onClick: PropTypes.func,
appendContent: PropTypes.node, appendContent: PropTypes.node,
collapseIssues: PropTypes.bool, collapseIssues: PropTypes.bool,
...WithRouterPropTypes,
}; };
state = { state = {
@ -63,12 +65,12 @@ class ColumnHeader extends PureComponent {
}; };
handleBackClick = () => { handleBackClick = () => {
const { router } = this.context; const { history } = this.props;
if (router.history.location?.state?.fromMastodon) { if (history.location?.state?.fromMastodon) {
router.history.goBack(); history.goBack();
} else { } else {
router.history.push('/'); history.push('/');
} }
}; };
@ -78,15 +80,14 @@ class ColumnHeader extends PureComponent {
handlePin = () => { handlePin = () => {
if (!this.props.pinned) { if (!this.props.pinned) {
this.context.router.history.replace('/'); this.props.history.replace('/');
} }
this.props.onPin(); this.props.onPin();
}; };
render () { render () {
const { router } = this.context; const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues, history } = this.props;
const { title, icon, active, children, pinned, multiColumn, extraButton, showBackButton, intl: { formatMessage }, placeholder, appendContent, collapseIssues } = this.props;
const { collapsed, animating } = this.state; const { collapsed, animating } = this.state;
const wrapperClassName = classNames('column-header__wrapper', { const wrapperClassName = classNames('column-header__wrapper', {
@ -129,7 +130,7 @@ class ColumnHeader extends PureComponent {
pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>; pinButton = <button key='pin-button' className='text-btn column-header__setting-btn' onClick={this.handlePin}><Icon id='plus' /> <FormattedMessage id='column_header.pin' defaultMessage='Pin' /></button>;
} }
if (!pinned && ((multiColumn && router.history.location?.state?.fromMastodon) || showBackButton)) { if (!pinned && ((multiColumn && history.location?.state?.fromMastodon) || showBackButton)) {
backButton = ( backButton = (
<button onClick={this.handleBackClick} className='column-header__back-button'> <button onClick={this.handleBackClick} className='column-header__back-button'>
<Icon id='chevron-left' className='column-back-button__icon' fixedWidth /> <Icon id='chevron-left' className='column-back-button__icon' fixedWidth />
@ -215,4 +216,4 @@ class ColumnHeader extends PureComponent {
} }
export default injectIntl(ColumnHeader); export default injectIntl(withRouter(ColumnHeader));

View File

@ -4,9 +4,8 @@ import classNames from 'classnames';
import type { List } from 'immutable'; import type { List } from 'immutable';
import type { Account } from 'flavours/glitch/types/resources';
import { autoPlayGif } from '../initial_state'; import { autoPlayGif } from '../initial_state';
import type { Account } from '../types/resources';
import { Skeleton } from './skeleton'; import { Skeleton } from './skeleton';

View File

@ -1,5 +1,4 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import * as React from 'react';
import { defineMessages, useIntl } from 'react-intl'; import { defineMessages, useIntl } from 'react-intl';

View File

@ -2,13 +2,16 @@ import PropTypes from 'prop-types';
import { PureComponent, cloneElement, Children } from 'react'; import { PureComponent, cloneElement, Children } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import Overlay from 'react-overlays/Overlay'; import Overlay from 'react-overlays/Overlay';
import { CircularProgress } from "./circular_progress"; import { CircularProgress } from 'flavours/glitch/components/circular_progress';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true; const listenerOptions = supportsPassiveEvents ? { passive: true, capture: true } : true;
@ -16,10 +19,6 @@ let id = 0;
class DropdownMenu extends PureComponent { class DropdownMenu extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired, items: PropTypes.oneOfType([PropTypes.array, ImmutablePropTypes.list]).isRequired,
loading: PropTypes.bool, loading: PropTypes.bool,
@ -159,11 +158,7 @@ class DropdownMenu extends PureComponent {
} }
export default class Dropdown extends PureComponent { class Dropdown extends PureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
@ -183,6 +178,7 @@ export default class Dropdown extends PureComponent {
renderItem: PropTypes.func, renderItem: PropTypes.func,
renderHeader: PropTypes.func, renderHeader: PropTypes.func,
onItemClick: PropTypes.func, onItemClick: PropTypes.func,
...WithRouterPropTypes
}; };
static defaultProps = { static defaultProps = {
@ -250,7 +246,7 @@ export default class Dropdown extends PureComponent {
item.action(); item.action();
} else if (item && item.to) { } else if (item && item.to) {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(item.to); this.props.history.push(item.to);
} }
}; };
@ -338,3 +334,5 @@ export default class Dropdown extends PureComponent {
} }
} }
export default withRouter(Dropdown);

View File

@ -1,5 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import * as React from 'react';
interface Props { interface Props {
src: string; src: string;

View File

@ -25,11 +25,11 @@ class SilentErrorBoundary extends Component {
error: false, error: false,
}; };
componentDidCatch () { componentDidCatch() {
this.setState({ error: true }); this.setState({ error: true });
} }
render () { render() {
if (this.state.error) { if (this.state.error) {
return null; return null;
} }

View File

@ -0,0 +1,234 @@
import { useState, useCallback } from 'react';
import { FormattedMessage } from 'react-intl';
import { Link } from 'react-router-dom';
import type { List, Record } from 'immutable';
import { groupBy, minBy } from 'lodash';
import { getStatusContent } from './status_content';
// Fit on a single line on desktop
const VISIBLE_HASHTAGS = 3;
// Those types are not correct, they need to be replaced once this part of the state is typed
export type TagLike = Record<{ name: string }>;
export type StatusLike = Record<{
tags: List<TagLike>;
contentHTML: string;
media_attachments: List<unknown>;
spoiler_text?: string;
}>;
function normalizeHashtag(hashtag: string) {
return (
hashtag && hashtag.startsWith('#') ? hashtag.slice(1) : hashtag
).normalize('NFKC');
}
function isNodeLinkHashtag(element: Node): element is HTMLLinkElement {
return (
element instanceof HTMLAnchorElement &&
// it may be a <a> starting with a hashtag
(element.textContent?.[0] === '#' ||
// or a #<a>
element.previousSibling?.textContent?.[
element.previousSibling.textContent.length - 1
] === '#')
);
}
/**
* Removes duplicates from an hashtag list, case-insensitive, keeping only the best one
* "Best" here is defined by the one with the more casing difference (ie, the most camel-cased one)
* @param hashtags The list of hashtags
* @returns The input hashtags, but with only 1 occurence of each (case-insensitive)
*/
function uniqueHashtagsWithCaseHandling(hashtags: string[]) {
const groups = groupBy(hashtags, (tag) =>
tag.normalize('NFKD').toLowerCase(),
);
return Object.values(groups).map((tags) => {
if (tags.length === 1) return tags[0];
// The best match is the one where we have the less difference between upper and lower case letter count
const best = minBy(tags, (tag) => {
const upperCase = Array.from(tag).reduce(
(acc, char) => (acc += char.toUpperCase() === char ? 1 : 0),
0,
);
const lowerCase = tag.length - upperCase;
return Math.abs(lowerCase - upperCase);
});
return best ?? tags[0];
});
}
// Create the collator once, this is much more efficient
const collator = new Intl.Collator(undefined, {
sensitivity: 'base', // we use this to emulate the ASCII folding done on the server-side, hopefuly more efficiently
});
function localeAwareInclude(collection: string[], value: string) {
const normalizedValue = value.normalize('NFKC');
return !!collection.find(
(item) => collator.compare(item.normalize('NFKC'), normalizedValue) === 0,
);
}
// We use an intermediate function here to make it easier to test
export function computeHashtagBarForStatus(status: StatusLike): {
statusContentProps: { statusContent: string };
hashtagsInBar: string[];
} {
let statusContent = getStatusContent(status);
const tagNames = status
.get('tags')
.map((tag) => tag.get('name'))
.toJS();
// this is returned if we stop the processing early, it does not change what is displayed
const defaultResult = {
statusContentProps: { statusContent },
hashtagsInBar: [],
};
// return early if this status does not have any tags
if (tagNames.length === 0) return defaultResult;
const template = document.createElement('template');
template.innerHTML = statusContent.trim();
const lastChild = template.content.lastChild;
if (!lastChild || lastChild.nodeType === Node.TEXT_NODE) return defaultResult;
template.content.removeChild(lastChild);
const contentWithoutLastLine = template;
// First, try to parse
const contentHashtags = Array.from(
contentWithoutLastLine.content.querySelectorAll<HTMLLinkElement>('a[href]'),
).reduce<string[]>((result, link) => {
if (isNodeLinkHashtag(link)) {
if (link.textContent) result.push(normalizeHashtag(link.textContent));
}
return result;
}, []);
// Now we parse the last line, and try to see if it only contains hashtags
const lastLineHashtags: string[] = [];
// try to see if the last line is only hashtags
let onlyHashtags = true;
const normalizedTagNames = tagNames.map((tag) => tag.normalize('NFKC'));
Array.from(lastChild.childNodes).forEach((node) => {
if (isNodeLinkHashtag(node) && node.textContent) {
const normalized = normalizeHashtag(node.textContent);
if (!localeAwareInclude(normalizedTagNames, normalized)) {
// stop here, this is not a real hashtag, so consider it as text
onlyHashtags = false;
return;
}
if (!localeAwareInclude(contentHashtags, normalized))
// only add it if it does not appear in the rest of the content
lastLineHashtags.push(normalized);
} else if (node.nodeType !== Node.TEXT_NODE || node.nodeValue?.trim()) {
// not a space
onlyHashtags = false;
}
});
const hashtagsInBar = tagNames.filter((tag) => {
const normalizedTag = tag.normalize('NFKC');
// the tag does not appear at all in the status content, it is an out-of-band tag
return (
!localeAwareInclude(contentHashtags, normalizedTag) &&
!localeAwareInclude(lastLineHashtags, normalizedTag)
);
});
const isOnlyOneLine = contentWithoutLastLine.content.childElementCount === 0;
const hasMedia = status.get('media_attachments').size > 0;
const hasSpoiler = !!status.get('spoiler_text');
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- due to https://github.com/microsoft/TypeScript/issues/9998
if (onlyHashtags && ((hasMedia && !hasSpoiler) || !isOnlyOneLine)) {
// if the last line only contains hashtags, and we either:
// - have other content in the status
// - dont have other content, but a media and no CW. If it has a CW, then we do not remove the content to avoid having an empty content behind the CW button
statusContent = contentWithoutLastLine.innerHTML;
// and add the tags to the bar
hashtagsInBar.push(...lastLineHashtags);
}
return {
statusContentProps: { statusContent },
hashtagsInBar: uniqueHashtagsWithCaseHandling(hashtagsInBar),
};
}
/**
* This function will process a status to, at the same time (avoiding parsing it twice):
* - build the HashtagBar for this status
* - remove the last-line hashtags from the status content
* @param status The status to process
* @returns Props to be passed to the <StatusContent> component, and the hashtagBar to render
*/
export function getHashtagBarForStatus(status: StatusLike) {
const { statusContentProps, hashtagsInBar } =
computeHashtagBarForStatus(status);
return {
statusContentProps,
hashtagBar: <HashtagBar hashtags={hashtagsInBar} />,
};
}
const HashtagBar: React.FC<{
hashtags: string[];
}> = ({ hashtags }) => {
const [expanded, setExpanded] = useState(false);
const handleClick = useCallback(() => {
setExpanded(true);
}, []);
if (hashtags.length === 0) {
return null;
}
const revealedHashtags = expanded
? hashtags
: hashtags.slice(0, VISIBLE_HASHTAGS);
return (
<div className='hashtag-bar'>
{revealedHashtags.map((hashtag) => (
<Link key={hashtag} to={`/tags/${hashtag}`}>
#<span>{hashtag}</span>
</Link>
))}
{!expanded && hashtags.length > VISIBLE_HASHTAGS && (
<button className='link-button' onClick={handleClick}>
<FormattedMessage
id='hashtags.and_other'
defaultMessage='…and {count, plural, other {# more}}'
values={{ count: hashtags.length - VISIBLE_HASHTAGS }}
/>
</button>
)}
</div>
);
};

View File

@ -1,5 +1,3 @@
import * as React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
interface Props extends React.HTMLAttributes<HTMLImageElement> { interface Props extends React.HTMLAttributes<HTMLImageElement> {

View File

@ -1,4 +1,4 @@
import * as React from 'react'; import { PureComponent } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
@ -33,7 +33,7 @@ interface States {
activate: boolean; activate: boolean;
deactivate: boolean; deactivate: boolean;
} }
export class IconButton extends React.PureComponent<Props, States> { export class IconButton extends PureComponent<Props, States> {
static defaultProps = { static defaultProps = {
size: 18, size: 18,
active: false, active: false,

View File

@ -1,5 +1,3 @@
import * as React from 'react';
import { Icon } from './icon'; import { Icon } from './icon';
const formatNumber = (num: number): number | string => (num > 40 ? '40+' : num); const formatNumber = (num: number): number | string => (num > 40 ? '40+' : num);

View File

@ -3,6 +3,7 @@ import { cloneElement, Component } from 'react';
import getRectFromEntry from '../features/ui/util/get_rect_from_entry'; import getRectFromEntry from '../features/ui/util/get_rect_from_entry';
import scheduleIdleTask from '../features/ui/util/schedule_idle_task'; import scheduleIdleTask from '../features/ui/util/schedule_idle_task';
// Diff these props in the "unrendered" state // Diff these props in the "unrendered" state
const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight']; const updateOnPropsForUnrendered = ['id', 'index', 'listLength', 'cachedHeight'];
@ -38,7 +39,6 @@ export default class IntersectionObserverArticle extends Component {
return true; return true;
} }
componentDidMount () { componentDidMount () {
const { intersectionObserverWrapper, id } = this.props; const { intersectionObserverWrapper, id } = this.props;
@ -106,24 +106,24 @@ export default class IntersectionObserverArticle extends Component {
const { children, id, index, listLength, cachedHeight } = this.props; const { children, id, index, listLength, cachedHeight } = this.props;
const { isIntersecting, isHidden } = this.state; const { isIntersecting, isHidden } = this.state;
const style = {};
if (!isIntersecting && (isHidden || cachedHeight)) { if (!isIntersecting && (isHidden || cachedHeight)) {
style.height = `${this.height || cachedHeight || 150}px`;
style.opacity = 0;
style.overflow = 'hidden';
}
return ( return (
<article <article
ref={this.handleRef} ref={this.handleRef}
aria-posinset={index + 1} aria-posinset={index + 1}
aria-setsize={listLength} aria-setsize={listLength}
style={{ height: `${this.height || cachedHeight}px`, opacity: 0, overflow: 'hidden' }}
data-id={id} data-id={id}
tabIndex={0} tabIndex={-1}
style={style}
> >
{children && cloneElement(children, { hidden: !isIntersecting && (isHidden || !!cachedHeight) })} {children && cloneElement(children, { hidden: true })}
</article>
);
}
return (
<article ref={this.handleRef} aria-posinset={index + 1} aria-setsize={listLength} data-id={id} tabIndex={-1}>
{children && cloneElement(children, { hidden: false })}
</article> </article>
); );
} }

View File

@ -11,7 +11,8 @@ import ImmutablePropTypes from 'react-immutable-proptypes';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import { Blurhash } from 'flavours/glitch/components/blurhash'; import { Blurhash } from 'flavours/glitch/components/blurhash';
import { autoPlayGif, displayMedia, useBlurhash } from 'flavours/glitch/initial_state';
import { autoPlayGif, displayMedia, useBlurhash } from '../initial_state';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';

View File

@ -2,14 +2,13 @@ import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
import 'wicg-inert'; import 'wicg-inert';
import { multiply } from 'color-blend'; import { multiply } from 'color-blend';
import { createBrowserHistory } from 'history'; import { createBrowserHistory } from 'history';
export default class ModalRoot extends PureComponent { import { WithOptionalRouterPropTypes, withOptionalRouter } from 'flavours/glitch/utils/react_router';
static contextTypes = { class ModalRoot extends PureComponent {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
children: PropTypes.node, children: PropTypes.node,
@ -21,6 +20,7 @@ export default class ModalRoot extends PureComponent {
}), }),
noEsc: PropTypes.bool, noEsc: PropTypes.bool,
ignoreFocus: PropTypes.bool, ignoreFocus: PropTypes.bool,
...WithOptionalRouterPropTypes,
}; };
activeElement = this.props.children ? document.activeElement : null; activeElement = this.props.children ? document.activeElement : null;
@ -56,7 +56,7 @@ export default class ModalRoot extends PureComponent {
componentDidMount () { componentDidMount () {
window.addEventListener('keyup', this.handleKeyUp, false); window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false); window.addEventListener('keydown', this.handleKeyDown, false);
this.history = this.context.router ? this.context.router.history : createBrowserHistory(); this.history = this.props.history || createBrowserHistory();
if (this.props.children) { if (this.props.children) {
this._handleModalOpen(); this._handleModalOpen();
@ -110,8 +110,9 @@ export default class ModalRoot extends PureComponent {
} }
_handleModalClose () { _handleModalClose () {
if (this.unlistenHistory) {
this.unlistenHistory(); this.unlistenHistory();
}
const { state } = this.history.location; const { state } = this.history.location;
if (state && state.mastodonModalKey === this._modalHistoryKey) { if (state && state.mastodonModalKey === this._modalHistoryKey) {
this.history.goBack(); this.history.goBack();
@ -160,3 +161,5 @@ export default class ModalRoot extends PureComponent {
} }
} }
export default withOptionalRouter(ModalRoot);

View File

@ -1,36 +0,0 @@
import { PureComponent } from 'react';
import { Switch, Route, withRouter } from 'react-router-dom';
import AccountNavigation from 'flavours/glitch/features/account/navigation';
import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
import { showTrends } from 'flavours/glitch/initial_state';
const DefaultNavigation = () => (
showTrends ? (
<>
<div className='flex-spacer' />
<Trends />
</>
) : null
);
class NavigationPortal extends PureComponent {
render () {
return (
<Switch>
<Route path='/@:acct' exact component={AccountNavigation} />
<Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} />
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
<Route path='/@:acct/followers' exact component={AccountNavigation} />
<Route path='/@:acct/following' exact component={AccountNavigation} />
<Route path='/@:acct/media' exact component={AccountNavigation} />
<Route component={DefaultNavigation} />
</Switch>
);
}
}
export default withRouter(NavigationPortal);

View File

@ -0,0 +1,25 @@
import { Switch, Route } from 'react-router-dom';
import AccountNavigation from 'flavours/glitch/features/account/navigation';
import Trends from 'flavours/glitch/features/getting_started/containers/trends_container';
import { showTrends } from 'flavours/glitch/initial_state';
const DefaultNavigation: React.FC = () =>
showTrends ? (
<>
<div className='flex-spacer' />
<Trends />
</>
) : null;
export const NavigationPortal: React.FC = () => (
<Switch>
<Route path='/@:acct' exact component={AccountNavigation} />
<Route path='/@:acct/tagged/:tagged?' exact component={AccountNavigation} />
<Route path='/@:acct/with_replies' exact component={AccountNavigation} />
<Route path='/@:acct/followers' exact component={AccountNavigation} />
<Route path='/@:acct/following' exact component={AccountNavigation} />
<Route path='/@:acct/media' exact component={AccountNavigation} />
<Route component={DefaultNavigation} />
</Switch>
);

View File

@ -1,5 +1,3 @@
import * as React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
export const NotSignedInIndicator: React.FC = () => ( export const NotSignedInIndicator: React.FC = () => (

View File

@ -1,11 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { PureComponent } from 'react'; import { PureComponent } from 'react';
export default class Permalink extends PureComponent { import { withOptionalRouter, WithOptionalRouterPropTypes } from 'flavours/glitch/utils/react_router';
static contextTypes = { class Permalink extends PureComponent {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
className: PropTypes.string, className: PropTypes.string,
@ -13,6 +11,7 @@ export default class Permalink extends PureComponent {
to: PropTypes.string.isRequired, to: PropTypes.string.isRequired,
children: PropTypes.node, children: PropTypes.node,
onInterceptClick: PropTypes.func, onInterceptClick: PropTypes.func,
...WithOptionalRouterPropTypes,
}; };
handleClick = (e) => { handleClick = (e) => {
@ -22,9 +21,9 @@ export default class Permalink extends PureComponent {
return; return;
} }
if (this.context.router) { if (this.props.history) {
e.preventDefault(); e.preventDefault();
this.context.router.history.push(this.props.to); this.props.history.push(this.props.to);
} }
} }
}; };
@ -47,3 +46,5 @@ export default class Permalink extends PureComponent {
} }
} }
export default withOptionalRouter(Permalink);

View File

@ -16,7 +16,6 @@ import Motion from 'flavours/glitch/features/ui/util/optional_motion';
import { RelativeTimestamp } from './relative_timestamp'; import { RelativeTimestamp } from './relative_timestamp';
const messages = defineMessages({ const messages = defineMessages({
closed: { closed: {
id: 'poll.closed', id: 'poll.closed',

View File

@ -1,5 +1,3 @@
import * as React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
interface Props { interface Props {

View File

@ -1,15 +1,22 @@
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
import React from 'react'; import React from 'react';
import { createBrowserHistory } from 'history';
import { Router as OriginalRouter } from 'react-router'; import { Router as OriginalRouter } from 'react-router';
import type {
LocationDescriptor,
LocationDescriptorObject,
Path,
} from 'history';
import { createBrowserHistory } from 'history';
import { layoutFromWindow } from 'flavours/glitch/is_mobile'; import { layoutFromWindow } from 'flavours/glitch/is_mobile';
interface MastodonLocationState { interface MastodonLocationState {
fromMastodon?: boolean; fromMastodon?: boolean;
mastodonModalKey?: string; mastodonModalKey?: string;
} }
type HistoryPath = Path | LocationDescriptor<MastodonLocationState>;
const browserHistory = createBrowserHistory< const browserHistory = createBrowserHistory<
MastodonLocationState | undefined MastodonLocationState | undefined
@ -17,28 +24,55 @@ const browserHistory = createBrowserHistory<
const originalPush = browserHistory.push.bind(browserHistory); const originalPush = browserHistory.push.bind(browserHistory);
const originalReplace = browserHistory.replace.bind(browserHistory); const originalReplace = browserHistory.replace.bind(browserHistory);
browserHistory.push = (path: string, state?: MastodonLocationState) => { function normalizePath(
state = state ?? {}; path: HistoryPath,
state.fromMastodon = true; state?: MastodonLocationState,
): LocationDescriptorObject<MastodonLocationState> {
const location = typeof path === 'string' ? { pathname: path } : { ...path };
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) { if (location.state === undefined && state !== undefined) {
originalPush(`/deck${path}`, state); location.state = state;
} else { } else if (
originalPush(path, state); location.state !== undefined &&
state !== undefined &&
process.env.NODE_ENV === 'development'
) {
// eslint-disable-next-line no-console
console.log(
'You should avoid providing a 2nd state argument to push when the 1st argument is a location-like object that already has state; it is ignored',
);
} }
if (
layoutFromWindow() === 'multi-column' &&
!location.pathname?.startsWith('/deck')
) {
location.pathname = `/deck${location.pathname}`;
}
return location;
}
browserHistory.push = (path: HistoryPath, state?: MastodonLocationState) => {
const location = normalizePath(path, state);
location.state = location.state ?? {};
location.state.fromMastodon = true;
originalPush(location);
}; };
browserHistory.replace = (path: string, state?: MastodonLocationState) => { browserHistory.replace = (path: HistoryPath, state?: MastodonLocationState) => {
const location = normalizePath(path, state);
if (!location.pathname) return;
if (browserHistory.location.state?.fromMastodon) { if (browserHistory.location.state?.fromMastodon) {
state = state ?? {}; location.state = location.state ?? {};
state.fromMastodon = true; location.state.fromMastodon = true;
} }
if (layoutFromWindow() === 'multi-column' && !path.startsWith('/deck')) { originalReplace(location);
originalReplace(`/deck${path}`, state);
} else {
originalReplace(path, state);
}
}; };
export const Router: React.FC<PropsWithChildren> = ({ children }) => { export const Router: React.FC<PropsWithChildren> = ({ children }) => {

View File

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import { Children, cloneElement, PureComponent } from 'react'; import { Children, cloneElement, PureComponent } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { useLocation } from 'react-router-dom';
import { List as ImmutableList } from 'immutable'; import { List as ImmutableList } from 'immutable';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -9,11 +10,11 @@ import { connect } from 'react-redux';
import { supportsPassiveEvents } from 'detect-passive-events'; import { supportsPassiveEvents } from 'detect-passive-events';
import { throttle } from 'lodash'; import { throttle } from 'lodash';
import IntersectionObserverArticleContainer from 'flavours/glitch/containers/intersection_observer_article_container';
import ScrollContainer from 'flavours/glitch/containers/scroll_container'; import ScrollContainer from 'flavours/glitch/containers/scroll_container';
import IntersectionObserverWrapper from 'flavours/glitch/features/ui/util/intersection_observer_wrapper';
import IntersectionObserverArticleContainer from '../containers/intersection_observer_article_container';
import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen'; import { attachFullscreenListener, detachFullscreenListener, isFullscreen } from '../features/ui/util/fullscreen';
import IntersectionObserverWrapper from '../features/ui/util/intersection_observer_wrapper';
import { LoadMore } from './load_more'; import { LoadMore } from './load_more';
import { LoadPending } from './load_pending'; import { LoadPending } from './load_pending';
@ -34,11 +35,32 @@ const mapStateToProps = (state, { scrollKey }) => {
}; };
}; };
class ScrollableList extends PureComponent { // This component only exists to be able to call useLocation()
const IOArticleContainerWrapper = ({id, index, listLength, intersectionObserverWrapper, trackScroll, scrollKey, children}) => {
const location = useLocation();
static contextTypes = { return (<IntersectionObserverArticleContainer
router: PropTypes.object, id={id}
}; index={index}
listLength={listLength}
intersectionObserverWrapper={intersectionObserverWrapper}
saveHeightKey={trackScroll ? `${location.key}:${scrollKey}` : null}
>
{children}
</IntersectionObserverArticleContainer>);
};
IOArticleContainerWrapper.propTypes = {
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
index: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
listLength: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
scrollKey: PropTypes.string.isRequired,
intersectionObserverWrapper: PropTypes.object.isRequired,
trackScroll: PropTypes.bool.isRequired,
children: PropTypes.node,
};
class ScrollableList extends PureComponent {
static propTypes = { static propTypes = {
scrollKey: PropTypes.string.isRequired, scrollKey: PropTypes.string.isRequired,
@ -331,13 +353,14 @@ class ScrollableList extends PureComponent {
{loadPending} {loadPending}
{Children.map(this.props.children, (child, index) => ( {Children.map(this.props.children, (child, index) => (
<IntersectionObserverArticleContainer <IOArticleContainerWrapper
key={child.key} key={child.key}
id={child.key} id={child.key}
index={index} index={index}
listLength={childrenCount} listLength={childrenCount}
intersectionObserverWrapper={this.intersectionObserverWrapper} intersectionObserverWrapper={this.intersectionObserverWrapper}
saveHeightKey={trackScroll ? `${this.context.router.route.location.key}:${scrollKey}` : null} trackScroll={trackScroll}
scrollKey={scrollKey}
> >
{cloneElement(child, { {cloneElement(child, {
getScrollPosition: this.getScrollPosition, getScrollPosition: this.getScrollPosition,
@ -345,7 +368,7 @@ class ScrollableList extends PureComponent {
cachedMediaWidth: this.state.cachedMediaWidth, cachedMediaWidth: this.state.cachedMediaWidth,
cacheMediaWidth: this.cacheMediaWidth, cacheMediaWidth: this.cacheMediaWidth,
})} })}
</IntersectionObserverArticleContainer> </IOArticleContainerWrapper>
))} ))}
{loadMore} {loadMore}

View File

@ -1,5 +1,4 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import * as React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';

View File

@ -1,5 +1,3 @@
import * as React from 'react';
interface Props { interface Props {
width?: number | string; width?: number | string;
height?: number | string; height?: number | string;

View File

@ -12,14 +12,18 @@ import { HotKeys } from 'react-hotkeys';
import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder'; import PictureInPicturePlaceholder from 'flavours/glitch/components/picture_in_picture_placeholder';
import PollContainer from 'flavours/glitch/containers/poll_container'; import PollContainer from 'flavours/glitch/containers/poll_container';
import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
import { displayMedia } from 'flavours/glitch/initial_state';
import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning'; import { autoUnfoldCW } from 'flavours/glitch/utils/content_warning';
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'flavours/glitch/utils/react_router';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
// We use the component (and not the container) since we do not want
// to use the progress bar to show download progress
import Bundle from '../features/ui/components/bundle'; import Bundle from '../features/ui/components/bundle';
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { displayMedia } from '../initial_state';
import AttachmentList from './attachment_list'; import AttachmentList from './attachment_list';
import { getHashtagBarForStatus } from './hashtag_bar';
import StatusActionBar from './status_action_bar'; import StatusActionBar from './status_action_bar';
import StatusContent from './status_content'; import StatusContent from './status_content';
import StatusHeader from './status_header'; import StatusHeader from './status_header';
@ -67,10 +71,6 @@ export const defaultMediaVisibility = (status, settings) => {
class Status extends ImmutablePureComponent { class Status extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
};
static propTypes = { static propTypes = {
containerId: PropTypes.string, containerId: PropTypes.string,
id: PropTypes.string, id: PropTypes.string,
@ -118,6 +118,7 @@ class Status extends ImmutablePureComponent {
inUse: PropTypes.bool, inUse: PropTypes.bool,
available: PropTypes.bool, available: PropTypes.bool,
}), }),
...WithOptionalRouterPropTypes,
}; };
state = { state = {
@ -357,10 +358,9 @@ class Status extends ImmutablePureComponent {
// Otherwise, we open the url handed to us in `destination`, if // Otherwise, we open the url handed to us in `destination`, if
// applicable. // applicable.
parseClick = (e, destination) => { parseClick = (e, destination) => {
const { router } = this.context; const { status, history } = this.props;
const { status } = this.props;
const { isCollapsed } = this.state; const { isCollapsed } = this.state;
if (!router) return; if (!history) return;
if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) { if (e.button === 0 && !(e.ctrlKey || e.altKey || e.metaKey)) {
if (isCollapsed) this.setCollapsed(false); if (isCollapsed) this.setCollapsed(false);
@ -378,7 +378,7 @@ class Status extends ImmutablePureComponent {
status.getIn(['reblog', 'id'], status.get('id')) status.getIn(['reblog', 'id'], status.get('id'))
}`; }`;
} }
router.history.push(destination); history.push(destination);
} }
e.preventDefault(); e.preventDefault();
} }
@ -432,7 +432,7 @@ class Status extends ImmutablePureComponent {
handleHotkeyReply = e => { handleHotkeyReply = e => {
e.preventDefault(); e.preventDefault();
this.props.onReply(this.props.status, this.context.router.history); this.props.onReply(this.props.status, this.props.history);
}; };
handleHotkeyFavourite = (e) => { handleHotkeyFavourite = (e) => {
@ -449,16 +449,16 @@ class Status extends ImmutablePureComponent {
handleHotkeyMention = e => { handleHotkeyMention = e => {
e.preventDefault(); e.preventDefault();
this.props.onMention(this.props.status.get('account'), this.context.router.history); this.props.onMention(this.props.status.get('account'), this.props.history);
}; };
handleHotkeyOpen = () => { handleHotkeyOpen = () => {
const status = this.props.status; const status = this.props.status;
this.context.router.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`); this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
}; };
handleHotkeyOpenProfile = () => { handleHotkeyOpenProfile = () => {
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`); this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}`);
}; };
handleHotkeyMoveUp = e => { handleHotkeyMoveUp = e => {
@ -515,7 +515,6 @@ class Status extends ImmutablePureComponent {
parseClick, parseClick,
setCollapsed, setCollapsed,
} = this; } = this;
const { router } = this.context;
const { const {
intl, intl,
status, status,
@ -534,6 +533,7 @@ class Status extends ImmutablePureComponent {
previousId, previousId,
nextInReplyToId, nextInReplyToId,
rootId, rootId,
history,
...other ...other
} = this.props; } = this.props;
const { isCollapsed } = this.state; const { isCollapsed } = this.state;
@ -783,6 +783,9 @@ class Status extends ImmutablePureComponent {
muted, muted,
}, 'focusable'); }, 'focusable');
const {statusContentProps, hashtagBar} = getHashtagBarForStatus(status);
contentMedia.push(hashtagBar);
return ( return (
<HotKeys handlers={handlers}> <HotKeys handlers={handlers}>
<div <div
@ -829,9 +832,10 @@ class Status extends ImmutablePureComponent {
onExpandedToggle={this.handleExpandedToggle} onExpandedToggle={this.handleExpandedToggle}
onTranslate={this.handleTranslate} onTranslate={this.handleTranslate}
parseClick={parseClick} parseClick={parseClick}
disabled={!router} disabled={!history}
tagLinks={settings.get('tag_misleading_links')} tagLinks={settings.get('tag_misleading_links')}
rewriteMentions={settings.get('rewrite_mentions')} rewriteMentions={settings.get('rewrite_mentions')}
{...statusContentProps}
/> />
{!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? ( {!isCollapsed || !(muted || !settings.getIn(['collapsed', 'show_action_bar'])) ? (
@ -855,4 +859,4 @@ class Status extends ImmutablePureComponent {
} }
export default injectIntl(Status); export default withOptionalRouter(injectIntl(Status));

View File

@ -3,14 +3,17 @@ import PropTypes from 'prop-types';
import { defineMessages, injectIntl } from 'react-intl'; import { defineMessages, injectIntl } from 'react-intl';
import classNames from 'classnames'; import classNames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import DropdownMenuContainer from 'flavours/glitch/containers/dropdown_menu_container';
import { me } from 'flavours/glitch/initial_state';
import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions'; import { PERMISSION_MANAGE_USERS, PERMISSION_MANAGE_FEDERATION } from 'flavours/glitch/permissions';
import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links'; import { accountAdminLink, statusAdminLink } from 'flavours/glitch/utils/backend_links';
import { WithRouterPropTypes } from 'flavours/glitch/utils/react_router';
import DropdownMenuContainer from '../containers/dropdown_menu_container';
import { me } from '../initial_state';
import { IconButton } from './icon_button'; import { IconButton } from './icon_button';
import { RelativeTimestamp } from './relative_timestamp'; import { RelativeTimestamp } from './relative_timestamp';
@ -54,7 +57,6 @@ const messages = defineMessages({
class StatusActionBar extends ImmutablePureComponent { class StatusActionBar extends ImmutablePureComponent {
static contextTypes = { static contextTypes = {
router: PropTypes.object,
identity: PropTypes.object, identity: PropTypes.object,
}; };
@ -82,6 +84,7 @@ class StatusActionBar extends ImmutablePureComponent {
showReplyCount: PropTypes.bool, showReplyCount: PropTypes.bool,
scrollKey: PropTypes.string, scrollKey: PropTypes.string,
intl: PropTypes.object.isRequired, intl: PropTypes.object.isRequired,
...WithRouterPropTypes,
}; };
// Avoid checking props that are functions (and whose equality will always // Avoid checking props that are functions (and whose equality will always
@ -97,12 +100,23 @@ class StatusActionBar extends ImmutablePureComponent {
const { signedIn } = this.context.identity; const { signedIn } = this.context.identity;
if (signedIn) { if (signedIn) {
this.props.onReply(this.props.status, this.context.router.history); this.props.onReply(this.props.status, this.props.history);
} else { } else {
this.props.onInteractionModal('reply', this.props.status); this.props.onInteractionModal('reply', this.props.status);
} }
}; };
handleQuoteClick = () => {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onQuote(this.props.status, this.props.history);
} else {
// TODO(ariadne): Add an interaction modal for quoting specifically.
this.props.onInteractionModal('reply', this.props.status);
}
};
handleShareClick = () => { handleShareClick = () => {
navigator.share({ navigator.share({
url: this.props.status.get('url'), url: this.props.status.get('url'),
@ -129,31 +143,20 @@ class StatusActionBar extends ImmutablePureComponent {
} }
}; };
handleQuoteClick = () => {
const { signedIn } = this.context.identity;
if (signedIn) {
this.props.onQuote(this.props.status, this.context.router.history);
} else {
// TODO(ariadne): Add an interaction modal for quoting specifically.
this.props.onInteractionModal('reply', this.props.status);
}
}
handleBookmarkClick = (e) => { handleBookmarkClick = (e) => {
this.props.onBookmark(this.props.status, e); this.props.onBookmark(this.props.status, e);
}; };
handleDeleteClick = () => { handleDeleteClick = () => {
this.props.onDelete(this.props.status, this.context.router.history); this.props.onDelete(this.props.status, this.props.history);
}; };
handleRedraftClick = () => { handleRedraftClick = () => {
this.props.onDelete(this.props.status, this.context.router.history, true); this.props.onDelete(this.props.status, this.props.history, true);
}; };
handleEditClick = () => { handleEditClick = () => {
this.props.onEdit(this.props.status, this.context.router.history); this.props.onEdit(this.props.status, this.props.history);
}; };
handlePinClick = () => { handlePinClick = () => {
@ -161,11 +164,11 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
handleMentionClick = () => { handleMentionClick = () => {
this.props.onMention(this.props.status.get('account'), this.context.router.history); this.props.onMention(this.props.status.get('account'), this.props.history);
}; };
handleDirectClick = () => { handleDirectClick = () => {
this.props.onDirect(this.props.status.get('account'), this.context.router.history); this.props.onDirect(this.props.status.get('account'), this.props.history);
}; };
handleMuteClick = () => { handleMuteClick = () => {
@ -177,12 +180,7 @@ class StatusActionBar extends ImmutablePureComponent {
}; };
handleOpen = () => { handleOpen = () => {
let state = { ...this.context.router.history.location.state }; this.props.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
if (state.mastodonModalKey) {
this.context.router.history.replace(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
} else {
this.context.router.history.push(`/@${this.props.status.getIn(['account', 'acct'])}/${this.props.status.get('id')}`);
}
}; };
handleEmbed = () => { handleEmbed = () => {
@ -355,4 +353,4 @@ class StatusActionBar extends ImmutablePureComponent {
} }
export default injectIntl(StatusActionBar); export default withRouter(injectIntl(StatusActionBar));

View File

@ -4,6 +4,7 @@ import { PureComponent } from 'react';
import { FormattedMessage, injectIntl } from 'react-intl'; import { FormattedMessage, injectIntl } from 'react-intl';
import classnames from 'classnames'; import classnames from 'classnames';
import { withRouter } from 'react-router-dom';
import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePropTypes from 'react-immutable-proptypes';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
@ -68,6 +69,15 @@ const isLinkMisleading = (link) => {
return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host)); return !(textMatchesTarget(text, origin, host) || textMatchesTarget(text.toLowerCase(), origin, host));
}; };
/**
*
* @param {any} status
* @returns {string}
*/
export function getStatusContent(status) {
return status.getIn(['translation', 'contentHtml']) || status.get('contentHtml');
}
class TranslateButton extends PureComponent { class TranslateButton extends PureComponent {
static propTypes = { static propTypes = {
@ -117,6 +127,7 @@ class StatusContent extends PureComponent {
static propTypes = { static propTypes = {
status: ImmutablePropTypes.map.isRequired, status: ImmutablePropTypes.map.isRequired,
statusContent: PropTypes.string,
expanded: PropTypes.bool, expanded: PropTypes.bool,
collapsed: PropTypes.bool, collapsed: PropTypes.bool,
onExpandedToggle: PropTypes.func, onExpandedToggle: PropTypes.func,
@ -131,6 +142,10 @@ class StatusContent extends PureComponent {
rewriteMentions: PropTypes.string, rewriteMentions: PropTypes.string,
languages: ImmutablePropTypes.map, languages: ImmutablePropTypes.map,
intl: PropTypes.object, intl: PropTypes.object,
// from react-router
match: PropTypes.object.isRequired,
location: PropTypes.object.isRequired,
history: PropTypes.object.isRequired
}; };
static defaultProps = { static defaultProps = {
@ -322,6 +337,7 @@ class StatusContent extends PureComponent {
tagLinks, tagLinks,
rewriteMentions, rewriteMentions,
intl, intl,
statusContent,
} = this.props; } = this.props;
const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden; const hidden = this.props.onExpandedToggle ? !this.props.expanded : this.state.hidden;
@ -329,7 +345,7 @@ class StatusContent extends PureComponent {
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('search_index').trim().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.getIn(['translation', 'contentHtml']) || status.get('contentHtml') }; const content = { __html: statusContent ?? getStatusContent(status) };
const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') }; const spoilerContent = { __html: status.getIn(['translation', 'spoilerHtml']) || status.get('spoilerHtml') };
const language = status.getIn(['translation', 'language']) || status.get('language'); const language = status.getIn(['translation', 'language']) || status.get('language');
const classNames = classnames('status__content', { const classNames = classnames('status__content', {
@ -506,4 +522,4 @@ class StatusContent extends PureComponent {
} }
export default connect(mapStateToProps)(injectIntl(StatusContent)); export default withRouter(connect(mapStateToProps)(injectIntl(StatusContent)));

View File

@ -6,7 +6,8 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator'; import RegenerationIndicator from 'flavours/glitch/components/regeneration_indicator';
import StatusContainer from 'flavours/glitch/containers/status_container';
import StatusContainer from '../containers/status_container';
import { LoadGap } from './load_gap'; import { LoadGap } from './load_gap';
import ScrollableList from './scrollable_list'; import ScrollableList from './scrollable_list';

View File

@ -1,5 +1,3 @@
import * as React from 'react';
import { FormattedMessage } from 'react-intl'; import { FormattedMessage } from 'react-intl';
interface Props { interface Props {

View File

@ -9,12 +9,12 @@ import {
unblockAccount, unblockAccount,
muteAccount, muteAccount,
unmuteAccount, unmuteAccount,
} from 'flavours/glitch/actions/accounts'; } from '../actions/accounts';
import { openModal } from 'flavours/glitch/actions/modal'; import { openModal } from '../actions/modal';
import { initMuteModal } from 'flavours/glitch/actions/mutes'; import { initMuteModal } from '../actions/mutes';
import Account from 'flavours/glitch/components/account'; import Account from '../components/account';
import { unfollowModal } from 'flavours/glitch/initial_state'; import { unfollowModal } from '../initial_state';
import { makeGetAccount } from 'flavours/glitch/selectors'; import { makeGetAccount } from '../selectors';
const messages = defineMessages({ const messages = defineMessages({
unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' }, unfollowConfirm: { id: 'confirmations.unfollow.confirm', defaultMessage: 'Unfollow' },

View File

@ -2,12 +2,13 @@ import { PureComponent } from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { fetchCustomEmojis } from 'flavours/glitch/actions/custom_emojis'; import { fetchCustomEmojis } from '../actions/custom_emojis';
import { hydrateStore } from 'flavours/glitch/actions/store'; import { hydrateStore } from '../actions/store';
import Compose from 'flavours/glitch/features/standalone/compose'; import Compose from '../features/standalone/compose';
import initialState from 'flavours/glitch/initial_state'; import initialState from '../initial_state';
import { IntlProvider } from 'flavours/glitch/locales'; import { IntlProvider } from '../locales';
import { store } from 'flavours/glitch/store'; import { store } from '../store';
if (initialState) { if (initialState) {
store.dispatch(hydrateStore(initialState)); store.dispatch(hydrateStore(initialState));

View File

@ -1,9 +1,8 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { openDropdownMenu, closeDropdownMenu } from 'flavours/glitch/actions/dropdown_menu'; import { openDropdownMenu, closeDropdownMenu } from '../actions/dropdown_menu';
import { openModal, closeModal } from 'flavours/glitch/actions/modal'; import { openModal, closeModal } from '../actions/modal';
import DropdownMenu from 'flavours/glitch/components/dropdown_menu'; import DropdownMenu from '../components/dropdown_menu';
import { isUserTouching } from '../is_mobile'; import { isUserTouching } from '../is_mobile';
/** /**

View File

@ -1,7 +1,7 @@
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { setHeight } from 'flavours/glitch/actions/height_cache'; import { setHeight } from '../actions/height_cache';
import IntersectionObserverArticle from 'flavours/glitch/components/intersection_observer_article'; import IntersectionObserverArticle from '../components/intersection_observer_article';
const makeMapStateToProps = (state, props) => ({ const makeMapStateToProps = (state, props) => ({
cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]), cachedHeight: state.getIn(['height_cache', props.saveHeightKey, props.id]),

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